Compare commits

...

19 Commits

Author SHA1 Message Date
Александр Петров a8d0692eb2 Add asynchronous machine over and out to shared MCP 2026-09-17 23:32:46 +03:00
Александр Петров 259c782190 Support bounded machine instruction step counts in MCP 2026-09-17 23:22:37 +03:00
Александр Петров e8189b137a Add bounded MCP text input for Sprinter programs 2026-09-17 23:17:38 +03:00
Александр Петров 958ad0ae0f Add bounded logical Z80 disassembly to shared MCP 2026-09-17 23:11:08 +03:00
Александр Петров 113d468c48 Expose owned C breakpoints through shared MCP session 2026-09-17 23:03:59 +03:00
Александр Петров 0ade3b4821 Preserve installed MAME settings in isolated ordinary runs 2026-09-17 22:59:47 +03:00
Александр Петров 5dc7998324 Expand shared Sprinter MCP with managed launch, raw reads and key input 2026-09-17 22:57:27 +03:00
Александр Петров a43e7bda89 Verify visible hello MCP run and add shared control lease 2026-09-17 18:15:01 +03:00
Александр Петров 619c4c9af5 План сведения raw и C-level MCP для Sprinter 2026-09-17 10:53:57 +03:00
Александр Петров 12667c865a Record MAME.HT raw MCP validation 2026-09-17 10:36:52 +03:00
Александр Петров 7970a27c98 Поддержать MAME.HT/sprinter в SDK и документации 2026-09-17 10:23:18 +03:00
Александр Петров 9ad5a018a2 Add shared-session MCP source debugger adapter 2026-09-16 20:43:50 +03:00
Александр Петров addc00a0f3 Handle MAME SIGTERM during source debugging 2026-09-16 20:10:33 +03:00
Александр Петров f91d296476 Проверить штатный выход MAME и загрузку state в DAP 2026-09-16 19:55:27 +03:00
Александр Петров 60762a7c0d Зафиксировать ограничение SIGTERM остановленного MAME 2026-09-16 15:13:46 +03:00
Александр Петров 0a71f94240 Завершать DAP при потере остановленного MAME 2026-09-16 15:12:42 +03:00
Александр Петров d25f28e1cd Проверить VSIX после разделения и принять размерный эталон SDK 2026-09-16 15:00:12 +03:00
Александр Петров 4ee45f0ace Уточнить итоговый статус разделения 2026-09-16 10:07:48 +03:00
Александр Петров a849a7b71d Зафиксировать упаковку VS Code extension 2026-09-16 10:07:15 +03:00
46 changed files with 3710 additions and 182 deletions
+4 -4
View File
@@ -86,12 +86,12 @@ sdbg-tests: tools lib
$(PYTHON) -m unittest discover -s tests/sdbg -v
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
size-check:
$(PYTHON) toolchain/size_check.py
size-check: tests
$(PYTHON) toolchain/size_check.py --require $(TESTS)
# Принять текущие размеры как эталон (после осознанных изменений).
size-baseline:
$(PYTHON) toolchain/size_check.py --update
size-baseline: tests
$(PYTHON) toolchain/size_check.py --update --require $(TESTS)
clean:
$(MAKE) -C toolchain/mkexe clean
+2 -1
View File
@@ -55,7 +55,8 @@ $(error SPRINTER_ROOT=$(SPRINTER_ROOT): bin/sprinter-cc не найден)
endif
MAME_HOME ?=
MAME_BIN ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/mame.arm,)
# Пустое значение выбирает MAME_HOME/sprinter или старый mame.arm в профиле.
MAME_BIN ?=
MAME_ROMPATH ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/roms,)
MAME_DSS_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/dss171u.img,)
MAME_SYSTEM_HDD_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/sp_hdd_sys.chd,)
+40
View File
@@ -279,12 +279,52 @@ Quick wins:
## Прочий backlog
- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый
stdio-адаптер с 31 инструментом использует общую sdbg-сессию и
owner ID личных точек. Полный план и матрица всех 30 raw-возможностей —
[mcp-convergence-plan.md](mcp-convergence-plan.md).
control lease для DAP/MCP и cleanup точек по истечении heartbeat
проверены живым прогоном. Привязка RPC к session/build/generation и
экранные API, одиночный `press_key` и читающий список C-точек уже
добавлены; logical Z80 disassembly и ограниченный `type_string`
проверены, машинный `step_instruction(count=1..64)` проверен на `hello`.
Машинные `over(count)`/`out` добавлены отдельно от C source-step;
они асинхронны, поскольку могут ждать пользовательский ввод.
Далее natural keyboard, управляемые поля портов и мышь,
затем доказанные
watchpoints, запись typed values, disassemble_src и batch-загрузка
точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же
MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT
после сверки 16 общих команд и живых MCP-прогонов; доказательства — в
[mcp-convergence-plan.md](mcp-convergence-plan.md).
Отдельно мигрировать raw `mame_mcp.py` с FastMCP SDK 1.x на MCPServer
SDK 2.x, если этот standalone-интерфейс сохраняется; сейчас окружения
raw и C-адаптера разделены.
Проверять конкуренцию, ошибки и lifecycle живым DAP/MCP
пробником; см. [sdbg-mcp.md](sdbg-mcp.md).
Автономный MCP `start_session`→DSS→`main`→DAP attach→`stop_session`
уже проверен официальным SDK-клиентом; остаётся испытать Codex/Claude
как реальные MCP-клиенты и передачу lease между ними и VS Code.
- [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная
задача, позже).** Проверить два изолированных процесса с разными
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
команду с PID/session ID, не допускать ответа от чужого процесса,
проверить сброс/exit и одновременные команды. До успешного end-to-end
теста параллельная работа MAME через MCP не гарантируется.
- [ ] **Финальная полировка: оградить ввод до запуска отлаживаемой программы.**
Проверить, можно ли не передавать в гостевой Sprinter физические
клавиатуру/мышь, пока MAME грузит DSS и launcher вводит команду запуска:
MAME может захватить фокус, а пользователь в это время печатает в другой
программе. По возможности до запуска EXE сохранить фокус на прежнем
приложении, чтобы оно продолжало получать ввод. Автоматический ввод
команды launcher должен оставаться рабочим. При переходе к исполнению
EXE сразу разрешить прямой ввод в MAME, в том числе для `getchar()`;
при Stop, ошибке запуска и restart корректно сбрасывать состояние.
Если MAME не умеет различать эти два
источника ввода, исследовать управление фокусом/захватом окна либо
узкий patch MAME; не блокировать ввод ценой поломки запуска.
Проверить ручное переключение окон, сохранение ввода в другом приложении
и ранние нажатия живым сценарием.
- [ ] factoring parse_argv из crt0/crt0_banked в общий argv.s
- [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность
- [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~
+5 -1
View File
@@ -484,7 +484,11 @@ global/static переменной, ненулевое значение озна
[руководство по SDBG_LOG](sdbg-log-macros.md).
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console
при подключённой source-debug сессии; без неё макрос сам ничего не печатает.
при подключённой source-debug сессии, а MCP-клиент читает тот же журнал
через `recent_events` ([руководство по MCP](sdbg-mcp.md)); без сессии макрос
сам ничего не печатает. Новый `MAME.HT/sprinter` 0.289 проверен с тем же
DAP-маршрутом в `sdbg`/`osx`; выбор бинарника описан в
[руководстве VS Code](vscode-sprinter-debug.md).
## <sprinter.h> — платформа
+4 -2
View File
@@ -86,8 +86,10 @@ python3 toolchain/mame_interactive.py /путь/к/Examples/mdview2/mdview2.exe
### 3.1 Запуск MAME
Профиль `MAME_HOME` выбирает `mame.arm`, `roms/`, DSS-дискету и системный
CHD. A: — временная дискета этого запуска; B: — DSS. При наличии в
Профиль `MAME_HOME` выбирает `sprinter` (новый MAME.HT) либо `mame.arm`
(прежняя установка), `roms/`, DSS-дискету и системный CHD. `MAME_BIN` явно
выбирает бинарник независимо от каталога ресурсов. A: — временная дискета
этого запуска; B: — DSS. При наличии в
установленной среде CD/NeoGS/медиа скрипт подключает их как необязательные
устройства. `-cfg_directory`, `-nvram_directory`, `-diff_directory` и
`-snapshot_directory` указывают в изолированный каталог сессии;
+108 -28
View File
@@ -1,8 +1,9 @@
# Отладка исходников: реализация и результаты
Дата: 2026-09-15. План: [mame-source-debug.md](mame-source-debug.md).
Дата: 2026-09-16. План: [mame-source-debug.md](mame-source-debug.md).
Реализованы сборка/карта, проверенный транспорт, базовая C-сессия,
постоянный session server, DAP MVP, VS Code launch и build task. MCP C-уровня,
постоянный session server, DAP MVP, VS Code launch/build task и первый
stdio MCP-адаптер C-уровня. Эксклюзивное владение CPU между клиентами,
безопасный restart и расширенная отладка ещё не готовы.
Этапы плана не считаются завершёнными целиком по одному успешному репро.
@@ -10,6 +11,10 @@
- **macOS:** текущий end-to-end цикл проверен, включая backend `sdbg` и
одновременную работу VS Code со штатным `osx` debugger MAME.
Новый `MAME.HT/sprinter` (0.289, Holub/Tolik) прошёл живой DAP launch с
прежними ROM/DSS: prompt на строке 22, `false_hits=0`, остановка в
`hello.c:17` (`main`, PC `0x8224`). У него уже были те же исходники
`sdbg` и `mamebridge`; рецепты сборки перенесены с новым именем бинарника.
- **Linux:** ожидается работа через `sdbg`, `qt` или `imgui`, но полный
живой прогон ещё не выполнен.
- **Windows:** весь функционал пока работать не будет. Provider
@@ -65,6 +70,11 @@
логические группы и события. `set_source_breakpoints` и
`set_function_breakpoints` создают новый disabled-набор, при ошибке удаляют
его, затем заменяют прежний набор и активируют точки.
- Session server проверяет `snapshot` и при остановленном CPU раз в 0,5 с:
reset/state load инвалидирует сессию, потеря MAME приводит к закрытию после
ограниченного timeout backend. Если выполнение продолжено из родного окна
debugger MAME, server сообщает DAP смену состояния. Закрытая сессия всегда
выдаёт DAP `terminated`, даже если событие `invalidated` выпало из журнала.
- DAP MVP и минимальное VS Code-расширение: attach к session server,
source/function breakpoints, один достоверный frame, registers,
поддержанные globals/statics, evaluate одного имени, continue/pause и
@@ -80,8 +90,9 @@
для приложений с `app.mk`, команду Build Active Project и автоматическую
debug-сборку перед F5. Она использует `make SRC_DEBUG=1` и local Python 3.12;
ошибки SDCC с файлом/строкой попадают в Problems. Код сборки проверяется
самим расширением: при ошибке MAME не запускается. Run-команда, расширенные
linker/assembler diagnostics, выбор profile/EXTRA_DATA и VSIX остаются
самим расширением: при ошибке MAME не запускается. VSIX 0.2.0 собран и
проверен в отдельном workspace SprPoP. Run-команда, расширенные
linker/assembler diagnostics и выбор profile/EXTRA_DATA остаются
следующими шагами.
- DAP `logMessage` без пересборки: разрешены литералы, `{{`/`}}` и только
подстановки `{variable}`. Session server читает типизированное значение,
@@ -112,7 +123,25 @@
копию системного HDD и отдельные state-каталоги, показывает окно Sprinter,
ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`,
проверяет сигнатуру entry и запускает session server. Закрытие DAP
завершает только созданные им server/MAME и удаляет временный каталог.
завершает только созданные им server/MAME, удаляет временный каталог и
свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс).
- `sdbg_mcp.py` предоставляет 31 инструмент через официальный MCP SDK
2.x поверх того же session server: статус, C-позиция, регистры, logical
memory, переменные, события/логи, личные точки и команды исполнения.
MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном
закрытии MCP-клиента они очищаются. Живой совместный прогон проверил
структурированные ответы, отказ на удаление чужой точки и последующее
срабатывание точки DAP с provider `sdbg` и `osx`. Отдельный WAITKEY-прогон
подтвердил удаление личной точки при running CPU и дальнейшее завершение
шага после клавиши.
Повторный запуск на том же фиксированном socket после прежнего stale-файла
прошёл; после Stop путь удалён.
В автономном режиме доступны 33 инструмента: официальный MCP-клиент
запустил `hello`, ввёл `x` через публичный `press_key` во время `getchar()`
и остановился на следующей C-строке. Остальные raw-возможности ввода
перечислены в [матрице](mcp-capability-matrix.md).
Контракт запуска и ограничения — в
[руководстве по MCP](sdbg-mcp.md).
## Использование карты
@@ -149,9 +178,8 @@ pyenv exec python toolchain/sdbg_session.py \
Доступны `where`, `step`, `continue`, `break-line`, `break-function`,
`read-variable`, `activate-breakpoints` и `deactivate-breakpoints`.
Это диагностический однооперационный CLI: логические ID групп точек живут
только внутри процесса. Постоянное владение и DAP добавятся в общем session
server; до него для долгой ручной работы нужен один Python-процесс с
`DebugSession`.
только внутри процесса. Для долгой работы и DAP используйте общий session
server ниже.
Предпочтительный режим для нескольких клиентов — один server:
@@ -165,7 +193,7 @@ pyenv exec python toolchain/sdbg_client.py \
--socket /tmp/sprinter-sdbg.sock status
```
VS Code-расширение находится в `toolchain/vscode-sprinter-debug/`. В
VS Code-расширение находится в отдельном репозитории `../VSCode-Sprinter`. В
Extension Development Host используется attach-конфигурация:
```json
@@ -177,9 +205,10 @@ Extension Development Host используется attach-конфигурац
}
```
В VS Code расширение `0.2.0` запускает адаптер через абсолютный путь к local
pyenv shim `~/.pyenv/shims/python` и задаёт корень workspace как `cwd`.
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock.
В VS Code расширение `0.2.0` находит SDK через `sprinterDebugger.sdkRoot`,
а Python 3.12 — через local pyenv shim или установленную pyenv-версию.
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock,
и позволяет отлаживать приложение из отдельного workspace.
Выбранные пути видны в Output → `Sprinter MAME Debug`; при раннем сбое
launcher сообщает stderr и последние строки MAME log.
Интегрированный запуск:
@@ -208,11 +237,18 @@ pyenv exec python tests/sdbg/run_mame_probe.py
pyenv exec python tests/sdbg/run_mame_probe.py --bridge
pyenv exec python tests/sdbg/run_mame_probe.py --bridge --banked
pyenv exec python tests/sdbg/run_mame_probe.py --bridge --banked --server
pyenv exec python tests/sdbg/run_mame_probe.py --banked --bridge --server --lifecycle-exit
pyenv exec python tests/sdbg/run_mame_probe.py --banked --bridge --server --lifecycle-load
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --native-debugger
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --source-step
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --step-out
pyenv exec python tests/sdbg/run_vscode_dap_probe.py
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --exit-while-stopped
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --stop-while-stopped
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --term-while-stopped
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --mcp-python /tmp/sprinter-sdbg-mcp-venv/bin/python
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --emulated-key --clear-while-running
pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --manual-key
```
@@ -251,13 +287,53 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`:
| Source F11/F10 | `main`: `0x42bb``0x42c1`; F10 выполнил банковский `worker` и остановился в caller на `0x42c9` |
| Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `0x42c9` |
| VS Code stdio DAP launch, 2026-09-15 | Local pyenv shim → DAP → изолированный MAME/DSS → `hello``main:17`, PC=`0x8224`; `false_hits=0`, штатный disconnect |
| MAME.HT 0.289, 2026-09-17 | Чистая сборка `make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp` и отдельные рецепты `stock`/`sdbg` создали `sprinter`; DAP в `sdbg` и `osx` на patched, а также `osx` на stock дождался DSS и остановился в `hello.c:17` без ложных точек. `getchar()` получил `x`, общий DAP/MCP сохранил ownership точек. Raw `mamebridge` через официальный MCP stdio-клиент перечислил 30 инструментов и ответил на `status`/`read_registers` при остановленном CPU; устаревший `resp_1.txt` не подменил ответ после перезапуска frontend |
| Raw MCP plugin/legacy, 2026-09-17 | Все 16 команд удалённого `mame_bridge.lua` найдены в plugin dispatch; MCP stdio-прогоны проверили общий running-набор. Plugin на `hello` прошёл DSS→`main`, запись/восстановление `errno`, реальное срабатывание watchpoint, ввод `x` до строки 63 после `getchar()`, машинные over/out, снимок и пиксели. После удаления legacy-файла оба plugin-пробника повторно прошли |
| Видимый raw MCP `hello`, 2026-09-17 | `tests/sdbg/run_raw_mcp_hello_probe.py` запустил MAME.HT с `-video soft -window`; кадр работающего `hello` показал весь текст и `Press any key to exit...`, затем ввод `x` довёл PC до `hello.c:63` |
| Control lease DAP/MCP, 2026-09-17 | `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 инструментов; строковый ввод и мышь остаются в плане |
| Список C-точек MCP, 2026-09-17 | В автономном видимом MAME MCP увидел собственную точку перед `getchar()` с owner и адресом; `list_breakpoints` также отдаёт guard-условие банка из bridge, если оно есть. Всего 29 автономных инструментов; вручную созданные debugger-точки пока не перечисляются |
| Logical Z80 disassembly MCP, 2026-09-17 | Через официальный MCP-клиент в видимом MAME прочитаны инструкции `main` с `0x8224` (`call $852C`); байты памяти до/после совпали. Адрес/длина ограничены, возвращаются bank_pages и generation. Всего 30 автономных инструментов; raw program disassembly ещё не поддержано |
| Строковый ввод MCP, 2026-09-17 | `tests/gets` с C-картой в видимом MAME получил `Ab9` и Enter через один MCP `type_string`; скриншот показал ввод и печать строки, CPU остановился на строке 16 после `gets()`. 31 автономный инструмент; natural keyboard/мышь остаются в плане |
| Счётчик машинных шагов MCP, 2026-09-17 | На видимом `hello` официальный MCP-клиент передал `step_instruction(count=3)`, получил следующую остановку и затем довёл приложение до `getchar()` и выхода; диапазон 1..64 проверяется клиентом, сервером и Lua backend |
| Машинные over/out MCP, 2026-09-17 | На видимом `hello` `over 1` перешагнул call пролога, `over count` дошёл до следующего call, `out` из `_gettextmode` вернулся в caller. `out` из `___sdcc_enter_ix` ждать нельзя: helper делает tail-jump `jp (hl)`; отдельный MCP-прогон получил асинхронный ответ за 8 мс и успешно прервал его Pause. Исправлена передача десятичных count для MAME console (`#`) и длины `dasm` |
| F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62``:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg |
| VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` |
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
Полный host-набор: 29 тестов прошли, один Unix-socket тест пропущен только
из-за запрета `bind` в sandbox. Тот же socket path проверен живым DAP-запуском.
Полный host-набор в этом коммите: 45 тестов (44 прошли, один Unix-socket
тест пропущен только из-за запрета `bind` в sandbox). Тот же socket path
проверен живым DAP-запуском.
После разделения репозиториев установленный VSIX в изолированном профиле
VS Code запустил сборку SprPoP и остановился в `src/sprpop.c:264` (`main`)
через API extension host. Новый живой пробник закрытия собственного MAME
при остановке в `hello/main` подтверждает DAP `terminated` после внезапной
потери процесса. Штатный DAP launch в `hello/main` также повторно прошёл
с `sdbg` и опциональным `osx` debugger provider.
Первоначально прямой `SIGTERM` остановленному MAME не давал DAP `terminated`:
через 12 секунд процесс оставался живым. Причина: SDL3 по умолчанию
преобразует `SIGTERM` в `SDL_EVENT_QUIT`, а SDL3 OSD MAME это событие
не обрабатывает. Launcher теперь выставляет `SDL_NO_SIGNAL_HANDLERS=1`
только для своего MAME, если переменная не задана пользователем. Повторный
живой прогон подтвердил `SIGTERM` → DAP `terminated`; DAP `disconnect`
(VS Code Stop) завершил MAME и адаптер с provider `sdbg` и `osx`.
Отдельный патч MAME для этого
не требуется. Закрытие окна по-прежнему проверено только через тот же
`schedule_exit()` в исходнике MAME, без автоматического UI-клика.
Живой lifecycle-пробник на банковой программе теперь прошёл с `sdbg` и
опциональным `osx` provider. На остановке в `main` Lua вызвал
`machine:exit()`: MAME вышел с кодом 0, DAP получил `terminated`, старый
session server отверг запрос. Закрытие SDL3-окна в MAME вызывает тот же
`schedule_exit()`; физический клик по кнопке окна не автоматизировался.
При save/load state пробник после сохранения поставил банковую точку,
загрузил сохранённое состояние и получил `terminated`; post-load проверка
`bplist()` показала ноль оставшихся точек, старый session server отверг
запрос. Для save/load пробник продолжает CPU после планирования операции:
эти команды MAME сами не выводят CPU из debugger stop-loop.
Это репро доступности механизма, а не полный benchmark или проверка всех
runtime/mapping/lifecycle. Отдельная публикация debug-библиотек требует
@@ -280,9 +356,8 @@ runtime/mapping/lifecycle. Отдельная публикация debug-биб
подтвердил, что при остановке в `main` окно снова открывается через
Cmd-Tab/Dock. Регрессионный DAP-проход через `getchar()` с клавишей `x`
также завершился на следующей строке с кодом `0x78`.
Воспроизводимый patch и идемпотентный установщик находятся в
`toolchain/mame-patches/` и `toolchain/apply-mame-sdbg-patch.sh`;
`make mame-sdbg` собирает и устанавливает бинарник. Provider `osx` остаётся
Воспроизводимый patch, установщик и сборочные рецепты находятся в отдельном
`../MAME/scripts/sprinter/`. Provider `osx` остаётся
доступной launch-опцией и проверен одновременно с DAP. Для непатченного MAME
можно использовать `auto`: штатные варианты — `osx` на macOS, `windows` в
native Windows build, `qt` или `imgui` в Linux. `qt` зависит от
@@ -291,20 +366,21 @@ host-моста пока не готова: FileBridge использует `fcn
Также startup debugscript с `g` может продолжить первое реальное попадание;
живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot
на reset защищена от дублирования callback. Старый mamebridge параллельно
с sdbgbridge не загружать: общая арбитрирующая сессия ещё не реализована.
на reset защищена от дублирования callback. Raw `-plugin mamebridge`
параллельно с `sdbgbridge` не загружать: общая арбитрирующая сессия для
двух Lua-мостов ещё не реализована.
Два одновременно запущенных процесса MAME через MCP bridge не проверялись;
корректная маршрутизация команд между ними не гарантируется. Это отдельная
отложенная задача в [TODO.md](TODO.md).
## Размерный регресс и оставшаяся работа
`make size-check` запущен: сообщает рост у 12 программ относительно текущего
эталона, отсутствующие и новые сборки. В списке роста нет пересобранного
hello. Дополнительно cat и hello собраны исходной обёрткой из HEAD и новой
на тех же runtime/библиотеках: EXE попарно идентичны. Эталон не изменялся.
Этот общий check пока не считается прошедшим; расхождение существующих
сборок с baseline отделено от побайтовых регрессий новой debug-сборки.
`make size-check` теперь проходит (46 свежих карт). Прежние 12 расхождений
разобраны: `open()` добавил 9 байт после исправления режимов DSS;
`openenv` содержит ещё новый регрессионный тест; прежний размер `w3bgfx`
был снят по несвежей карте. Подробное сравнение с исходной ревизией и
правила обновления эталона — в
[плане разделения](project-reorganization.md).
Hard reset из RPC был удалён после воспроизводимого падения MAME 0.287:
старый Lua periodic callback обращался к `debugger_manager` во время нового
@@ -316,9 +392,13 @@ Crash reports в 19:22/19:23 относились к неподдержанно
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
позже новых `mame.arm-*.ips` не появилось.
Далее по плану: завершить инвалидацию на exit/state load, MCP-адаптер,
безопасный restart, расширенные выражения
и упаковку VS Code-расширения. Для IDE ещё нужны Run-команда, выбор профиля
Далее по плану: завершить арбитраж управления CPU и cleanup для MCP-точек при
аварийном выходе клиента, затем безопасный restart и расширенные выражения.
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с
`-plugin mamebridge`) нельзя просто перенести: они должны
проходить через общий session server и получить проверенный контракт для
ввода, скриншотов, записи/watchpoints. VSIX уже упакован;
для IDE ещё нужны Run-команда, выбор профиля
сборки/данных и расширенная диагностика assembler/linker.
Базовый attach уже проверяет принадлежность resident/current-bank к build,
но не умеет читать неотображённую physical RAM. Нет автоматического чтения
+48 -2
View File
@@ -55,8 +55,9 @@ CLI или MCP.
[sprinter-cc](../bin/sprinter-cc), [app.mk](../app.mk),
[mame_interactive.py](../toolchain/mame_interactive.py),
[bank.s](../runtime/bank.s), [crt0_banked.s](../runtime/crt0_banked.s).
MAME fork теперь самостоятельный проект: `MAME/plugins/mamebridge/init.lua`,
`MAME/src/mame_mcp.py`; среда запуска задаётся `MAME_HOME`, а исходники
MAME fork теперь самостоятельный проект: новый `MAME.HT` содержит
`plugins/mamebridge/init.lua`, `src/mame_mcp.py` и backend `sdbg`;
среда запуска задаётся `MAME_HOME`, бинарник — `MAME_BIN`, а исходники
fork приложению не нужны. Расширение VS Code находится в отдельном
`VSCode-Sprinter`; Python DAP backend остаётся в SDK.
@@ -534,6 +535,26 @@ console_log и загрузка набора точек. Возвращать bu
Неподдержанное — явная ошибка. Старые низкоуровневые команды интегрируются
в арбитраж, а не обходят его.
Первый stdio MCP-адаптер уже работает через общий session server и отдаёт
31 проверенный C-инструмент (33 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой
DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния,
точки и последующее срабатывание DAP-точки. Добавлен control lease для
команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat
проверены живым прогоном. RPC привязан к session/build/generation; экран и
VRAM доступны через общий bridge, что подтверждено снимком работающего `hello`;
одиночный `press_key` проверен на ожидающем `getchar()`, а `type_string`
ввёл `Ab9` и Enter в `gets()` с остановкой после печати строки;
машинный `step_instruction` принимает ограниченный `count` 1..64, не меняя
семантику DAP source-step;
машинные `step_over_instruction(count)` и `step_out_instruction()` выделены
отдельно от C-шагов и возвращают принятие команды асинхронно, сохраняя Pause;
остальные интерфейсы этого раздела ещё не реализованы.
Автономный MCP `start_session` поднимает DSS→EXE→`main` без VS Code;
DAP attach к созданному socket и последующий `stop_session` проверены.
Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md);
результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
[mcp-convergence-plan.md](mcp-convergence-plan.md).
### 8.2 DAP MVP и развитие
MVP: initialize, attach, configurationDone, disconnect, setBreakpoints,
@@ -678,6 +699,20 @@ stepIn. Версионировать мост/патчи и установщик
exit/reset/reload инвалидируют состояние; потеря клиента не блокирует MAME,
конкурентные операции не читают смешанный snapshot.
Текущая реализация опрашивает backend и при остановленном CPU: потеря MAME
переводит DAP в `terminated` (проверено живым пробником с внезапным выходом).
Проверенные живые lifecycle-пробники вызывают штатный `schedule_exit()` через
Lua `machine:exit()` и реальный save/load state при остановке в `main`:
оба завершают DAP, post-load удаляет старые точки. SDL3-событие закрытия
окна использует тот же `schedule_exit()`; прямой UI-клик не автоматизировался.
Reset по-прежнему требует безопасного отдельного репро. Для запускаемого
через toolkit MAME launcher выставляет `SDL_NO_SIGNAL_HANDLERS=1`, если
переменная не задана пользователем: SDL3 иначе превращает `SIGTERM` в
`SDL_EVENT_QUIT`, который MAME SDL3 OSD игнорирует. Живые пробы подтвердили
прямой `SIGTERM` → DAP `terminated` и DAP `disconnect` → завершение MAME и
адаптера при остановке в `main`. Изменение действует только на MAME,
запущенный launcher; системный MAME без этого окружения ведёт себя по-прежнему.
### Этап 3 — полезная CLI/MCP-отладка и логи
Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись
@@ -737,6 +772,17 @@ SDCC. Добавить launch/restart/stopOnMain и упаковку VSIX. До
без side effects, после проверки размера, доступного банка, границ и image
identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный
pointee дают явный `unavailable`/truncated, а не неверное значение.
- Исследовать временную блокировку физического ввода в MAME до запуска
отлаживаемого EXE: окно может получить фокус, пока пользователь вводит
текст в другой программе. По возможности сохранить фокус и ввод за этой
программой до запуска EXE. Автоматический ввод команды launcher в DSS
должен работать и при блокировке. В момент запуска EXE сразу открыть
прямой ввод, включая ожидание `getchar()`; восстановить исходное состояние
при Stop, ошибке и restart. Если источники ввода нельзя разделить штатно,
проверить управление фокусом или узкий patch MAME. Приёмка — живой тест
ранних нажатий, ввода в другое приложение, переключения окон и ввода после
запуска. Подробная задача
сохранена в [TODO.md](TODO.md).
**Выход:** доказательства и ограничения каждой функции опубликованы.
Эвристика не выдаётся за стек, anchors — за гарантированную идентичность,
+53
View File
@@ -0,0 +1,53 @@
# Покрытие 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` | `list_breakpoints` | Проверено на `hello`: логические C-точки всех владельцев, owner, адреса/банк и фактическое guard-условие только для чтения. Точки из родного окна MAME сюда не входят |
| `set_watchpoint` | — | Нужны owner, space/access, bank guard и проверка PC при попадании |
| `clear_watchpoint` | — | Зависит от owner-модели watchpoint |
| `step` | `step_instruction` | Проверено на `hello` с `count=3`: 1..64 машинных инструкций при stop, общий control lease и событие остановки; при breakpoint возможно раннее завершение |
| `step_over` | `step_over_instruction` | Проверено на `hello`: машинный `over count=1..64` отдельно от C-F10, асинхронное завершение через события; длинный вызов можно прервать Pause |
| `step_out` | `step_out_instruction` | Проверено на возвращаемом `_gettextmode`: машинный `out` отдельно от C Shift+F11, асинхронное завершение. Вызов через tail-jump (`___sdcc_enter_ix`) может длиться до следующего RET; нужен Pause |
| `resume` | `continue_execution` | Проверено с control lease и DAP-событиями |
| `pause` | `pause_execution` | Проверено с control lease |
| `status` | `session_status`, `where` | Проверено; C-статус содержит session/build/generation |
| `disassemble` | `disassemble_logical` | Проверено на `hello`: 1..256 байт текущей logical Z80 памяти при stop, ответ содержит bank_pages/generation. Raw program 0..0x3ffff вне текущего окна ещё не дизассемблируется |
| `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` | `type_string` | Проверено на `tests/gets`: `Ab9` + Enter напечатаны, CPU остановился на C-строке после `gets()`. До 64 символов из PC-раскладки, предварительная проверка всей строки, пауза между клавишами; набор прекращается при stop. Асинхронная очередь пока не нужна для этого ограниченного вызова |
| `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-клиент проверяет
`disassemble_logical` в `main`, `list_breakpoints`, `press_key("x")` и
остановку на строке после `getchar()`.
`run_type_string_probe.py` вводит `Ab9` и Enter в `tests/gets`, проверяет
остановку после `gets()` и сохраняет снимок с напечатанной строкой.
`run_raw_mcp_hello_probe.py`
проверяет отдельный raw plugin на том же приложении. Несколько процессов MAME
с MCP одновременно не проверялись.
+240
View File
@@ -0,0 +1,240 @@
# План сведения MCP-интерфейсов MAME и C-отладки
## Цель и границы
Для C-приложения один `sdbg_server.py` владеет одним процессом MAME. VS Code
подключается к нему через DAP, Codex и Claude — через MCP, в том числе без
запущенного VS Code. Агент получает возможности нынешнего raw MCP, сохраняя
проверку C-карты, банки, события и владение точками. Один процесс MAME не
загружает одновременно `mamebridge` и `sdbgbridge`.
`MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот
же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop.
`toolchain/sdbg_mcp.py` публикует 31 C-инструмент через общую с DAP сессию
(33 в автономном режиме с командами start/stop).
**Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не
механически скопировать имена и небезопасную семантику.** Полный raw MCP
остаётся отдельным режимом для задач без C-пакета и других машин MAME.
Матрица покрытия должна для каждого raw-инструмента содержать имя/аргументы,
адресное пространство, состояние CPU, владельца операции, эквивалент в
C-MCP, ограничения совместной работы с DAP и живой тест. «Покрыт» означает
проверенное поведение, а не только регистрацию инструмента в MCP SDK.
## А. Проверить и убрать `mame_bridge.lua`
1. Зафиксировать 16 команд старого Lua-dispatch: `regs`, `mem`, `setmem`,
`bp`, `bpclr`, `bplist`, `wp`, `wpclr`, `step`, `over`, `out`, `cont`,
`pause`, `status`, `dasm`, `cmd`. Проверять именно Lua-обработчики: Python
frontend показывает 30 инструментов и со старым скриптом, хотя 14 из них
получают `unknown command`.
2. Расширить `MAME.HT/scripts/sprinter/probe-raw-mcp.py` отдельным
воспроизводимым профилем. Запускать legacy script и plugin **по очереди**
с одинаковым BIOS, ROM, fixture и изолированными каталогами IPC/дисков.
Для чтения сравнивать нормализованные результаты, для `setmem` писать лишь
в выделенный scratch-буфер и восстанавливать байты. У legacy проверять
создание/перечень/очистку не срабатывающих точек при running CPU; у plugin
дополнительно проверять реальное попадание и очистку после stop.
`cmd` в сравнении ограничить читающей командой, например `print pc`.
Не выполнять reset/state load ради сравнения: безопасный reset ещё не
подтверждён.
3. Старый frame notifier не обслуживает команды при остановленном CPU.
Поэтому stop-dependent `step`/`over`/`out` и интерактивную работу точек
нельзя требовать от legacy в этом состоянии. Для них сверить трансляцию
команд по коду и проверить поведение plugin при stop→step→inspect. Ошибка
или timeout старого скрипта при hard-stop — его известное ограничение,
а не недостающая функция plugin.
4. Отдельно прогнать 30 инструментов plugin через настоящий MCP stdio-клиент
с корректными предусловиями: running для ввода, stopped для шагов,
изолированные пути для снимков. Для каждой команды проверить содержимое
ответа, а не только отсутствие `unknown command`; после ввода и точек
проверить очистку состояния.
5. Если все 16 legacy-команд покрыты plugin и нет внешнего обязательного
потребителя `-autoboot_script mame_bridge.lua`, удалить файл из активного
дерева MAME.HT. Сохранить его в Git-истории; поправить ссылки в
`MAME_MCP_GUIDE.md`, `src/CLAUDE.md`, `scripts/sprinter/README.md`,
`src/mame_mcp.py` и заголовок plugin, где сейчас заявлено, что отличается
только способ опроса. Зафиксировать отдельным коммитом MAME.HT.
**Выход А:** plugin обеспечивает весь прежний набор и 30 своих инструментов;
проверяемых ссылок на удалённый script в текущем способе запуска нет. До
этого шага считать legacy устаревшим, но не удалённым.
**Статус А, 2026-09-17 — выполнено.** Сравнение Lua-dispatch показало 16/16
общих команд. Последовательные MCP stdio-прогоны на MAME.HT проверили
регистры, память, дизассемблирование, консоль, точки/watchpoints и
resume/pause у legacy при running CPU и plugin при stop. На реальном `hello`
plugin прошёл DSS→`main`, запись и восстановление `errno`, попадание в
watchpoint, ввод `x` через `getchar()` и машинные шаги over/out. Старый
frame notifier не смог обслужить запрос после service-stop в `main`, что
подтвердило известное ограничение. Файл удалён из активного дерева MAME.HT;
после удаления оба plugin-пробника повторно прошли. Повторный прогон `hello`
с `-video soft -window` показал вывод программы и `Press any key to exit...`
на снимке работающего экрана; затем `x` довёл PC до строки после `getchar()`.
Пробник `tests/sdbg/run_raw_mcp_hello_probe.py` теперь по умолчанию использует
видимое окно, а `--proof` сохраняет снимок вне временного каталога сессии.
## Б. Подготовить общую C-сессию к расширению
1. Ввести control lease между DAP и MCP для `continue`, `pause`, шагов,
записи памяти, ввода и raw-команд. Читающие операции допускают нескольких
клиентов; конфликтующие изменения получают явный отказ или передачу
управления. У каждой точки, watchpoint и удерживаемой клавиши есть owner;
heartbeat и закрытие соединения освобождают их даже после аварии клиента.
2. Все ответы и запросы связывать с session ID, build ID и generation. При
reset/load/exit или замене EXE инвалидировать старые операции и снимки.
Ограничить размеры запросов, очередь событий и время ожидания; после
timeout не повторять изменяющую команду вслепую. Для дополнительных
возможностей расширять один `sdbgbridge`, а не загружать рядом raw plugin.
3. Определить единый контракт адресов: logical Z80, raw program/data/io,
VRAM/share и текущие страницы банков — разные типы адреса. Существующий
C-инструмент `read_memory` оставляет значение «logical Z80»; raw-чтение
получает другое имя, например `read_program_memory`. Аналогично
`clear_breakpoint` продолжает принимать логический ID владельца, а не
незащищённый MAME ID. Совпадение имени не должно менять старую семантику.
**Выход Б:** два MCP-клиента и DAP читают одну сессию; конкурентные мутации
сериализованы, а потеря клиента не оставляет его точек или нажатых клавиш.
**Промежуточный статус Б, 2026-09-17.** В session server добавлен 30-секундный
control lease: команды CPU и ввод принимаются от текущего владельца, чужие
получают явный отказ. MCP предоставляет `claim_control`/`release_control` и
продлевает lease фоновым heartbeat; DAP продлевает его при опросе событий и
освобождает при disconnect. Каждый процесс session server выдаёт новый
`session_id`. Unit-тест проверил конфликт, освобождение и истечение lease;
живой DAP+MCP-прогон проверил передачу управления обратно VS Code и попадание
в его точку. Сервер теперь также удаляет личные MCP-точки и отпускает
удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем
без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие
ложной остановки в `hello.c:62` после возобновления DAP.
RPC теперь привязывает последующие запросы DAP/MCP/CLI к session ID и build ID;
мутации CPU и точек также требуют актуальную generation. Смена сессии/socket
или устаревшая generation дают явный отказ, без автоматического повтора.
**Этап Б не закрыт:** ещё нужны проверка reset/load на живом MAME, контроль
новых типов мутаций и политика передачи lease между долгоживущими клиентами.
## В. Перенести 30 возможностей raw MCP по группам
| Raw-инструменты | Реализация в общей сессии и проверка |
|---|---|
| `status`, `read_registers`, `step`, `step_over`, `step_out`, `resume`, `pause` | Сохранить C-позицию и события; добавить ограниченный счётчик машинных шагов там, где raw его принимает. Проверить F10/F11/Shift+F11, остановку на пользовательской точке, ожидание `getchar()` и управление из VS Code и MCP. |
| `read_memory`, `read_logical_memory`, `read_vram`, `read_share`, `list_shares`, `write_memory` | Явные пространства и пределы длины, чтение без side effects, маркировка банков. Запись — только с lease и при остановленном CPU, с проверкой диапазона и read-back. Изменение кода/банков инвалидирует или повторно проверяет C-карту; не выдавать изменённый EXE за исходный build. |
| `set_breakpoint`, `clear_breakpoint`, `list_breakpoints`, `set_watchpoint`, `clear_watchpoint` | Raw-адрес и условие вынести в отдельный управляемый API; сохранить C-точки по строке/функции. Учитывать owner, bank/window и физический alias, показывать чужие точки только для чтения. Для watchpoint доказать срабатывание на read/write/IO, корректный PC и отсутствие ложного попадания при загрузке DSS. |
| `disassemble`, `debugger_command`, `screenshot`, `read_screen_pixels` | Дизассемблировать с явным пространством/банком. Снимок и пиксели отдавать с размером, форматом, временем кадра и ограничением объёма; при hard-stop сообщать, что кадр может быть старым. `debugger_command` в общей DAP-сессии сначала поддерживает проверенные читающие команды; полный pass-through — только под эксклюзивным raw lease с переоценкой/инвалидацией состояния после команды. Недопустимую команду отклонять явно, не выдавать частичный результат за поддержку. |
| `list_ports`, `press_key`, `type_string`, `type_text`, `move_mouse`, `click_mouse`, `press_input`, `set_input` | Одна очередь ввода с владельцем, временем удержания и гарантированным release при stop/disconnect/error. Вводить лишь после запуска EXE при running CPU; автоматический ввод DSS оставить launcher. Проверить `getchar()`, графическое приложение, мышь, прямую физическую клавиатуру MAME и отсутствие перехвата фокуса другой программы. |
Для каждой группы сначала добавить ограниченный метод `sdbgbridge`, затем
проверку/событие в `sdbg_server.py`, затем MCP-инструмент и документацию.
Не переносить старые строковые Lua-команды напрямую через MCP: они обходят
проверку типов, владения и generation. Если точная семантика raw-инструмента
небезопасна при открытом DAP, сохранить его функцию в явно эксклюзивном
режиме, а в общей сессии вернуть объясняемое ограничение.
**Выход В:** матрица всех 30 строк закрыта живыми тестами; ограничения raw
pass-through и особенности адресов перечислены поимённо. Число методов C-MCP
может быть больше 30 из-за разных адресных пространств и C-операций.
**Промежуточный статус В, 2026-09-17.** Через существующий `sdbgbridge`
добавлены `list_shares`, `read_share`, `read_vram`, `read_screen_pixels`,
`screenshot`, `read_program_memory`, `list_ports` с пределами размеров,
точным tag share, изолированным каталогом PNG и признаком устаревшего кадра
при stop. Живой DAP+MCP-прогон подтвердил совпадение raw program/logical Z80
в `main`, VRAM/share, список портов и PNG; второй прогон снял экран работающего
`hello` во время `getchar()` и затем завершил шаг клавишей `x`. Полное
поимённое состояние — в [матрице 30 raw-инструментов](mcp-capability-matrix.md).
Одиночный публичный `press_key` теперь требует running CPU и control lease,
удерживает клавишу 1..60 кадров и отпускает её при stop/ошибке. Автономный
официальный MCP-клиент ввёл `x` в ожидающий `getchar()` и попал на следующую
C-строку. Асинхронная очередь событий, другие имена физических клавиш и мышь
ещё не реализованы; поэтому строка raw `press_key` покрыта частично.
Ограниченный `type_string` уже выполняет последовательность из 1..64
символов PC-раскладки с предварительной проверкой, покадровым удержанием и
отпусканием, прерыванием при stop. На `tests/gets` строка `Ab9` и Enter
дошли до `gets()`; программа напечатала её и остановилась на следующей
C-строке. Это покрывает типовой строковый ввод, но не natural keyboard и
не асинхронную очередь произвольных событий мыши/портов.
Читающий `list_breakpoints` теперь показывает логические C-точки всех
владельцев, адреса и полученные от MAME условия банковской страницы;
удалять чужие точки он не позволяет. Живой MCP-прогон увидел личную точку
перед `getchar()`. Точки, вручную созданные в родном debugger, пока вне списка.
Дизассемблирование текущего logical Z80 окна добавлено как
`disassemble_logical`: фиксированная команда MAME с числовыми аргументами,
пределом 256 байт и изолированным временным файлом; ответ включает
`bank_pages` и generation. Живой MCP-прогон получил инструкции `main` с
адреса `0x8224`; байты до и после операции совпали. Raw program вне
текущего отображения остаётся открытым.
Машинный `step_instruction` принимает ограниченный `count=1..64`; backend
передаёт это число в native `cpu.debug:step(count)`, общий session server
сохраняет control lease и событие остановки. Живой автономный MCP-прогон
выполнил `count=3` в `main`, затем продолжил `hello` до `getchar()` и вышел
через клавишу. DAP source-step не менялся.
Отдельные машинные `step_over_instruction(count=1..64)` и
`step_out_instruction()` теперь асинхронны: MAME-команда может ждать ввод,
поэтому RPC не держит lock до остановки; события и Pause остаются доступны.
Живой `hello` подтвердил `over` через call, счётчик `over` до второго call и
`out` из возвращаемой функции `_gettextmode`. Вызов `out` из
`___sdcc_enter_ix` выявил tail-jump вместо RET и не останавливался быстро:
именно поэтому машинные over/out не должны быть синхронными. Отдельный живой
MCP-прогон получил ответ `out` за 8 мс и остановил долгий выход через Pause.
Консоль MAME
считает числа без префикса шестнадцатеричными; для `over count` и длины
`dasm` backend теперь передаёт явный десятичный префикс `#`.
## Г. Сделать запуск пригодным для Codex и Claude без VS Code
Сейчас `sdbg_mcp.py` подключается лишь к уже работающему socket. Сам
`sdbg_launcher.py` умеет поднять MAME→DSS→EXE→`main` и `sdbg_server.py`
без VS Code, но это ещё не удобный жизненный цикл для MCP-клиента.
1. Добавить supervisor/команду запуска C-сессии с параметрами build,
`MAME_HOME`/`MAME_BIN`/ROM/DSS/образов и стабильным session socket.
MCP handshake должен завершаться быстро; запуск MAME выполняется
асинхронным инструментом с событиями прогресса, чтобы долгий DSS boot не
выглядел как зависший MCP-сервер. Предусмотреть attach к уже работающей
VS Code-сессии и DAP attach к сессии, запущенной агентом.
2. Определить судьбу MAME при закрытии каждого клиента: отсоединение одного
агента не завершает VS Code-сессию; явно управляемая автономная сессия
живёт до `stop_session` или закрытия её владельца согласно выбранной
политике. Повторный MCP старт не должен подключаться к старому socket/PID.
3. Проверить реальные stdio-подключения Codex и Claude отдельно: агент
запускает C-приложение без VS Code, останавливается в `main`, читает
переменную и экран, вводит клавишу; затем VS Code подключается к той же
сессии. Обратный порядок: F5 в VS Code, агент подключается по socket и
действует без второго Lua-моста. Проверить смену lease и cleanup.
**Выход Г:** C-отладка полностью доступна агенту без VS Code, а подключение
агента к VS Code-сессии не создаёт второй MAME и не теряет точки DAP.
**Промежуточный статус Г, 2026-09-17.** `sdbg_mcp.py --build` публикует
асинхронный `start_session`, фазовый `session_status` и `stop_session`.
Живой официальный MCP-клиент получил ответ старта за 5–7 мс, дождался
DSS→`hello.exe``main`, прочитал переменную и PNG. DAP подключился к тому же
socket; его disconnect оставил MAME работающим. Повторный MCP start был
отклонён, `stop_session` удалил socket и завершил свой MAME. При закрытии
MCP-процесса supervisor также завершает принадлежащий ему launcher.
Тем же официальным клиентом проверены установка C-точки перед `getchar()`,
ввод `x` через публичный MCP `press_key` и остановка на следующей C-строке.
**Этап Г не закрыт:** нужны реальные подключения из Codex и Claude,
проверка передачи control lease между их клиентами и DAP, а также устойчивость
к сбоям launcher/клиента на разных стадиях boot.
## Проверки и ограничения на всём пути
- Живые прогоны выполнять на `MAME.HT/sprinter`, собранном командой
`make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp`; отдельно
проверить stock + `osx` и patched + `sdbg` там, где функция не требует
патча. Lua-изменения не требуют пересборки бинарника, но требуют нового
запуска MAME.
- Использовать локальный pyenv Python 3.12. Raw MCP пока требует SDK 1.x,
C-MCP — SDK 2.x; окружения разделять до явной миграции frontend. Проверять
оба MCP-клиента на реальном протоколе, а не только прямыми Python-вызовами.
- Тесты на macOS включают окно `osx` как опцию, отзывчивость MAME, ручной
ввод и Debug Console. Linux проверять отдельным живым прогоном. Полный
Windows-маршрут пока не поддерживается из-за Unix socket/host launcher.
- Несколько процессов MAME с MCP остаются отдельной отложенной задачей:
их одновременная работа не проверялась и не гарантируется. Не объявлять
её рабочей по результату тестов нескольких клиентов одной сессии.
- Сохранять риск раннего фокуса MAME отдельной финальной задачей: защита от
случайного ввода до старта EXE не должна ломать DSS bootstrap и прямой
ввод после запуска приложения.
+50 -21
View File
@@ -1,7 +1,8 @@
# Разделение Sprinter-CC, MAME и приложений
Статус: физическое разделение выполнено; завершаются локальные коммиты,
проверка документов и публикация Sprinter-CC. Дата обновления: 2026-09-16.
Статус: разделение выполнено, локальные репозитории зафиксированы,
Sprinter-CC опубликован. Открытые проверки перечислены ниже. Дата обновления:
2026-09-16.
## Цель и границы
@@ -17,7 +18,8 @@ MAME приложению не нужны.
```text
~/Projects/DIY/Z80/Sprinter/
├── C-Compiler/ # Sprinter-CC
├── MAME/ # fork MAME, сборки и локальная среда запуска
├── MAME/ # прежний fork MAME 0.287, локальные ресурсы
├── MAME.HT/ # новый fork Holub/Tolik, сборка sprinter
├── Examples/ # один Git-репозиторий примеров
├── Applications/ # каталог без собственного Git
│ ├── SprPoP/ # самостоятельный Git-репозиторий
@@ -42,8 +44,9 @@ MAME приложению не нужны.
| SprPoP | `4507ba9` | `bd300b3` |
| Volkov | `a71e3c5` | `3c1956b` |
| PoP-Archive | `716c639` | `54d0b3d` |
| VSCode-Sprinter | `68a821e` | `901d91a` |
| VSCode-Sprinter | `68a821e` | `abfc29d` (`901d91a` — функциональные изменения) |
| MAME | baseline `b0c4527c`, backend/MCP `fe7ee37a` | `37b89b78` |
| MAME.HT | Holub/Tolik `7f64914880f`, backend `46e89112836`, plugin `1188177a516` | `e0c509c7150` (рецепты и ранний callback), `bb79847affd` (полный raw MCP smoke test и frontend) |
Первые пять base-коммитов получены из подготовительного коммита Sprinter-CC
через `git subtree split`; поэтому их история прослеживается до исходного
@@ -54,7 +57,8 @@ MAME приложению не нужны.
| Проект | Что остаётся/переходит | Причина |
|---|---|---|
| Sprinter-CC | `bin/`, `runtime/`, `libc/`, `libbgi/`, `lib/`, общие `toolchain/`, `tests/`, `testkit/`, `app.mk`, справочник API, платформенные исследования, `release_docs/`, рецепт SDCC в `third_party/` | Это target SDK, его ABI, инструменты и собственные регрессионные тесты. libc/libbgi пока остаются вместе: их версия и ABI тесно связаны с `sprinter-cc`; дальнейшее выделение возможно после стабильного интерфейса. |
| MAME | Выделенный `../MAME` со своим Git, изменения драйвера/OSD, патч debugger backend, общие `mamebridge` и `mame_mcp.py`, рецепты stock/sdbg сборок | Изменения ядра и общий транспорт MCP должны проверяться и выпускаться вместе с конкретной ревизией MAME. |
| MAME | Прежний `../MAME` 0.287 и локальные ROM/DSS/CHD, пока новая среда не подготовлена | История проверенной сборки и ресурсы остаются доступными для сравнения и совместимости. |
| MAME.HT | Новый `../MAME.HT` Holub/Tolik: драйвер, OSD, backend `sdbg`, raw `mamebridge`/`mame_mcp.py`, рецепты stock/sdbg с выходом `sprinter` | Это активный исходный fork; его нельзя подменять исходниками MAME 0.287, а патчи и бинарники проверяются на его собственной базе. |
| Examples | Выделенный `../Examples`: `balls`, `mdview`, `mdview2`, `rpgwalk`, `scroll`, `space` и относящиеся к ним ресурсы/документы | Это демонстрации SDK с общей версией. Один репозиторий избегает множества мелких релизов. |
| SprPoP | Всё из прежнего `applications/SprPoP/`: исходники, конверторы, тесты, собственные ресурсы и планы | Уже почти автономное приложение; оригинальные ресурсы и дальше остаются внешними. |
| Volkov | Всё из прежнего `applications/Volkov/`: исходники, сценарии MAME, собственные тестовые носители и документы | Продукт и его проверки должны развиваться без дерева тулкита. |
@@ -87,13 +91,15 @@ MAME, а не к его исходникам. Стандартные пути в
контракт установленной среды и допускают переопределение из окружения, аргументов `make` или
локального игнорируемого файла настроек. Если `MAME_HOME` не задан,
необходимые пути можно указать отдельно; отсутствие обоих источников
диагностировать в цели запуска, не превращая пустое значение в `/mame.arm`:
диагностировать в цели запуска, не превращая пустое значение в путь от
корня файловой системы:
```make
MAME_BIN ?= $(MAME_HOME)/mame.arm
MAME_ROMPATH ?= $(MAME_HOME)/roms
MAME_DSS_IMAGE ?= $(MAME_HOME)/IMG/dss171u.img
MAME_SYSTEM_HDD_IMAGE ?= $(MAME_HOME)/IMG/sp_hdd_sys.chd
MAME_HOME ?=
MAME_BIN ?=
MAME_ROMPATH ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/roms,)
MAME_DSS_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/dss171u.img,)
MAME_SYSTEM_HDD_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/sp_hdd_sys.chd,)
MAME_BIOS ?= v3.06
```
@@ -106,8 +112,13 @@ MAME_BIOS ?= v3.06
DAP-launch должны брать один источник настройки, даже если конкретный профиль
не монтирует DSS-дискету.
`MAME_HOME/mame.arm` — стандартный установленный бинарник, а stock/sdbg
сборки находятся отдельно и выбираются явным `MAME_BIN`. Сборка одного
В новом `MAME.HT` команда
`make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp` создаёт
`sprinter`, не `mame.arm`. SDK при наличии `MAME_HOME/sprinter` выбирает его;
для прежних установок сохраняется `MAME_HOME/mame.arm`. Явный `MAME_BIN`
имеет приоритет и позволяет запускать `MAME.HT/sprinter` со старым каталогом
ROM/DSS без копирования образов. Stock/sdbg сборки находятся отдельно и
выбираются явным `MAME_BIN`. Сборка одного
варианта не перезаписывает другой и не меняет молча активный бинарник.
Для запуска с нетипичной установкой можно переопределить каждый путь, не
копируя ROM/CHD в каталог приложения. Все цели запуска валидируют свои
@@ -135,6 +146,10 @@ host-зависимости и проверять их перед упаковк
разные локальные диски. Не требовать закрывать все чужие процессы MAME по
общему шаблону: предотвращать конфликт только за конкретный изменяемый
носитель/IPC-сеанс. Закрытие MAME не должно терять лог и снимки теста.
Обычный `run_sprinter_mame.py` перед стартом копирует `cfg` и `nvram` из
`MAME_HOME` в каталог сессии, затем включает обе клавиатуры в копии
`sprinter.cfg`, сохраняя остальные настройки. Исходная установка не меняется;
отладчик DAP по-прежнему начинает с чистого изолированного состояния.
## Граница MAME и отладки
@@ -248,14 +263,14 @@ debug packages, носители, снимки, внешние источник
их документы и историю. Критерий: каждый продукт собирается и проходит
доступные host/MAME проверки из изолированного клона без Examples и
других приложений.
6. **Реализовано; остаётся ручная проверка установленного VSIX после разделения — выделить extension.** Научить VS Code находить SDK DAP независимо от
6. **Выполнено — выделить extension.** Научить VS Code находить SDK DAP независимо от
workspace, запускать build/run/debug приложения и показывать ошибки
разрешения путей/версий. Проверить VSIX в отдельном workspace SprPoP
или Volkov, а не только в `C-Compiler`. Критерий: F5 строит debug package,
ждёт DSS, доходит до `main`, принимает breakpoint/logpoint и клавиатуру;
опция родного окна MAME работает на macOS. Windows остаётся явно
ограниченной до отдельной реализации host transport.
7. **Завершается — очистить SDK и документы.** Заменить корневые цели, release-скрипт,
7. **Выполнено — очистить SDK и документы.** Заменить корневые цели, release-скрипт,
README, `AGENTS.md`, инструкции автотеста/отладки и рабочие ссылки.
Проверить `make`, `make -C libc`, `make -C libbgi`, `make size-check`,
SDK host/sdbg tests и отдельные проекты. Критерий: в SDK нет tracked
@@ -273,13 +288,27 @@ SprPoP, Volkov и PoP-Archive PoC. Живые последовательные D
`SDBG_LOG` в обеих консолях и запуск SprPoP с собственного `hard2` до
`main`.
`make size-check` пока не принят: текущий baseline показывает 12 старых
увеличений (обычно +9 байт, `openenv` +188) и несколько исчезнувших прежних
тестов. Реорганизация не меняла libc/libbgi, поэтому эталон автоматически не
перезаписывался; расхождения нужно разобрать отдельно. После разделения ещё
нужна ручная проверка установленного VSIX в чистом workspace. Несколько
одновременных MAME, маршрутизация MCP между ними и Windows transport остаются
явно непроверенными сценариями.
Extension воспроизводимо упакован через `npm ci && npm run package` и VSIX
версии 0.2.0 установлен в локальный VS Code. Рецепт и `package-lock.json`
хранятся в VSCode-Sprinter. Изолированный профиль VS Code с файлами
установленного VSIX выполнил F5-сценарий SprPoP: задача `make SRC_DEBUG=1
hdd` завершилась, DAP остановился в `src/sprpop.c:264` (`main`). Проверка
шла через API extension host; автоматизировать физическое нажатие F5 через
macOS Accessibility из текущего окружения не удалось.
Эталон `make size-check` разобран и обновлён. Десять тестов, использующих
`open()`, выросли на 9 байт из-за исправления таблицы режимов ESTEX после
предыдущего эталона (ревизия `879f2ba`). `openenv` вырос на 188 байт: те же
9 байт и новый тест записи через `O_RDONLY`. Для `w3bgfx` прежняя запись
5243 байта была несвежей: пересборка в отдельном worktree самой ревизии
`879f2ba` тем же SDCC дала 5257 байт, как и текущая сборка. Других
изменений размеров свежих карт нет. Теперь `size-check` сначала собирает
обязательные SDK-тесты, требует их свежие карты и пропускает устаревшие
артефакты необязательных тестов; `size-baseline` сохраняет их прежние
значения, пока эти тесты не собраны заново.
Несколько одновременных MAME, маршрутизация MCP между ними и Windows
transport остаются явно непроверенными сценариями.
Каждый этап заканчивается проверкой в отдельном репозитории и просмотром Git
diff. Новые Git remote для выделенных репозиториев и публикация их релизов
+5 -1
View File
@@ -5,7 +5,11 @@
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME,
но не открывает штатное debugger-окно.
но не открывает штатное debugger-окно. Подключённый
[MCP-адаптер C-уровня](sdbg-mcp.md) читает те же записи `output` через
`recent_events(after=...)`; он не ставит второй набор макросных точек.
Маршрут DAP проверен и с новым `MAME.HT/sprinter` 0.289 в режимах `sdbg`
и `osx`; выбор бинарника описан в [руководстве VS Code](vscode-sprinter-debug.md).
## Быстрый пример
+207
View File
@@ -0,0 +1,207 @@
# MCP-доступ к source debugger Sprinter
`toolchain/sdbg_mcp.py` подключается к уже работающей `sdbg_server.py` через
Unix socket. Это отдельный stdio MCP-сервер для C-уровня: он использует ту же
сессию, карту сборки и журнал, что VS Code/DAP. В MAME.HT отдельно работает
raw MCP-мост `-plugin mamebridge` с `src/mame_mcp.py`. Старый
`src/mame_bridge.lua` удалён после проверки: он обрабатывал лишь 16 из 30
публикуемых Python-команд и не отвечал при остановленном CPU. Plugin использует
`register_periodic` и отвечает даже при остановленном CPU. Ни один из этих
вариантов не должен одновременно управлять тем же MAME в обход sdbg-сессии.
`src/mame_mcp.py` всё ещё импортирует `FastMCP` по пути SDK 1.x;
наш адаптер использует `MCPServer` SDK 2.x. Пока raw frontend не мигрирован,
держите для него отдельное Python-окружение. Это различие касается Python
обвязки, а не Lua-плагина `mamebridge`.
Для MCP нужен Python 3.12 с официальным SDK 2.x. Он не требуется для сборки
приложений и работы VS Code. Например, создайте отдельное окружение и
установите `toolchain/requirements-mcp.txt`:
```sh
pyenv exec python -m venv /tmp/sprinter-sdbg-mcp-venv
/tmp/sprinter-sdbg-mcp-venv/bin/python -m pip install -r toolchain/requirements-mcp.txt
```
В профиле `launch` типа `sprinter-mame` укажите фиксированный socket,
например `"socket": "/tmp/sprinter-sdbg-hello.sock"`. После F5 дождитесь
остановки в `main`. Путь должен быть коротким (у Unix socket есть лимит длины),
уникальным для этого сеанса и доступным только вашему пользователю. Затем
зарегистрируйте в MCP-клиенте stdio-сервер:
```json
{
"command": "/tmp/sprinter-sdbg-mcp-venv/bin/python",
"args": [
"/ABS/PATH/C-Compiler/toolchain/sdbg_mcp.py",
"--socket", "/tmp/sprinter-sdbg-hello.sock"
]
}
```
Замените `/ABS/PATH/C-Compiler` абсолютным путём к toolkit. MCP-сервер при
старте проверяет подключение; если VS Code ещё не запустил MAME или socket
устарел, он завершается с понятной ошибкой в stderr. После Stop запустите
MCP-сервер заново вместе с новой DAP-сессией. Альтернатива параметру
`--socket` — переменная окружения `SDBG_SOCKET`.
Для работы **без VS Code** запустите тот же MCP-сервер с `--build`:
```json
{
"command": "/tmp/sprinter-sdbg-mcp-venv/bin/python",
"args": [
"/ABS/PATH/C-Compiler/toolchain/sdbg_mcp.py",
"--build", "/ABS/PATH/C-Compiler/tests/hello/.sprinter-cc-hello",
"--socket", "/tmp/sprinter-hello-agent.sock",
"--mame-home", "/ABS/PATH/MAME/runtime",
"--mame-bin", "/ABS/PATH/MAME.HT/sprinter"
]
}
```
MCP handshake не ждёт загрузку DSS. `start_session` сразу возвращает socket и
launch ID; `session_status` показывает `starting`, затем `ready` с build ID,
session ID, PC и PID MAME либо `failed` с диагностикой. Повторный start при
активном сеансе отклоняется. `stop_session` завершает только запущенный этим
MCP-сервером MAME и удаляет socket. Закрытие самого MCP-процесса также
завершает его автономный MAME. DAP/VS Code может подключиться к тому же socket;
его disconnect не завершает MCP-owned MAME. Конфигурация VS Code — в
[разделе attach](vscode-sprinter-debug.md#ручной-attach).
Доступны также `--mame-rompath`, `--mame-dss-image`,
`--mame-system-hdd-image`, `--mame-bios`, `--app-hdd`, `--launch-path`,
`--data`, `--debugger`, `--launch-at`, `--dss-timeout`.
Живой stdio MCP→DSS→`main`→DAP attach→stop прогон прошёл; подключение
непосредственно из UI Codex и Claude ещё не проверено.
Доступные инструменты: `session_status`, `where`, `read_registers`,
`read_memory`, `read_program_memory`, `disassemble_logical`, `list_ports`,
`list_shares`, `read_share`, `read_vram`,
`read_screen_pixels`, `screenshot`, `press_key`, `type_string`,
`list_variables`, `read_variable`, `recent_events`,
`mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`,
`list_breakpoints`, `clear_breakpoint`, `clear_my_breakpoints`,
`claim_control`, `release_control`,
`continue_execution`,
`pause_execution`, `step_instruction`, `step_over_instruction`,
`step_out_instruction`, `step_source`. Ответы SDK 2.x содержат
`structuredContent`. `step_source` принимает `into`, `over` или `out` и
возвращает принятие команды; итоговую остановку получите через
`recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события
`output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`.
`step_instruction(count)` синхронно выполняет 1..64 машинных шагов
(`count=1` по умолчанию) и возвращает позицию остановки. Если раньше
сработает breakpoint, выполнение может остановиться до заданного числа.
`step_over_instruction(count=1..64)` и `step_out_instruction()` дают именно
машинную семантику MAME. Они отвечают сразу `accepted`; итоговую остановку
получайте через `recent_events` или `session_status`, при долгом вызове
доступен `pause_execution`. Например, `out` из `___sdcc_enter_ix` не
останавливается быстро: этот helper возвращает управление через `jp (hl)`,
а не через `ret`. Живой тест проверил `over` через такой вызов и `out` из
возвращаемого `_gettextmode`. Команды DAP F11/F10/Shift+F11 сохраняют
прежнюю C-семантику.
Число 31 в attach-режиме (33 в автономном с `start_session`/`stop_session`)
не означает полного переноса более нового `-plugin mamebridge`:
его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на
машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а
некоторые функции (C-позиция, typed global/static, журнал с generation)
в raw MCP вообще отсутствуют. Оставшиеся группы требуют отдельного контракта:
| Группа raw MCP | Что требуется перед переносом в общую сессию |
|---|---|
| `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии |
| VRAM/shares, screen pixels, screenshot | Реализованы через общий `sdbgbridge`; точный tag, пределы 4096 байт/8192 пикселя, PNG только в каталоге сессии |
| Клавиатура, мышь, type/press | `list_ports`, одиночный `press_key` и ограниченный `type_string` перенесены; natural keyboard, произвольные поля портов и мышь требуют отдельного контракта |
| Raw disassembly | Текущее окно logical Z80 уже доступно через `disassemble_logical`; адреса raw program вне отображённого банка требуют отдельного безопасного режима |
Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить
`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять
CPU и точки без общего owner ID и журнала.
Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую
C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md) и
[матрице покрытия](mcp-capability-matrix.md).
Чтение памяти принимает десятичный адрес или `0xHEX`, 1256 байт logical
Z80 memory, только при остановленном CPU, без side effects. Ответ содержит
generation и страницы банков; диапазон не может пересекать границу 64 КБ.
`read_variable` работает с проверенными global/static размером 1, 2 или 4
байта, только когда нужный банк отображён. Локальные, стек, watchpoints,
запись переменных и произвольные выражения пока не поддерживаются.
`read_program_memory` читает raw program space 0..0x3ffff без side effects,
до 4096 байт при остановленном CPU; это отдельный инструмент, так как
`read_memory` означает logical Z80. `list_ports` возвращает tag, имена полей
и битовые маски; результат ограничен 512 портами/4096 полями и помечает
`truncated`, если достигнут предел. Оба инструмента проверены в живом MAME.
`disassemble_logical` принимает числовой адрес (десятичный или `0xHEX`) и
длину 1..256 байт в текущем logical Z80 пространстве, только при stop.
Сервер сверяет загруженный C-код, вызывает фиксированную читающую команду
MAME `dasm` во временном каталоге сессии и возвращает текст, generation и
текущие `bank_pages`. Это не произвольная debugger-команда. Дизассемблирование
raw program вне текущих банков пока не поддержано. На живом `hello` ответ
начинался с адреса `8224`, соответствующего `main`.
`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` не заменяет
его. `type_string` принимает 1..64 символа той же PC-раскладки, включая
реальный перевод строки `\n` для Enter. Все символы проверяются до первого
нажатия. Каждый символ удерживается три кадра и затем отпускается;
между символами выдерживаются четыре кадра. При stop дальнейший набор
прекращается, ответ сообщает `requested`, `typed`, `complete` и `stopped`.
Живой тест `gets()` получил `Ab9` и Enter; снимок показал введённую и
напечатанную строку, а CPU остановился на следующей C-строке. Ввод через
natural keyboard, произвольные имена физических клавиш, мышь и поля портов
пока не публичны в C-MCP.
`list_breakpoints` возвращает логические ID, владельца (`mcp:…`, `dap` или
`build`), вид C-точки, адреса и фактические условия банковской страницы для
всех точек общей сессии. Это чтение доступно и при running CPU. Точки,
поставленные вручную в родном debugger MAME, в этот список не входят.
Инструмент не даёт удалить чужую точку: `clear_breakpoint` по-прежнему
проверяет owner.
После первого `session_status` клиент отправляет session ID и build ID в
каждом RPC; команды, меняющие CPU/точки, дополнительно сверяют generation.
При замене сеанса или устаревшей generation команда отклоняется без
автоматического повтора.
У каждой MCP-копии свой owner ID. Она может удалить только свои точки; при
обычном закрытии stdio они очищаются. Живой тест подтвердил, что попытка
удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP.
Удаление личной точки при running CPU отдельно проверено на ожидании
`getchar()`; оно не ставит CPU на паузу и не блокирует ввод.
Общий session server выдаёт 30-секундный control lease первому владельцу
команды CPU; MCP может захватить/освободить его явно. Фоновый heartbeat MCP
и опрос событий DAP продлевают активный lease. Конкурентная команда получает
отказ с именем владельца, после штатного закрытия lease освобождается.
При аварийном завершении MCP lease истекает за 30 секунд; сервер удаляет
личные точки и отпускает удерживаемые клавиши. Живой прогон с владельцем без
heartbeat подтвердил удаление его точки и отсутствие ложной остановки после
возобновления DAP; освобождение удерживаемой клавиши пока проверено только
unit-тестом.
Несколько одновременно
запущенных MAME с MCP не проверялись и не гарантируются; это отложенный тест.
Проверка совместной работы с DAP и реальным MAME:
```sh
MAME_HOME=/path/to/MAME/runtime pyenv exec python tests/sdbg/run_vscode_dap_probe.py \
--socket /tmp/sprinter-sdbg-mcp-probe.sock \
--mcp-python /tmp/sprinter-sdbg-mcp-venv/bin/python
```
+13 -13
View File
@@ -1,4 +1,4 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
# Эталон размеров _CODE (байт); обновление: make size-baseline
accfill 3797
accop 7067
argv 3445
@@ -14,18 +14,18 @@ bgitest 3762
bios_text 4475
blitperf 5860
blitw 4939
cat 927
cblstream 5944
cat 936
cblstream 5953
cbltest 6090
cblwav 6183
cblwav 6192
conio 4619
conio2 3943
convbench 3508
dec_test 874
errno 5980
fbench 8149
fdmax 6087
filetest 10611
errno 5989
fbench 8158
fdmax 6096
filetest 10620
fpsdiv 4870
gets 523
gfx_dbuf 5207
@@ -40,26 +40,26 @@ ls 4849
malloc 4475
mem_test 4569
mouse 4396
openenv 6138
openenv 6326
pageflip 6334
palfile 5406
ptime 5758
rt_test 5216
scroll 6744
seek 4202
seek 4211
simple 969
solidt 11595
solidt 11604
spranim 9431
spriteclip 4119
sprites 6791
stattest 7563
stattest 7572
stdlib 6657
stest2 3658
strtest 1354
text_palette 5038
timedir 5468
w0page 8831
w3bgfx 5243
w3bgfx 5257
w3big 3673
w3huge 3700
w3probe 3556
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update # Эталон размеров _CODE (байт); обновление: make size-baseline
2 accfill
3 accop
4 argv
14 bios_text
15 blitperf
16 blitw
17 cat
18 cblstream
19 cbltest
20 cblwav
21 conio
22 conio2
23 convbench
24 dec_test
25 errno
26 fbench
27 fdmax
28 filetest
29 fpsdiv
30 gets
31 gfx_dbuf
40 malloc
41 mem_test
42 mouse
43 openenv
44 pageflip
45 palfile
46 ptime
47 rt_test
48 scroll
49 seek
50 simple
51 solidt
52 spranim
53 spriteclip
54 sprites
55 stattest
56 stdlib
57 stest2
58 strtest
59 text_palette
60 timedir
61 w0page
62 w3bgfx
63 w3big
64 w3huge
65 w3probe
+44 -10
View File
@@ -59,6 +59,8 @@ code --extensionDevelopmentPath="/путь/к/VSCode-Sprinter" "$PWD"
второй — на среду `MAME/runtime` с бинарником, ROM и DSS/CHD. Для выбора
stock/sdbg или нестандартной установки используются поля `mameBin`,
`mameRompath`, `mameDssImage`, `mameSystemHddImage`, `mameBios` профиля launch.
Для нового fork можно оставить `mameHome` на подготовленной прежней среде и
задать `"mameBin": "/путь/к/MAME.HT/sprinter"` в launch-конфигурации.
Расширение в режиме `auto` использует `~/.pyenv/shims/python` при наличии
local `.python-version`; для внешнего workspace ищет установленный
@@ -99,6 +101,15 @@ service-точка `main` и вводится `a:\\HELLO.EXE`. После сов
а VS Code показывает остановку на entry. Завершение debug session останавливает
только созданные ей MAME/server.
При reset/state load или потере MAME остановленная сессия становится
недействительной и DAP завершает её; для новой отладки запускайте F5 заново.
Проверены внезапная потеря собственного MAME, штатный `machine:exit()` и
реальная загрузка state при остановке в `main`: каждый раз DAP получает
`terminated`, после load старые точки удалены. Закрытие SDL3-окна MAME
вызывает тот же `schedule_exit()`, что `machine:exit()`; прямой UI-клик
в пробнике не выполнялся. Эти проверки прошли с `sdbg` и опциональным
`osx` debugger provider.
`dssTimeout` задаёт предельное время ожидания prompt (30 эмулируемых секунд).
`launchAt` можно задать как необязательную нижнюю границу времени запуска;
наличие prompt всё равно обязательно. Перед вводом сохраняется диагностический
@@ -142,7 +153,8 @@ Launcher монтирует временную копию CHD как `-hard2`,
Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу
Sprinter Build. После успешной сборки должно появиться окно Sprinter;
launcher дождётся prompt DSS, введёт
`A:\\HELLO.EXE` и VS Code остановится в `main`.
`A:\\HELLO.EXE` и VS Code остановится в `main`. До этой остановки
не вводите символы в MAME: они смешаются с командой launcher в DSS.
4. Проверьте Call Stack, scope Registers и Debug Console. После Continue
должна сработать подтверждённая точка строки 31.
5. На остановке проверьте F11 и F10. Курсор должен переходить только после
@@ -153,6 +165,19 @@ Launcher монтирует временную копию CHD как `-hard2`,
6. Завершите сессию кнопкой Stop. Затем повторите профиль с `osx`: вместе с
тем же VS Code-сеансом должно открыться штатное Cocoa-окно debugger MAME.
Stop отправляет DAP `disconnect`; launcher завершает запущенный им MAME.
Чтобы SDL3 не перехватывал `SIGTERM` в необрабатываемое MAME событие Quit,
launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, если переменная
не была задана вручную. Прямой `SIGTERM` и Stop проверены живыми пробниками
на остановке в `main`; они не требуют отдельного патча MAME.
Для одновременного чтения C-сессии из MCP задайте в launch-профиле короткий
фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите
`toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же
session server; его личные точки не заменяют точки VS Code. Порядок запуска,
31 доступный инструмент и ограничения совместного управления описаны в
[руководстве по MCP](sdbg-mcp.md).
Команда палитры `Sprinter: Build Active Project` собирает приложение по
Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в
`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию,
@@ -232,19 +257,25 @@ reset в native console обходят модель состояния VS Code;
локального RPC и launcher; выбор `windows` решает только сторону MAME и не
обеспечивает работу VS Code-интеграции.
Backend `sdbg` входит как воспроизводимый patch к MAME 0.287. Исходники и
рецепты теперь принадлежат самостоятельному проекту MAME:
Backend `sdbg` уже входит в новый fork `MAME.HT` на базе MAME 0.289
(Holub/Tolik). Его штатная сборка и имя исполняемого файла:
```sh
cd /путь/к/MAME
cd /путь/к/MAME.HT
make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp
./sprinter -version
scripts/sprinter/build-variants.sh stock
scripts/sprinter/build-variants.sh sdbg
```
В fork patch уже зафиксирован в Git; для чистого baseline сохранён
`scripts/sprinter/apply-sdbg-patch.sh`. Каждый вариант копируется в
`MAME_HOME/bin/stock/mame.arm` или `MAME_HOME/bin/sdbg/mame.arm` без подмены
активного `MAME_HOME/mame.arm`.
Результат `make``./sprinter`, при запуске система по-прежнему задаётся
аргументом `sprinter`. Для проверки на имеющихся ROM/DSS можно оставить
старый `MAME_HOME` и указать `MAME_BIN=/путь/к/MAME.HT/sprinter`.
В новом fork patch уже зафиксирован в Git; для чистой базы сохранён
`scripts/sprinter/apply-sdbg-patch.sh`. Варианты копируются в
`MAME.HT/runtime/bin/stock/sprinter` и `.../sdbg/sprinter`, не подменяя
активный `MAME_HOME/sprinter`. Старый MAME 0.287 с `mame.arm` также
поддерживается через явный `MAME_BIN` или прежнюю среду запуска.
## Logpoints
@@ -278,7 +309,8 @@ trampoline до первой C-позиции вызывающей функци
## Ручной attach
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py`, затем:
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py` либо
автономный MCP `start_session` и дождитесь `session_status.phase=ready`, затем:
```json
{
@@ -290,7 +322,9 @@ trampoline до первой C-позиции вызывающей функци
```
Команды server и диагностического CLI приведены в
[mame-source-debug-status.md](mame-source-debug-status.md).
[mame-source-debug-status.md](mame-source-debug-status.md). Автономный
MCP-запуск описан в [sdbg-mcp.md](sdbg-mcp.md); DAP disconnect не завершает
MAME, созданный MCP, а `stop_session` завершает его вместе с socket.
## Ограничения MVP
+3 -2
View File
@@ -26,5 +26,6 @@ include $(SPRINTER_ROOT)/app.mk
`make floppy` creates an image under the application's `build/media/`.
For an emulator launch, set `MAME_HOME` to a prepared MAME runtime with
`mame.arm`, `roms/` and `IMG/`; `MAME_BIN`, `MAME_ROMPATH`, `MAME_DSS_IMAGE`,
`MAME_SYSTEM_HDD_IMAGE` and `MAME_BIOS` can override each part.
`sprinter` or `mame.arm`, `roms/` and `IMG/`; `MAME_BIN`, `MAME_ROMPATH`,
`MAME_DSS_IMAGE`, `MAME_SYSTEM_HDD_IMAGE` and `MAME_BIOS` can override each
part.
+2 -1
View File
@@ -50,7 +50,8 @@ The release does **not** include the MAME emulator or the Sprinter ROM /
DSS / HDD images — those are large and have their own licensing. To test:
* **MAME:** install a build with the Sprinter driver and prepare `MAME_HOME`
containing `mame.arm`, `roms/`, DSS floppy and system CHD. `make floppy`
containing `sprinter` (new MAME.HT) or `mame.arm` (legacy), `roms/`, DSS
floppy and system CHD. `make floppy`
creates a local SDK test disk; `make run MAME_HOME=/path/to/runtime` mounts it.
* **Real Sprinter:** copy `.exe` to a floppy or HDD partition that DSS can
see, then `RUN HELLO` from the shell.
+2 -1
View File
@@ -25,6 +25,7 @@ include $(SPRINTER_ROOT)/app.mk
```
`make floppy` создаёт образ в локальном `build/media/` приложения. Для
запуска в эмуляторе задайте `MAME_HOME` с `mame.arm`, `roms/` и `IMG/`;
запуска в эмуляторе задайте `MAME_HOME` с `sprinter` или `mame.arm`,
`roms/` и `IMG/`;
`MAME_BIN`, `MAME_ROMPATH`, `MAME_DSS_IMAGE`, `MAME_SYSTEM_HDD_IMAGE` и
`MAME_BIOS` позволяют переопределить отдельные пути.
+2 -1
View File
@@ -50,7 +50,8 @@ Sprinter / MAME / в любом ESTEX DSS шелле.
они большие и имеют свои лицензии. Для тестирования:
* **MAME:** установите сборку с драйвером Sprinter и подготовьте `MAME_HOME`
с `mame.arm`, `roms/`, дискетой DSS и системным CHD. `make floppy` создаёт
с `sprinter` (новый MAME.HT) или `mame.arm` (прежняя сборка), `roms/`,
дискетой DSS и системным CHD. `make floppy` создаёт
локальный диск SDK-тестов; `make run MAME_HOME=/путь/к/runtime` монтирует его.
* **Реальный Sprinter:** скопируйте `.exe` на флоп или раздел HDD, видимый
для DSS, затем `RUN HELLO` в шелле.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Живой MCP: долгий машинный out из tail-jump можно прервать Pause."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
async def probe() -> None:
home = Path(os.environ['MAME_HOME']).resolve()
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
package = ROOT / 'tests/hello/.sprinter-cc-hello'
with tempfile.TemporaryDirectory(prefix='sprinter-out-mcp-', dir='/tmp') as temp:
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
'--socket', str(Path(temp) / 'session.sock'),
'--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
await call('start_session')
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
status = await call('session_status')
if status['phase'] == 'ready':
break
if status['phase'] in ('failed', 'stopped'):
raise RuntimeError('Launcher: ' + repr(status))
await asyncio.sleep(.2)
else:
raise TimeoutError('hello не дошёл до main')
entered = await call('step_instruction', {'count': 1})
code = await call('disassemble_logical',
{'address': hex(entered['pc']), 'length': 12})
if 'jp (hl)' not in code['text']:
raise RuntimeError('Ожидался tail-jump helper: ' + code['text'])
started_at = time.monotonic()
accepted = await call('step_out_instruction')
response_ms = round((time.monotonic() - started_at) * 1000, 1)
if not accepted['accepted'] or response_ms > 3000:
raise RuntimeError('Машинный out не ответил быстро: ' + repr(accepted))
await asyncio.sleep(.3)
if not (await call('session_status'))['running']:
raise RuntimeError('Tail-jump out неожиданно завершился')
paused = await call('pause_execution')
if (await call('session_status'))['running']:
raise RuntimeError('Pause не остановил долгий out')
print(json.dumps({'event': 'machine_out_pause_verified',
'response_ms': response_ms,
'entered_pc': entered['pc'],
'paused_pc': paused['pc']}, ensure_ascii=False),
flush=True)
finally:
await call('stop_session')
if __name__ == '__main__':
asyncio.run(probe())
+4 -1
View File
@@ -60,7 +60,10 @@ def main():
raise RuntimeError('DAP завершился до авторского лога')
if event.get('event') == 'output': outputs.append(event)
socket = launched['body']['socket']
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6)
identity = rpc_call(socket, 'status')
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6,
session_id=identity['session_id'],
build_id=identity['build_id'])
if not any('total=1' in line for line in tail['lines']):
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
print(json.dumps({'event': 'macro_dual_console_verified',
+88 -3
View File
@@ -23,7 +23,7 @@ os.environ['SPRINTER_PYTHON'] = sys.executable
sys.path.insert(0,str(ROOT/'toolchain'))
from sdbg.model import DebugMap
from sdbg.image import read_ihx
from sdbg.session import DebugSession
from sdbg.session import DebugSession, SessionError
from mame_interactive import build_events
from make_disk import create_floppy_image
from mame_profile import MameProfile, write_keyboard_config
@@ -42,6 +42,12 @@ def wait_dap_stop(engine, timeout=15):
def main():
banked_mode = '--banked' in sys.argv
if '--lifecycle-exit' in sys.argv and '--lifecycle-load' in sys.argv:
raise ValueError('Выберите один lifecycle-сценарий')
lifecycle = 'exit' if '--lifecycle-exit' in sys.argv else (
'load' if '--lifecycle-load' in sys.argv else None)
if lifecycle and not {'--banked', '--bridge', '--server'} <= set(sys.argv):
raise ValueError('Lifecycle-пробник требует --banked --bridge --server')
work=ROOT/'build/sdbg-live'
work.mkdir(parents=True,exist_ok=True)
source=work/'probe.c'
@@ -161,6 +167,8 @@ void worker(void) __banked {
ev=',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events)
report=work/'result.jsonl'
report.write_text('')
control=work/('control-'+uuid.uuid4().hex)
state_file=work/('state-'+uuid.uuid4().hex+'.sta')
lua=work/'probe.lua'
lua.write_text('''if _G.sdbg_probe_loaded then return end
_G.sdbg_probe_loaded=true
@@ -180,14 +188,39 @@ local bp=nil
local false_hits=0
local launch_reported=false
local active=true
local state_loaded=false
local state_load_reported=false
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
-- Первый notifier относится к начальному machine start. После arm это
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
if phase~="booting" then active=false end
end)
_G.sdbg_probe_load_subscription=emu.add_machine_post_load_notifier(function()
state_loaded=true
end)
debug.execution_state="run"
emu.register_periodic(function()
if not active then return end
if state_loaded and not state_load_reported then
local count=0
for _ in pairs(cpu.debug:bplist()) do count=count+1 end
emit('{"event":"state_loaded","breakpoints":' .. count .. '}')
state_load_reported=true
end
if phase=="bridge_wait" then
local control=io.open(CONTROL,"rb")
if control then
local action=control:read("*a");control:close();os.remove(CONTROL)
emit('{"event":"control","action":"' .. action .. '"}')
if action=="exit" then machine:exit()
-- save/load не входят в scheduled_event_pending(): остановленный
-- debugger надо отпустить, иначе операция не дойдёт до timeslice.
elseif action=="save" then machine:save(STATE);debug.execution_state="run"
elseif action=="load" then machine:load(STATE);debug.execution_state="run"
else error("Unknown lifecycle action: " .. action) end
end
return
end
ticks=ticks+1
-- До окна запуска приложения DSS работает вообще без наших точек.
-- Первая клавиша launch sequence придёт через полсекунды после arm.
@@ -234,11 +267,13 @@ emu.register_periodic(function()
if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end
end)
'''.replace('REPORT',json.dumps(str(report))).replace('EVENTS',ev)
.replace('SIGNATURE',','.join(map(str,signature))).replace('MAIN',str(main_address)))
.replace('SIGNATURE',','.join(map(str,signature))).replace('MAIN',str(main_address))
.replace('CONTROL',json.dumps(str(control))).replace('STATE',json.dumps(str(state_file))))
command=[str(profile.binary),'sprinter','-noreadconfig',
'-rompath',str(profile.rompath),'-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k',
'-video','none','-sound','none','-nothrottle','-skip_gameinfo',
'-beta:wd179x:0','35hd','-flop1',str(work/'probe.img'),
'-beta:wd179x:0','35hd','-beta:wd179x:1','35hd',
'-flop1',str(work/'probe.img'),
'-flop2',str(profile.dss_image),
'-hard1',str(system),'-debug','-debugger','none',
'-autoboot_delay','0','-autoboot_script',str(lua)]
@@ -296,6 +331,56 @@ end)
dap=DapEngine()
attached,_=dap.handle('attach',{'socket':str(socket_path)})
assert attached['buildId']==model.manifest['build_id']
if lifecycle:
def send_control(action):
pending=control.with_suffix('.tmp')
pending.write_text(action)
pending.replace(control)
if lifecycle=='load':
send_control('save')
deadline=time.monotonic()+20
while (not state_file.is_file() or state_file.stat().st_size==0):
if process.poll() is not None or time.monotonic()>deadline:
raise RuntimeError('MAME не сохранил state; лог: '+str(work/'mame.log'))
time.sleep(.05)
time.sleep(.25)
dap.handle('pause',{})
points=dap.rpc('set_function_breakpoints',
{'names':['worker']})['breakpoints']
assert points[0]['verified'] and points[0]['backend_ids'],points
send_control(lifecycle)
deadline=time.monotonic()+20
dap_events=[]
while time.monotonic()<deadline:
found,closed=dap.poll_events(.5)
dap_events.extend(found)
if closed: break
assert closed and any(name=='terminated' for name,_ in dap_events),dap_events
try:
dap.rpc('status')
raise AssertionError('Старая DAP-сессия осталась доступной')
except SessionError as error:
assert 'закрыта' in str(error) or 'инвалидирована' in str(error),error
if lifecycle=='exit':
assert process.wait(timeout=5)==0
else:
deadline=time.monotonic()+5
while '"event":"state_loaded"' not in report.read_text():
if process.poll() is not None or time.monotonic()>deadline:
raise RuntimeError('Нет post-load события; лог: '+str(work/'mame.log'))
time.sleep(.05)
loaded=next(json.loads(line) for line in report.read_text().splitlines()
if '"event":"state_loaded"' in line)
assert loaded['breakpoints']==0,loaded
with report.open('a') as file:
file.write(json.dumps({'event':'lifecycle_verified',
'action':lifecycle,'terminated':True,
'backend_breakpoints':points[0]['backend_ids']})+'\n')
print(json.dumps({'event':'lifecycle_verified',
'action':lifecycle,'dap_terminated':True,
'state_breakpoints':0 if lifecycle=='load' else None}))
state_file.unlink(missing_ok=True)
return 0
started=time.monotonic()
points,_=dap.handle('setFunctionBreakpoints',
{'breakpoints':[{'name':'worker'}]})
+227
View File
@@ -0,0 +1,227 @@
#!/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', 'list_breakpoints', 'disassemble_logical'} <= 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))
bytes_before = await call('read_memory',
{'address': hex(location['pc']), 'length': 32})
disassembly = await call('disassemble_logical',
{'address': hex(location['pc']), 'length': 32})
bytes_after = await call('read_memory',
{'address': hex(location['pc']), 'length': 32})
if disassembly['space'] != 'logical_z80' or \
disassembly['address'] != location['pc'] or \
not disassembly['text'].upper().startswith(
f"{location['pc']:04X}:") or \
bytes_before['hex'] != bytes_after['hex']:
raise RuntimeError('Нет дизассемблирования main: ' + repr(disassembly))
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')
lines = disassembly['text'].splitlines()
addresses = [int(line.split(':', 1)[0], 16) for line in lines]
calls = [index for index, line in enumerate(lines)
if ' call ' in ' ' + line.lower() + ' ']
if len(calls) < 2 or calls[0] != 0 or calls[1] <= 1:
raise RuntimeError('Нет двух call в main: ' + disassembly['text'])
async def wait_machine_stop() -> dict:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if not (await call('session_status'))['running']:
return await call('where')
await asyncio.sleep(.05)
paused = await call('pause_execution')
raise TimeoutError('Машинный шаг не завершился; Pause PC=' +
hex(paused['pc']))
await call('step_over_instruction', {'count': 1})
after_over = await wait_machine_stop()
if after_over['pc'] != addresses[1]:
raise RuntimeError('over call не остановился после вызова: ' +
repr(after_over))
await call('step_over_instruction', {'count': calls[1] - 1})
at_call = await wait_machine_stop()
if at_call['pc'] != addresses[calls[1]]:
raise RuntimeError('over count не дошёл до второго call: ' +
repr(at_call))
entered = await call('step_instruction', {'count': 1})
if entered['pc'] == at_call['pc']:
raise RuntimeError('Машинный step не вошёл в возвращаемый вызов')
await call('step_out_instruction')
returned = await wait_machine_stop()
if returned['pc'] != addresses[calls[1] + 1]:
raise RuntimeError('Машинный out не вернулся после call: ' +
repr(returned))
stepped = await call('step_instruction', {'count': 3})
if stepped['pc'] == returned['pc'] or \
(await call('session_status'))['running']:
raise RuntimeError('Три машинных шага не остановились: ' + repr(stepped))
source = str(ROOT / 'tests/hello/hello.c')
before = await call('set_line_breakpoint', {'file': source, 'line': 62})
points = await call('list_breakpoints')
active = [item for item in points['breakpoints']
if item['id'] == before['id']]
if len(active) != 1 or not active[0]['owner'].startswith('mcp:') or \
not any(loc.get('line') == 62 for loc in active[0]['locations']):
raise RuntimeError('Личная точка не отражена в списке: ' + repr(points))
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)
running = await call('session_status')
if not running['running']:
raise RuntimeError('CPU не ожидает getchar перед вводом')
while_running = await call('list_breakpoints')
if not any(item['id'] == after['id']
for item in while_running['breakpoints']):
raise RuntimeError('Не прочитана точка при running CPU')
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'],
'disassembly_head': disassembly['text'][:90],
'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())
+57
View File
@@ -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()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Живой MCP 2.x клиент: stdio → sdbg MCP → общая DAP-сессия."""
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
import sys
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
async def probe(socket: str, source: str, foreign_id: int):
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--socket', socket])
async with Client(parameters) as client:
listed = await client.list_tools()
names = {tool.name for tool in listed.tools}
expected = {'session_status', 'where', 'read_registers', 'read_memory',
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
'claim_control', 'release_control', 'list_shares',
'read_share', 'read_vram', 'read_screen_pixels', 'screenshot',
'read_program_memory', 'list_ports'}
if not expected <= names:
raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names))
async def call(name: str, arguments: dict | None = None):
result = await client.call_tool(name, arguments or {})
if result.is_error:
raise RuntimeError(name + ': ' + str(result.content))
if result.structured_content is None:
raise RuntimeError(name + ': нет structuredContent: '+repr(result.content))
return result.structured_content
status = await call('session_status')
location = await call('where')
registers = await call('read_registers')
memory = await call('read_memory', {'address': hex(location['pc']), 'length': 4})
events = await call('recent_events', {'after': 0})
if not status['build_id'] or location['status'] != 'mapped' or \
not status['session_id'] or \
registers['registers']['PC'] != location['pc'] or \
len(memory['hex']) != 8 or not events['events']:
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
program = await call('read_program_memory', {
'address': hex(0x10000 + location['pc']), 'length': 4})
if program['hex'] != memory['hex'] or program['space'] != 'program':
raise RuntimeError('Raw program и logical Z80 расходятся в main')
ports = await call('list_ports')
if ports['truncated'] or not any(':kbd:ms_naturl:' in item['tag']
for item in ports['ports']):
raise RuntimeError('Порты PC-клавиатуры не найдены: ' + repr(ports)[:500])
shares = await call('list_shares')
vram = [item for item in shares['shares'] if 'vram' in item['tag']]
if len(vram) != 1 or vram[0]['size'] < 4:
raise RuntimeError('VRAM share не найден: ' + repr(shares))
raw_vram = await call('read_vram', {'address': '0', 'length': 4})
same_share = await call('read_share', {
'tag': vram[0]['tag'], 'address': '0', 'length': 4})
if len(raw_vram['hex']) != 8 or raw_vram['hex'] != same_share['hex']:
raise RuntimeError('Чтение VRAM/share различается')
pixels = await call('read_screen_pixels', {
'x': 0, 'y': 0, 'width': 2, 'height': 2})
if len(pixels['hex']) != 16 or not pixels['stale_frame']:
raise RuntimeError('Неверный снимок пикселей: ' + repr(pixels))
shot = await call('screenshot')
snapshot = Path(shot['path'])
if snapshot.suffix != '.png' or snapshot.stat().st_size != shot['size'] or \
snapshot.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
raise RuntimeError('Снимок MAME не является PNG: ' + repr(shot))
own = await call('set_line_breakpoint', {'file': source, 'line': 62})
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
if not foreign.is_error or 'не принадлежит' not in str(foreign.content):
raise RuntimeError('MCP не подтвердил защиту чужой точки VS Code')
lease = await call('claim_control')
if not lease['owner'].startswith('mcp:'):
raise RuntimeError('MCP не захватил управление CPU: ' + repr(lease))
released = await call('release_control')
if released['released'] != lease['owner']:
raise RuntimeError('MCP не освободил управление CPU: ' + repr(released))
print(json.dumps({'event': 'mcp_shared_session_verified',
'build_id': status['build_id'], 'pc': location['pc'],
'tools': len(names), 'foreign_point_rejected': True,
'owned_point_for_cleanup': own['id'],
'control_claim_release': True,
'shares_pixels_png': True,
'program_and_ports': True},
ensure_ascii=False), flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--socket', required=True)
parser.add_argument('--source', required=True)
parser.add_argument('--foreign-id', type=int, required=True)
args = parser.parse_args()
asyncio.run(probe(args.socket, args.source, args.foreign_id))
if __name__ == '__main__':
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Живой raw MCP-пробник на C-приложении hello и подготовленной DSS-среде."""
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
ROOT = Path(__file__).resolve().parents[2]
MAME_REPO = ROOT.parent / "MAME.HT"
sys.path.insert(0, str(ROOT / "toolchain"))
from make_disk import create_floppy_image # noqa: E402
from mame_interactive import basename_83, build_events # noqa: E402
from mame_profile import write_keyboard_config # noqa: E402
from sdbg.image import read_ihx # noqa: E402
from sdbg.model import DebugMap # noqa: E402
from sdbg_launcher import lua_script # noqa: E402
def answer_text(result) -> str:
return "".join(item.text for item in result.content if item.type == "text")
async def call(session: ClientSession, name: str, arguments=None) -> str:
result = await session.call_tool(name, arguments or {})
value = answer_text(result)
if result.isError or value.startswith("ERROR:"):
raise AssertionError(f"{name}: {value}")
return value
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--show-seconds", type=float, default=0,
help="Сколько секунд оставить видимым экран hello перед вводом x")
parser.add_argument("--proof", type=Path,
help="Сохранить снимок работающего hello вне временной сессии")
args = parser.parse_args()
home = Path(os.environ["MAME_HOME"]).resolve()
binary = Path(os.environ.get("MAME_BIN", MAME_REPO / "sprinter")).resolve()
rompath = Path(os.environ.get("MAME_ROMPATH", home / "roms")).resolve()
package = ROOT / "tests/hello/.sprinter-cc-hello"
model = DebugMap(package)
model.verify_executable()
executable = Path(model.manifest["executable_path"])
function = next(item for item in model.functions if item["name"] == "main")
main_address = function["start"]
image = read_ihx(package / (executable.stem + ".ihx"))
signature = bytes(image[addr] for addr in range(
function["start"], min(function["end"], function["start"] + 24)))
variable = next(item for item in model.variables if item["name"] == "errno")
raw_address = 0x10000 + variable["logical_address"]
with tempfile.TemporaryDirectory(prefix="sprinter-raw-hello-") as directory:
state = Path(directory)
for name in ("ipc", "cfg", "nvram", "diff", "snapshot"):
(state / name).mkdir()
write_keyboard_config(state / "cfg")
shutil.copyfile(home / "IMG/sp_hdd_sys.chd", state / "system.chd")
disk = state / "debug.img"
if not create_floppy_image(str(disk),
[(basename_83(executable.name), str(executable))]):
raise RuntimeError("Не удалось создать тестовую дискету")
ready = state / "main.json"
lua = state / "launch.lua"
events = build_events([(0, "a:\\" + basename_83(executable.name).replace(" ", "") + "\n")])
lua_script(lua, ready, main_address, signature, events, 0, 30)
env = dict(os.environ, MAME_MCP_DIR=str(state / "ipc"),
MAME_MCP_SNAP_DIR=str(state / "snapshot"),
SDL_NO_SIGNAL_HANDLERS="1")
command = [str(binary), "sprinter", "-noreadconfig", "-rompath", str(rompath),
"-bios", "v3.06", "-kbd", "ms_naturl,bios=sp2k",
"-video", "soft", "-window", "-sound", "none", "-skip_gameinfo",
"-beta:wd179x:0", "35hd", "-beta:wd179x:1", "35hd",
"-flop1", str(disk), "-flop2", str(home / "IMG/dss171u.img"),
"-hard1", str(state / "system.chd"),
"-debug", "-debugger", "sdbg", "-plugin", "mamebridge",
"-pluginspath", str(MAME_REPO / "plugins"),
"-autoboot_delay", "0", "-autoboot_script", str(lua)]
for name in ("cfg", "nvram", "diff", "snapshot"):
command.extend(["-" + name + "_directory", str(state / name)])
log_path = state / "mame.log"
with log_path.open("w") as log:
machine = subprocess.Popen(command, cwd=state, env=env,
stdout=log, stderr=subprocess.STDOUT)
try:
params = StdioServerParameters(
command=sys.executable, args=[str(MAME_REPO / "src/mame_mcp.py")],
env=env)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
deadline = time.monotonic() + 75
await session.initialize()
while not ready.exists():
if machine.poll() is not None:
raise RuntimeError("MAME завершился до main")
if (state / "main.json.error").exists():
raise RuntimeError((state / "main.json.error").read_text())
if time.monotonic() > deadline:
raise TimeoutError("MAME не дошёл до main")
await asyncio.sleep(0.1)
status = await call(session, "status")
if not status.startswith("state=stop"):
raise AssertionError(f"Ожидалась остановка в main: {status}")
logical = await call(session, "read_logical_memory",
{"address": hex(variable["logical_address"]),
"length": 1})
original = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if logical.strip() != original.strip():
raise AssertionError(f"logical/raw memory diverged: {logical}/{original}")
changed = int(original, 16) ^ 1
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": f"{changed:02X}"})
actual = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if actual.strip() != f"{changed:02X}":
raise AssertionError(f"write_memory failed: {actual}")
await call(session, "write_memory",
{"address": hex(raw_address), "hex_bytes": original.strip()})
restored = await call(session, "read_memory",
{"address": hex(raw_address), "length": 1})
if restored.strip() != original.strip():
raise AssertionError("scratch byte not restored")
shares = await call(session, "list_shares")
if "vram" not in shares:
raise AssertionError(f"VRAM share missing: {shares}")
await call(session, "read_vram", {"address": "0", "length": 4})
await call(session, "read_share",
{"name": "vram", "address": "0", "length": 4})
pixels = await call(session, "read_screen_pixels",
{"x": 0, "y": 0, "width": 2, "height": 2})
if not pixels.strip():
raise AssertionError("screen pixels are empty")
await call(session, "list_ports")
await call(session, "disassemble",
{"address": hex(0x10000 + main_address), "num_bytes": 16})
shot = await call(session, "screenshot", {"name": "raw-hello.png"})
if "raw-hello.png" not in shot or not (state / "snapshot/raw-hello.png").is_file():
raise AssertionError(f"screenshot path missing: {shot}")
point = await call(session, "set_watchpoint",
{"address": hex(raw_address), "length": 2,
"access": "w", "space": "program"})
match = re.search(r"Watchpoint (\d+) set", point)
if not match:
raise AssertionError(f"watchpoint ID missing: {point}")
wp_id = int(match.group(1))
await call(session, "resume")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("errno watchpoint did not stop CPU")
await call(session, "clear_watchpoint", {"index": wp_id})
after_getchar = model.line_locations("hello.c", 63)["locations"][0]["logical_address"]
point = await call(session, "set_breakpoint",
{"address": hex(after_getchar)})
match = re.search(r"Breakpoint (\d+) set", point)
if not match:
raise AssertionError(f"breakpoint ID missing: {point}")
bp_id = int(match.group(1))
await call(session, "resume")
await asyncio.sleep(0.5)
running_shot = await call(session, "screenshot",
{"name": "hello-running.png"})
running_image = state / "snapshot/hello-running.png"
if "hello-running.png" not in running_shot or not running_image.is_file():
raise AssertionError(f"working hello screenshot missing: {running_shot}")
if args.proof:
args.proof.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(running_image, args.proof)
print(f"Снимок работающего hello: {args.proof}", flush=True)
if args.show_seconds:
await asyncio.sleep(args.show_seconds)
await call(session, "move_mouse", {"dx": 1, "dy": 0, "frames": 2})
await call(session, "click_mouse", {"button": "left", "frames": 2})
await call(session, "press_input",
{"port": ":JOY1", "mask": "0x400", "frames": 2})
await call(session, "set_input",
{"port": ":JOY1", "mask": "0x400", "value": 1,
"frames": 2})
await call(session, "press_key", {"key": "x", "frames": 3})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("press_key did not leave getchar")
if f"PC=0x{after_getchar:X}" not in status:
raise AssertionError(f"stopped before line 63: {status}")
await call(session, "clear_breakpoint", {"index": bp_id})
await call(session, "step_over", {"count": 1})
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_over did not stop")
await call(session, "step_out")
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
status = await call(session, "status")
if status.startswith("state=stop"):
break
await asyncio.sleep(0.1)
else:
raise TimeoutError("step_out did not stop")
await call(session, "resume")
await call(session, "type_string", {"text": "a"})
await call(session, "type_text", {"text": "b"})
print(f"main=0x{main_address:04X}; errno=0x{raw_address:X}; "
f"write/read-back OK; watchpoint={wp_id}; "
f"keyboard→line63=0x{after_getchar:04X}; "
f"pixels={pixels.strip()[:50]}; screenshot={running_shot.strip()}")
except BaseException:
print(log_path.read_text(errors="replace")[-4000:], file=sys.stderr)
raise
finally:
machine.terminate()
machine.wait(timeout=8)
if __name__ == "__main__":
asyncio.run(main())
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Живой MCP-прогон: строка с Shift и Enter проходит через DSS gets()."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import time
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
async def probe() -> None:
home = Path(os.environ['MAME_HOME']).resolve()
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
package = ROOT / 'tests/gets/.sprinter-cc-gets'
with tempfile.TemporaryDirectory(prefix='sprinter-type-mcp-', dir='/tmp') as temp:
socket = str(Path(temp) / 'session.sock')
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
'--socket', socket, '--mame-home', str(home),
'--mame-bin', str(binary)])
async with Client(parameters) as client:
async def call(name: str, arguments: dict | None = None) -> dict:
result = await client.call_tool(name, arguments or {})
if result.is_error or result.structured_content is None:
raise RuntimeError(name + ': ' + repr(result.content))
return result.structured_content
names = {tool.name for tool in (await client.list_tools()).tools}
if 'type_string' not in names:
raise RuntimeError('MCP не публикует type_string')
await call('start_session')
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
status = await call('session_status')
if status['phase'] == 'ready':
break
if status['phase'] in ('failed', 'stopped'):
raise RuntimeError('Launcher: ' + repr(status))
await asyncio.sleep(.2)
else:
raise TimeoutError('gets не дошёл до main')
source = str(ROOT / 'tests/gets/gets.c')
point = await call('set_line_breakpoint', {'file': source, 'line': 16})
await call('continue_execution')
await asyncio.sleep(.5)
if not (await call('session_status'))['running']:
raise RuntimeError('gets не ожидает строку')
typed = await call('type_string', {'value': 'Ab9\n'})
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
status = await call('session_status')
if not status['running']:
break
await asyncio.sleep(.1)
else:
shot = await call('screenshot')
destination = Path('/private/tmp/sprinter-c-mcp-gets-timeout.png')
shutil.copyfile(shot['path'], destination)
raise TimeoutError('Enter не завершил gets: ' +
repr({'typed': typed, 'status': status,
'screenshot': str(destination)}))
location = await call('where')
if typed['typed'] != 4 or not typed['complete'] or \
not any(item['line'] == 16 for item in location['sources']):
raise RuntimeError('Неверный результат набора: ' +
repr((typed, location)))
shot = await call('screenshot')
destination = Path('/private/tmp/sprinter-c-mcp-gets.png')
shutil.copyfile(shot['path'], destination)
await call('clear_breakpoint', {'identifier': point['id']})
print(json.dumps({'event': 'mcp_type_string_verified',
'tools': len(names), 'typed': typed,
'line': 16, 'screenshot': str(destination)},
ensure_ascii=False), flush=True)
finally:
await call('stop_session')
if __name__ == '__main__':
asyncio.run(probe())
+194 -10
View File
@@ -10,6 +10,7 @@ import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import time
@@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2]
PYTHON = Path.home()/'.pyenv/shims/python'
sys.path.insert(0, str(ROOT/'toolchain'))
from mame_interactive import resolve
from sdbg.server import rpc_call
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call as raw_rpc_call
from sdbg.session import SessionError
def send(process, sequence, command, arguments=None):
@@ -88,8 +90,26 @@ def wait_event(process, buffer, event, timeout):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', default=None)
parser.add_argument('--socket', default=None,
help='фиксированный Unix socket DAP/MCP-сеанса')
parser.add_argument('--app-hdd', default=None)
parser.add_argument('--launch-path', default=None)
parser.add_argument('--mcp-python', default=None,
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
parser.add_argument('--orphan-expiry', action='store_true',
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
parser.add_argument('--screen-mcp-python', default=None,
help='Python с MCP SDK 2.x для снимка hello во время getchar')
parser.add_argument('--screen-proof', default=None,
help='сохранить PNG работающего hello вне временной сессии')
parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
parser.add_argument('--exit-while-stopped', action='store_true',
help='аварийно завершить собственный MAME на main и ждать DAP terminated')
parser.add_argument('--stop-while-stopped', action='store_true',
help='послать DAP disconnect на main и проверить завершение MAME')
parser.add_argument('--term-while-stopped', action='store_true',
help='послать SIGTERM собственному MAME на main и проверить DAP terminated')
options, _ = parser.parse_known_args()
if not PYTHON.is_file():
raise RuntimeError('Нет local pyenv shim: '+str(PYTHON))
@@ -104,12 +124,22 @@ def main():
_, buffer, _ = wait_response(process, buffer, 'initialize', 10)
debugger = 'osx' if '--osx' in sys.argv else 'sdbg'
launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30}
if options.socket:
launch['socket'] = options.socket
if options.app_hdd:
launch['appHdd'] = str(Path(options.app_hdd).resolve())
if options.launch_path:
launch['launchPath'] = options.launch_path
send(process, 2, 'launch', launch)
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
session_identity = raw_rpc_call(launched['body']['socket'], 'status')
def session_rpc(path, method, arguments=None, timeout=10):
identity = {'session_id': session_identity['session_id'],
'build_id': session_identity['build_id']}
if method in RPC_GENERATION_MUTATIONS:
current = raw_rpc_call(path, 'status', **identity)
identity['generation'] = current['generation']
return raw_rpc_call(path, method, arguments, timeout, **identity)
if not any(item.get('event') == 'initialized' for item in events):
initialized, buffer = receive(process, buffer, 5)
if initialized.get('event') != 'initialized':
@@ -121,6 +151,130 @@ def main():
frame = frame_response['body']['stackFrames'][0]
if frame['name'] != 'main':
raise RuntimeError('Не main: '+str(frame))
if options.mcp_python:
source = str(ROOT/'tests/hello/hello.c')
send(process, 5, 'setBreakpoints', {
'source': {'path': source}, 'breakpoints': [{'line': 31}]})
points, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10)
dap_point = points['body']['breakpoints'][0]
if not dap_point['verified']:
raise RuntimeError('DAP-точка строки 31 не подтверждена')
probe = subprocess.run([
options.mcp_python, str(ROOT/'tests/sdbg/run_mcp_sdk_probe.py'),
'--socket', launched['body']['socket'], '--source', source,
'--foreign-id', str(dap_point['id'])],
capture_output=True, text=True, timeout=45, check=False)
if probe.returncode:
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
if options.orphan_expiry:
owner = 'mcp:orphan-expiry-probe'
orphan = session_rpc(launched['body']['socket'], 'break_line', {
'file': source, 'line': 62, 'owner': owner})
session_rpc(launched['body']['socket'], 'claim_control', {'owner': owner})
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
deadline = time.monotonic()+36
while time.monotonic() < deadline:
events = session_rpc(launched['body']['socket'], 'events', {'after': 0})
if any(event['event'] == 'owner_expired' and
event['body']['owner'] == owner for event in events['events']):
break
time.sleep(.5)
else:
raise RuntimeError('MCP owner не истёк за 36 с')
try:
session_rpc(launched['body']['socket'], 'clear_breakpoint', {
'id': orphan['id'], 'owner': owner})
except SessionError as error:
if 'Неизвестная логическая точка' not in str(error):
raise
else:
raise RuntimeError('MCP orphan-точка не была очищена')
send(process, 6, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10)
stopped, buffer = wait_event(process, buffer, 'stopped', 20)
if not stopped:
raise RuntimeError('После MCP DAP-точка строки 31 не сработала')
send(process, 7, 'stackTrace')
at_point, buffer, _ = wait_response(process, buffer, 'stackTrace', 10)
if at_point['body']['stackFrames'][0]['line'] != 31:
raise RuntimeError('После MCP ожидалась DAP-точка строки 31')
send(process, 8, 'setBreakpoints', {
'source': {'path': source}, 'breakpoints': []})
_, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10)
send(process, 9, 'continue')
_, buffer, _ = wait_response(process, buffer, 'continue', 10)
stale, buffer = wait_event(process, buffer, 'stopped', 3)
if stale:
raise RuntimeError('Точка MCP осталась после закрытия stdio-клиента')
print(probe.stdout.strip(), flush=True)
if options.orphan_expiry:
print(json.dumps({'event': 'mcp_orphan_expired_and_cleared',
'id': orphan['id']}, ensure_ascii=False), flush=True)
send(process, 10, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
if options.socket:
deadline = time.monotonic()+10
while Path(options.socket).exists() and time.monotonic() < deadline:
time.sleep(.1)
if Path(options.socket).exists():
raise RuntimeError('После DAP disconnect остался Unix socket сессии')
return 0
if options.stop_while_stopped:
mame_pid = launched['body']['mamePid']
send(process, 5, 'disconnect')
_, buffer, _ = wait_response(process, buffer, 'disconnect', 10)
deadline = time.monotonic()+12
while time.monotonic() < deadline:
try:
os.kill(mame_pid, 0)
except ProcessLookupError:
break
time.sleep(.1)
else:
raise RuntimeError('После DAP disconnect MAME остался запущен')
code = process.wait(timeout=3)
if code != 0:
raise RuntimeError(f'После DAP disconnect адаптер завершился с rc={code}')
print(json.dumps({'event': 'vscode_stop_closes_mame', 'frame': frame},
ensure_ascii=False), flush=True)
return 0
if options.term_while_stopped:
mame_pid = launched['body']['mamePid']
os.kill(mame_pid, signal.SIGTERM)
deadline = time.monotonic()+12
while time.monotonic() < deadline:
try:
message, buffer = receive(process, buffer, deadline-time.monotonic())
except TimeoutError:
break
if message.get('event') == 'terminated':
print(json.dumps({'event': 'sigterm_terminates_dap', 'frame': frame},
ensure_ascii=False), flush=True)
send(process, 5, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
return 0
try:
os.kill(mame_pid, 0)
status = 'alive'
except ProcessLookupError:
status = 'exited'
process_state = subprocess.run(
['ps', '-p', str(mame_pid), '-o', 'stat=,ppid=,comm='],
capture_output=True, text=True, check=False).stdout.strip()
raise RuntimeError('SIGTERM: DAP terminated не получен за 12 с; MAME '+
status+'; ps='+repr(process_state))
if options.exit_while_stopped:
os.kill(launched['body']['mamePid'], signal.SIGKILL)
deadline = time.monotonic()+15
while time.monotonic() < deadline:
message, buffer = receive(process, buffer, deadline-time.monotonic())
if message.get('event') == 'terminated':
print(json.dumps({'event': 'idle_mame_exit_terminates_dap',
'frame': frame}, ensure_ascii=False), flush=True)
send(process, 5, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
return 0
raise RuntimeError('После закрытия остановленного MAME нет DAP terminated')
if '--waitkey' in sys.argv:
send(process, 5, 'setBreakpoints', {
'source': {'path': str(ROOT/'tests/hello/hello.c')},
@@ -139,23 +293,52 @@ def main():
before = at_getchar['body']['stackFrames'][0]
if before['line'] != 62:
raise RuntimeError('Остановка не на getchar: '+str(before))
socket_path = launched['body']['socket']
clear_point = None
if options.clear_while_running:
clear_point = session_rpc(socket_path, 'break_line', {
'file': str(ROOT/'tests/hello/hello.c'), 'line': 63,
'owner': 'probe:running-clear'})
started = time.monotonic()
send(process, 8, 'next')
_, buffer, _ = wait_response(process, buffer, 'next', 3)
response_ms = round((time.monotonic()-started)*1000, 2)
if options.screen_mcp_python:
time.sleep(.6)
screen_command = [options.screen_mcp_python,
str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'),
'--socket', socket_path]
if options.screen_proof:
screen_command += ['--proof', options.screen_proof]
screen_probe = subprocess.run(screen_command, capture_output=True,
text=True, timeout=20, check=False)
if screen_probe.returncode:
raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:])
print(screen_probe.stdout.strip(), flush=True)
if clear_point:
snapshot = session_rpc(socket_path, 'snapshot', timeout=5)
if snapshot['state'] != 'running':
raise RuntimeError('CPU не выполняется перед очисткой точки')
try:
session_rpc(socket_path, 'read_memory', {'address': 0, 'length': 1})
raise RuntimeError('Чтение памяти разрешено при running CPU')
except SessionError as error:
if 'остановленного CPU' not in str(error):
raise
session_rpc(socket_path, 'clear_breakpoint', {
'id': clear_point['id'], 'owner': 'probe:running-clear'})
if '--emulated-key' in sys.argv:
socket_path = launched['body']['socket']
first = rpc_call(socket_path, 'snapshot', timeout=5)
first = session_rpc(socket_path, 'snapshot', timeout=5)
time.sleep(1)
second = rpc_call(socket_path, 'snapshot', timeout=5)
second = session_rpc(socket_path, 'snapshot', timeout=5)
if second['paused'] or second['time'] <= first['time']:
raise RuntimeError('MAME не обновляет input frames: '+
str((first, second)))
tag, mask, _ = resolve('x')
rpc_call(socket_path, 'input_key',
session_rpc(socket_path, 'input_key',
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
time.sleep(.15)
rpc_call(socket_path, 'input_key',
session_rpc(socket_path, 'input_key',
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
if not key_stop:
@@ -165,7 +348,7 @@ def main():
after = after_response['body']['stackFrames'][0]
if after['line'] != 63:
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
ascii_code = ascii_code['registers']['DE'] & 0xff
if ascii_code != ord('x'):
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
@@ -173,14 +356,15 @@ def main():
'at': before, 'after': after,
'ascii': ascii_code,
'emulated_time_advanced': second['time']-first['time'],
'next_response_ms': response_ms},
'next_response_ms': response_ms,
'running_clear_verified': bool(clear_point)},
ensure_ascii=False), flush=True)
send(process, 10, 'disconnect')
wait_response(process, buffer, 'disconnect', 10)
return 0
if '--manual-key' in sys.argv:
socket_path = launched['body']['socket']
keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards']
keyboard_state = session_rpc(socket_path, 'snapshot', timeout=5)['keyboards']
if keyboard_state.get(':kbd:ms_naturl') is not True:
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
str(keyboard_state))
@@ -197,7 +381,7 @@ def main():
after = after_response['body']['stackFrames'][0]
if after['line'] != 63:
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
ascii_code = ascii_code['registers']['DE'] & 0xff
if ascii_code != ord('x'):
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
+8
View File
@@ -106,6 +106,14 @@ class DapTests(unittest.TestCase):
self.assertIn('пропущено 2', events[0][1]['output'])
self.assertEqual(events[1][1]['output'], 'total=42\n')
def test_closed_session_always_terminates_dap(self):
self.dap._rpc_override = lambda method, args: {
'events': [], 'first': 1, 'last': 0, 'closed': True,
}
events, closed = self.dap.poll_events(timeout=0)
self.assertTrue(closed)
self.assertEqual(events, [('terminated', {'restart': False})])
def test_stdio_framing_without_attach(self):
requests = [
{'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}},
+36
View File
@@ -8,9 +8,25 @@ import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg_launcher import disk_entries, lua_script, write_keyboard_config
from mame_profile import MameProfile
class LauncherTests(unittest.TestCase):
def test_mame_profile_prefers_sprinter_and_keeps_legacy_binary(self):
with tempfile.TemporaryDirectory() as directory:
home = Path(directory).resolve()
legacy = home / 'mame.arm'
legacy.touch()
self.assertEqual(MameProfile.resolve({'MAME_HOME': directory,
'MAME_BIN': ''}).binary, legacy)
modern = home / 'sprinter'
modern.touch()
self.assertEqual(MameProfile.resolve({'MAME_HOME': directory,
'MAME_BIN': ''}).binary, modern)
self.assertEqual(MameProfile.resolve({'MAME_HOME': directory,
'MAME_BIN': str(legacy)}).binary,
legacy)
def test_lua_tables_have_expected_shape(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'launch.lua'
@@ -44,5 +60,25 @@ class LauncherTests(unittest.TestCase):
self.assertEqual(keyboards, {':': '1', ':kbd:ms_naturl': '1'})
def test_existing_config_is_merged_not_replaced(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/'sprinter.cfg'
path.write_text('\ufeff<?xml version="1.0"?>\n<mameconfig version="10">'
'<system name="sprinter"><input>'
'<keyboard tag=":" enabled="0" />'
'<port tag=":TURBO" type="OTHER" mask="1" defvalue="0" value="1" />'
'</input><video><target index="0" /></video>'
'</system></mameconfig>', encoding='utf-8')
write_keyboard_config(Path(directory))
root = ET.parse(path).getroot()
system = root.find('system')
self.assertEqual([item.tag for item in system.find('input')],
['keyboard', 'keyboard', 'port'])
keyboards = {item.attrib['tag']: item.attrib['enabled']
for item in system.findall('input/keyboard')}
self.assertEqual(keyboards, {':': '1', ':kbd:ms_naturl': '1'})
self.assertEqual(system.find('input/port').attrib['value'], '1')
self.assertIsNotNone(system.find('video/target'))
if __name__ == '__main__':
unittest.main()
+127
View File
@@ -0,0 +1,127 @@
"""MCP передаёт команды одной сессии и не удаляет чужие точки."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
from sdbg.mcp_adapter import McpSession
from sdbg.session import SessionError
class McpAdapterTests(unittest.TestCase):
def setUp(self):
self.calls = []
def rpc(path, method, arguments, timeout=10, **identity):
self.calls.append((path, method, arguments, timeout, identity))
if method == 'status':
return {'session_id': 'test-session', 'build_id': 'test-build',
'generation': 7}
return {'method': method, 'arguments': arguments}
self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
def test_personal_breakpoints_are_tagged_and_cleared_by_owner(self):
self.client.break_line('/src/main.c', 12)
self.client.break_function('main')
self.client.clear_breakpoint(7)
self.client.clear_owned_breakpoints()
self.assertTrue(self.client.owner.startswith('mcp:'))
for _, method, arguments, _, identity in self.calls:
if method == 'status':
continue
self.assertEqual(arguments['owner'], self.client.owner)
self.assertEqual(identity['session_id'], 'test-session')
self.assertEqual(identity['build_id'], 'test-build')
self.assertEqual([item[1] for item in self.calls if item[1] != 'status'],
['break_line', 'break_function', 'clear_breakpoint',
'clear_owned_breakpoints'])
def test_memory_and_events_validate_before_rpc(self):
self.client.read_memory('0xc000', 16)
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
self.client.events(after=4, timeout=12)
self.assertEqual(self.calls[-1][1:4],
('events', {'after': 4, 'timeout': 12,
'owner': self.client.owner}, 14))
before = len(self.calls)
with self.assertRaisesRegex(SessionError, 'Адрес памяти'):
self.client.read_memory('not-an-address')
with self.assertRaisesRegex(SessionError, 'timeout'):
self.client.events(timeout=31)
with self.assertRaisesRegex(SessionError, 'kind'):
self.client.step_source('back')
self.assertEqual(len(self.calls), before)
def test_machine_step_count_is_validated_before_rpc(self):
before = len(self.calls)
for count in (0, 65, True):
with self.assertRaisesRegex(SessionError, '1..64'):
self.client.step_instruction(count)
self.assertEqual(len(self.calls), before)
self.client.step_instruction(3)
self.assertEqual(self.calls[-1][1], 'step')
self.assertEqual(self.calls[-1][2]['count'], 3)
self.client.step_over_instruction(2)
self.assertEqual(self.calls[-1][1], 'step_over_instruction')
self.assertEqual(self.calls[-1][2]['count'], 2)
self.client.step_out_instruction()
self.assertEqual(self.calls[-1][1], 'step_out_instruction')
before = len(self.calls)
with self.assertRaisesRegex(SessionError, '1..64'):
self.client.step_over_instruction(0)
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))
def test_type_string_checks_layout_before_input_and_releases_each_key(self):
calls = []
frame = 0
def rpc(path, method, arguments, timeout=10, **identity):
nonlocal frame
calls.append((method, arguments))
if method == 'status':
return {'session_id': 'test-session', 'build_id': 'test-build',
'generation': 7, 'running': True}
if method == 'snapshot':
frame += 1
return {'state': 'running', 'frame': frame}
return {}
client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
with self.assertRaisesRegex(SessionError, 'нет раскладки'):
client.type_string('a€')
self.assertEqual(calls, [])
result = client.type_string('ab')
self.assertEqual(result, {'requested': 2, 'typed': 2,
'complete': True, 'stopped': False})
keys = [args for method, args in calls if method == 'input_key']
self.assertEqual([item['down'] for item in keys],
[True, False, True, False])
if __name__ == '__main__':
unittest.main()
+267 -7
View File
@@ -4,6 +4,7 @@ import sys
import tempfile
import threading
import unittest
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'toolchain'))
@@ -18,11 +19,21 @@ class DummyModel:
class DummyBridge:
def __init__(self): self.calls = []
def __init__(self):
self.calls = []
self.state = 'stopped'
self.generation = 1
def close(self): pass
def request(self, command, **arguments):
self.calls.append((command, arguments))
if command == 'snapshot': return {'state': self.state}
if command == 'console_print': return {'printed': True}
if command == 'memory': return {'hex': '00' * arguments['length']}
if command == 'disassemble_logical':
return {'space': 'logical_z80', 'address': arguments['address'],
'length': arguments['length'], 'text': '8100: NOP',
'generation': self.generation}
if command == 'key': return {'accepted': True}
raise AssertionError(command)
@@ -37,13 +48,24 @@ class DummySession:
def attach(self):
return {'build_id': 'test', 'location': {'status': 'mapped'}}
def where(self, snapshot=None):
return {'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100,
'sources': [{'file': '/src/main.c', 'line': 3}]}
def refresh(self):
return SimpleNamespace(generation=1, bank_pages={})
def break_line(self, filename, line, enabled=True):
if line == 99:
raise SessionError('нет адреса')
identifier = self.next_id
self.next_id += 1
return {'id': identifier, 'backend_ids': [identifier],
'conditions': [], 'locations': [{'line': line}], 'enabled': enabled}
'conditions': [], 'locations': [{'line': line, 'link_address': 0x8100}],
'enabled': enabled}
def break_function(self, name, enabled=True):
return self.break_line('/src/main.c', 3, enabled=enabled)
def clear_breakpoint(self, identifier):
self.cleared.append(identifier)
@@ -89,13 +111,16 @@ class StepBridge:
def __init__(self, owner):
self.owner = owner
self.calls = []
self.step_counts = []
def close(self): pass
def request(self, command, **arguments):
self.calls.append(command)
if command in ('step', 'step_over', 'step_out'):
self.owner.index = min(self.owner.index + 1,
count = arguments.get('count', 1) if command in ('step', 'step_over') else 1
self.step_counts.append(count)
self.owner.index = min(self.owner.index + count,
len(self.owner.locations) - 1)
return {'accepted': True}
if command == 'snapshot':
@@ -128,9 +153,10 @@ class StepSession:
class WaitingBridge:
"""Машинный over ожидает внешний ввод, но pause должен остаться доступен."""
"""Машинный over/out ожидает ввод, но Pause должен остаться доступен."""
def __init__(self):
self.paused = False
self.started = False
self.calls = []
def close(self): pass
@@ -138,11 +164,12 @@ class WaitingBridge:
def request(self, command, **arguments):
self.calls.append(command)
if command == 'snapshot':
return {'state': 'stopped' if self.paused else 'running'}
return {'state': 'running' if self.started and not self.paused else 'stopped'}
if command == 'pause':
self.paused = True
return {'accepted': True}
if command == 'step_over':
if command in ('step_over', 'step_out'):
self.started = True
return {'accepted': True}
raise AssertionError(command)
@@ -170,8 +197,26 @@ class ServerTests(unittest.TestCase):
try:
status = rpc_call(server.path, 'status')
self.assertEqual(status['build_id'], 'test')
events = rpc_call(server.path, 'events', {'after': 0})
identity = {'session_id': status['session_id'],
'build_id': status['build_id']}
events = rpc_call(server.path, 'events', {'after': 0}, **identity)
self.assertEqual(events['events'][0]['event'], 'stopped')
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0})
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0},
session_id='old', build_id=status['build_id'])
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0},
session_id=status['session_id'], build_id='old-build')
with self.assertRaisesRegex(SessionError, 'Устаревшая generation'):
rpc_call(server.path, 'break_line',
{'file': '/src/main.c', 'line': 3},
generation=0, **identity)
point = rpc_call(server.path, 'break_line',
{'file': '/src/main.c', 'line': 3},
generation=status['generation'], **identity)
self.assertEqual(point['id'], 1)
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
rpc_call(server.path, 'unknown')
finally:
@@ -180,6 +225,165 @@ class ServerTests(unittest.TestCase):
server.close()
controller.close()
def test_individual_breakpoints_keep_owner_and_source_step_priority(self):
session = DummySession()
controller = SessionController(session)
try:
mine = controller.call('break_line',
{'file': '/src/main.c', 'line': 3, 'owner': 'mcp:a'})
other = controller.call('break_function', {'name': 'main', 'owner': 'mcp:b'})
controller.breakpoint_info[mine['id']]['guard_conditions'] = ['PG3 == 0x12']
listed = controller.call('list_breakpoints', {})['breakpoints']
self.assertEqual([(item['id'], item['owner']) for item in listed],
[(mine['id'], 'mcp:a'), (other['id'], 'mcp:b')])
self.assertEqual(listed[0]['locations'][0]['link_address'], 0x8100)
self.assertEqual(listed[0]['locations'][0]['bank_guard'], 'PG3 == 0x12')
self.assertTrue(controller._has_stop_breakpoint(session.where()))
with self.assertRaisesRegex(SessionError, 'не принадлежит'):
controller.call('clear_breakpoint', {'id': other['id'], 'owner': 'mcp:a'})
self.assertEqual(controller.call('clear_owned_breakpoints', {'owner': 'mcp:a'}),
{'cleared': [mine['id']]})
self.assertEqual(session.cleared, [mine['id']])
self.assertIn(other['id'], controller.breakpoint_info)
controller.call('clear_breakpoint', {'id': other['id'], 'owner': 'mcp:b'})
self.assertFalse(controller.breakpoint_info)
finally:
controller.close()
def test_disassemble_logical_is_bounded_and_labels_bank_mapping(self):
controller = SessionController(DummySession())
try:
result = controller.call('disassemble_logical',
{'address': 0x8100, 'length': 32})
self.assertEqual(result['space'], 'logical_z80')
self.assertEqual(result['address'], 0x8100)
self.assertEqual(result['bank_pages'], {})
with self.assertRaisesRegex(SessionError, 'Дизассемблирование'):
controller.call('disassemble_logical',
{'address': 0xffff, 'length': 2})
self.assertEqual(len([call for call in controller.session.bridge.calls
if call[0] == 'disassemble_logical']), 1)
finally:
controller.close()
def test_control_lease_blocks_other_client_and_can_be_released_or_expire(self):
controller = SessionController(DummySession())
try:
first = controller.call('claim_control', {'owner': 'mcp:a'})
self.assertEqual(first['owner'], 'mcp:a')
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
with self.assertRaisesRegex(SessionError, 'mcp:a'):
controller.call('claim_control', {'owner': 'mcp:b'})
controller.call('renew_control', {'owner': 'mcp:a'})
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
controller.call('release_control', {'owner': 'mcp:b'})
self.assertEqual(controller.call('status', {})['control_owner'], 'mcp:a')
controller.call('release_control', {'owner': 'mcp:a'})
self.assertEqual(controller.call('claim_control', {'owner': 'mcp:b'})['owner'], 'mcp:b')
controller.control_deadline = 0
self.assertEqual(controller.call('claim_control', {'owner': 'cli'})['owner'], 'cli')
self.assertFalse(controller.call('renew_control', {'owner': 'mcp:b'})['has_control'])
finally:
controller.close()
def test_expired_mcp_owner_clears_points_without_touching_dap_points(self):
session = DummySession()
controller = SessionController(session)
try:
mine = controller.call('break_line', {
'file': '/src/main.c', 'line': 3, 'owner': 'mcp:lost'})
dap = controller.call('break_function', {'name': 'main', 'owner': 'cli'})
controller.call('claim_control', {'owner': 'mcp:lost'})
controller.owner_deadlines['mcp:lost'] = 0
with controller.lock:
controller._reap_owners()
self.assertIn(mine['id'], session.cleared)
self.assertNotIn(dap['id'], session.cleared)
self.assertIsNone(controller.call('status', {})['control_owner'])
self.assertEqual(controller.events[-1]['event'], 'owner_expired')
finally:
controller.close()
def test_expired_owner_releases_held_key(self):
session = DummySession()
controller = SessionController(session)
try:
with controller.lock:
controller.running = True
session.bridge.state = 'running'
controller.call('input_key', {
'owner': 'mcp:lost', 'tag': ':kbd:ms_naturl',
'mask': 0x400, 'down': True})
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': True}), session.bridge.calls)
controller.owner_deadlines['mcp:lost'] = 0
controller._reap_owners()
self.assertIn(('key', {'tag': ':kbd:ms_naturl', 'mask': 0x400,
'down': False}), session.bridge.calls)
self.assertFalse(controller.held_inputs)
finally:
controller.close()
def test_read_memory_is_bounded_and_generation_tied(self):
session = DummySession()
controller = SessionController(session)
try:
result = controller.call('read_memory', {'address': 0xfffe, 'length': 2})
self.assertEqual(result, {'address': 0xfffe, 'length': 2, 'hex': '0000',
'generation': 1, 'bank_pages': {}})
with self.assertRaisesRegex(SessionError, 'за 64 КБ'):
controller.call('read_memory', {'address': 0xffff, 'length': 2})
with self.assertRaisesRegex(SessionError, 'длина 1..256'):
controller.call('read_memory', {'address': 0, 'length': 257})
finally:
controller.close()
def test_screen_and_share_reads_reject_oversized_requests(self):
controller = SessionController(DummySession())
try:
with self.assertRaisesRegex(SessionError, 'длина 1..4096'):
controller.call('read_share', {'tag': ':vram', 'address': 0,
'length': 4097})
with self.assertRaisesRegex(SessionError, 'точный tag'):
controller.call('read_share', {'tag': '', 'address': 0,
'length': 1})
with self.assertRaisesRegex(SessionError, '8192 пикселей'):
controller.call('read_screen_pixels', {'x': 0, 'y': 0,
'width': 128, 'height': 128})
finally:
controller.close()
def test_idle_snapshot_detects_invalidation(self):
session = DummySession()
controller = SessionController(session)
try:
session.bridge.state = 'invalidated'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: controller.closed,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'invalidated')
self.assertEqual(controller.events[-1]['body']['reason'], 'reset_or_load')
finally:
controller.close()
def test_native_debugger_continue_is_reported(self):
session = DummySession()
controller = SessionController(session)
try:
session.bridge.state = 'running'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: controller.running,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'continued')
self.assertEqual(controller.events[-1]['body']['reason'], 'external')
session.bridge.state = 'stopped'
with controller.changed:
self.assertTrue(controller.changed.wait_for(lambda: not controller.running,
timeout=2))
self.assertEqual(controller.events[-1]['event'], 'stopped')
finally:
controller.close()
def test_source_breakpoints_replace_and_rollback(self):
session = DummySession()
controller = SessionController(session)
@@ -281,6 +485,45 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_instruction_step_count_is_bounded_and_forwarded(self):
session = StepSession()
controller = SessionController(session)
try:
with self.assertRaisesRegex(SessionError, '1..64'):
controller.call('step', {'count': 0, 'owner': 'mcp:test'})
for bad in (65, 1.5, True):
with self.assertRaisesRegex(SessionError, '1..64'):
controller.call('step', {'count': bad, 'owner': 'mcp:test'})
result = controller.call('step', {'count': 2, 'owner': 'mcp:test'})
self.assertEqual(result['sources'][0]['line'], 4)
self.assertEqual(session.bridge.step_counts, [2])
finally:
controller.close()
def test_machine_over_and_out_are_separate_from_source_steps(self):
session = StepSession()
controller = SessionController(session)
try:
for bad in (0, 65, 1.5):
with self.assertRaisesRegex(SessionError, '1..64'):
controller.call('step_over_instruction',
{'count': bad, 'owner': 'mcp:test'})
over = controller.call('step_over_instruction',
{'count': 2, 'owner': 'mcp:test'})
self.assertEqual(over['accepted'], True)
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 4)
self.assertEqual(session.bridge.step_counts, [2])
session.index = 0
out = controller.call('step_out_instruction', {'owner': 'mcp:test'})
self.assertEqual(out['accepted'], True)
self.wait_source_step(controller)
self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 3)
self.assertEqual([name for name in session.bridge.calls
if name != 'snapshot'], ['step_over', 'step_out'])
finally:
controller.close()
def test_source_step_preserves_user_breakpoint_on_same_line(self):
session = StepSession()
controller = SessionController(session)
@@ -312,6 +555,23 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_waiting_machine_over_out_keep_pause_available(self):
for method in ('step_over_instruction', 'step_out_instruction'):
with self.subTest(method=method):
session = StepSession()
session.bridge = WaitingBridge()
controller = SessionController(session)
try:
started = controller.call(method, {'owner': 'mcp:test'})
self.assertTrue(started['accepted'])
self.assertTrue(controller.running)
stopped = controller.call('pause', {'owner': 'mcp:test'})
self.assertEqual(stopped['sources'][0]['line'], 3)
self.assertFalse(controller.running)
self.assertIn('pause', session.bridge.calls)
finally:
controller.close()
if __name__ == '__main__':
unittest.main()
+19
View File
@@ -127,6 +127,25 @@ class SessionTests(unittest.TestCase):
bridge.calls)
session.clear_breakpoint(result['id'])
def test_partial_breakpoint_clear_retains_remaining_backend_id(self):
class FlakyBridge(FakeBridge):
failed = False
def request(self, command, **args):
if command == 'clear' and args['id'] == 11 and not self.failed:
self.failed = True
raise SessionError('временная ошибка удаления')
return super().request(command, **args)
bridge = FlakyBridge(self.memory)
session = DebugSession(self.model, bridge)
session.breakpoints[1] = [10, 11]
with self.assertRaisesRegex(SessionError, 'временная ошибка'):
session.clear_breakpoint(1)
self.assertEqual(session.breakpoints[1], [11])
self.assertEqual(session.clear_breakpoint(1)['backend_ids'], [11])
self.assertNotIn(1, session.breakpoints)
def test_stale_source_and_unmapped_bank_are_rejected(self):
session = DebugSession(self.model, FakeBridge(self.memory))
session.attach()
+50 -12
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import sys
import xml.etree.ElementTree as ET
@dataclass(frozen=True)
@@ -26,8 +28,15 @@ class MameProfile:
def selected(key: str, suffix: str) -> Path | None:
return _path(values.get(key)) or (home / suffix if home else None)
binary = _path(values.get("MAME_BIN"))
if binary is None and home:
# Новая Sprinter-сборка MAME.HT называется sprinter;
# старые установленные среды по-прежнему содержат mame.arm.
binary = next((home / name for name in ("sprinter", "mame.arm")
if (home / name).is_file()), home / "sprinter")
return cls(home=home,
binary=selected("MAME_BIN", "mame.arm"),
binary=binary,
rompath=selected("MAME_ROMPATH", "roms"),
dss_image=selected("MAME_DSS_IMAGE", "IMG/dss171u.img"),
system_hdd_image=selected("MAME_SYSTEM_HDD_IMAGE", "IMG/sp_hdd_sys.chd"),
@@ -70,16 +79,45 @@ def from_arguments(args) -> MameProfile:
for name in names})
KEYBOARD_TAGS = (":", ":kbd:ms_naturl")
def write_keyboard_config(cfg_directory: Path) -> None:
"""Включить обе клавиатуры Sprinter, включая прямой ввод в DSS."""
"""Включить обе клавиатуры Sprinter, включая прямой ввод в DSS.
Если в каталоге уже лежит sprinter.cfg (например, скопированный из
MAME_HOME/cfg), остальные его настройки сохраняются, а в копии
включаются только нужные клавиатуры.
"""
cfg_directory.mkdir(parents=True, exist_ok=True)
(cfg_directory / "sprinter.cfg").write_text(
'<?xml version="1.0"?>\n'
'<mameconfig version="10">\n'
' <system name="sprinter">\n'
' <input>\n'
' <keyboard tag=":" enabled="1" />\n'
' <keyboard tag=":kbd:ms_naturl" enabled="1" />\n'
' </input>\n'
' </system>\n'
'</mameconfig>\n', encoding="utf-8")
path = cfg_directory / "sprinter.cfg"
root = None
if path.is_file():
try:
root = ET.parse(path).getroot()
except ET.ParseError as error:
print(f"mame_profile: {path} не разобран ({error}), создаётся заново",
file=sys.stderr)
if root is None or root.tag != "mameconfig":
root = ET.Element("mameconfig", version="10")
system = next((item for item in root.findall("system")
if item.get("name") == "sprinter"), None)
if system is None:
system = ET.SubElement(root, "system", name="sprinter")
input_node = system.find("input")
if input_node is None:
input_node = ET.SubElement(system, "input")
keyboards = {item.get("tag"): item for item in input_node.findall("keyboard")}
for tag in KEYBOARD_TAGS:
keyboard = keyboards.get(tag)
if keyboard is None:
keyboard = ET.Element("keyboard", tag=tag)
# MAME ожидает keyboard перед port внутри input.
position = sum(1 for item in input_node if item.tag == "keyboard")
input_node.insert(position, keyboard)
keyboard.set("enabled", "1")
tree = ET.ElementTree(root)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
with path.open("a", encoding="utf-8") as stream:
stream.write("\n")
+133 -4
View File
@@ -8,6 +8,8 @@ function exports.startplugin()
local lfs = require("lfs")
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
local snapdir = os.getenv("SDBG_SNAP_DIR")
local snapshot_sequence = 0
local generation, sequence = 0, 0
local events, owned = {}, {}
local state, pending = "initializing", nil
@@ -53,6 +55,37 @@ function exports.startplugin()
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
return value
end
local function first_screen()
for _,screen in pairs(machine().screens) do return screen end
error("Экран MAME не найден")
end
local function share_info()
local result={shares={},regions={}}
for tag,share in pairs(machine().memory.shares) do
result.shares[#result.shares+1]={tag=tag,size=share.size}
end
for tag,region in pairs(machine().memory.regions) do
result.regions[#result.regions+1]={tag=tag,size=region.size}
end
table.sort(result.shares,function(a,b) return a.tag<b.tag end)
table.sort(result.regions,function(a,b) return a.tag<b.tag end)
return result
end
local function read_share(tag,address,length)
assert(type(tag)=="string" and #tag>0 and #tag<=128,"Нужен точный tag share")
local share=machine().memory.shares[tag]
assert(share,"Share не найден: "..tag)
local size=number(share.size,0x10000000)
address=number(address,size)
length=number(length,4096)
assert(length>=1 and address+length<=size,"Чтение за пределами share")
local bytes={}
for offset=0,length-1 do
bytes[#bytes+1]=string.format("%02x",share:read_u8(address+offset))
end
return {tag=tag,address=address,length=length,size=size,hex=table.concat(bytes),
generation=generation}
end
local invalidate
local function dispatch(request)
assert(request.session==session,"Чужая сессия")
@@ -65,9 +98,13 @@ function exports.startplugin()
session_started=true
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
instruction_step=true,step_over=true,step_out=true,
bank_guard=true,deferred_breakpoints=true,console_print=true},state=state}
bank_guard=true,deferred_breakpoints=true,console_print=true,
shares=true,screen_pixels=true,screen_snapshot=snapdir~=nil,
program_memory=true,list_ports=true,disassemble_logical=true},state=state}
elseif command=="snapshot" then
local result={state=state,time=now(),paused=machine().paused}
local screen=first_screen()
result.frame=screen:frame_number()
result.keyboards=setmetatable({}, {__jsontype="object"})
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
result.keyboards[tag]=kbd.enabled
@@ -126,11 +163,16 @@ function exports.startplugin()
state="running"
event("running",{reason=command})
if command=="step" then
local count=number(args.count or 1,64)
assert(count>=1,"step count должен быть 1..64")
pending={kind="step",time=now()}
cpu().debug:step(1)
cpu().debug:step(count)
elseif command=="step_over" then
local count=number(args.count or 1,64)
assert(count>=1,"over count должен быть 1..64")
pending={kind="step_over",time=now()}
machine().debugger:command("over 1")
-- В консоли MAME числа без префикса шестнадцатеричные.
machine().debugger:command("over #"..count)
elseif command=="step_out" then
pending={kind="step_out",time=now()}
machine().debugger:command("out")
@@ -149,6 +191,92 @@ function exports.startplugin()
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true))
end
return {hex=table.concat(bytes)}
elseif command=="program_memory" then
stopped(request)
local address=number(args.address,0x3ffff)
local length=number(args.length,4096)
assert(length>=1 and address+length<=0x40000,
"Чтение за пределами raw program space")
local symbols=emu.symbol_table(cpu())
local bytes={}
for offset=0,length-1 do
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(
":maincpu","p",address+offset,1,true))
end
return {space="program",address=address,length=length,
hex=table.concat(bytes),generation=generation}
elseif command=="disassemble_logical" then
stopped(request)
local address=number(args.address,0xffff)
local length=number(args.length,256)
assert(length>=1 and address+length<=0x10000,
"Дизассемблирование за пределами logical Z80")
local path=directory.."/_disasm.txt"
os.remove(path) -- Не возвращать файл от прежней команды при ошибке MAME.
machine().debugger:command(string.format("dasm %s,0x%X,#%d",path,address,length))
local size=lfs.attributes(path,"size")
assert(size and size>0 and size<=65536,"MAME не создал ограниченный disasm-файл")
local file=assert(io.open(path,"rb"))
local output=file:read("*a")
file:close()
os.remove(path)
return {space="logical_z80",address=address,length=length,
text=output,generation=generation}
elseif command=="list_ports" then
local result={ports={},truncated=false}
local fields_seen=0
for tag,port in pairs(machine().ioport.ports) do
if #result.ports>=512 then result.truncated=true;break end
local item={tag=tag,fields={}}
for name,field in pairs(port.fields) do
if fields_seen>=4096 then result.truncated=true;break end
item.fields[#item.fields+1]={name=tostring(name),mask=field.mask}
fields_seen=fields_seen+1
end
table.sort(item.fields,function(a,b) return a.mask<b.mask end)
result.ports[#result.ports+1]=item
end
table.sort(result.ports,function(a,b) return a.tag<b.tag end)
return result
elseif command=="list_shares" then
return share_info()
elseif command=="read_share" then
return read_share(args.tag,args.address,args.length)
elseif command=="read_vram" then
local matches={}
for tag in pairs(machine().memory.shares) do
if tag:find("vram",1,true) then matches[#matches+1]=tag end
end
assert(#matches==1,"Нужен ровно один VRAM share; используйте list_shares")
return read_share(matches[1],args.address,args.length)
elseif command=="screen_pixels" then
local x=number(args.x,4095)
local y=number(args.y,4095)
local width=number(args.width,512)
local height=number(args.height,512)
assert(width>=1 and height>=1 and width*height<=8192,
"Прямоугольник должен содержать 1..8192 пикселей")
local screen=first_screen()
local values={}
for dy=0,height-1 do
for dx=0,width-1 do
values[#values+1]=string.format("%04X",screen:pixel(x+dx,y+dy)&0xffff)
end
end
return {x=x,y=y,width=width,height=height,format="pen16be-hex",
hex=table.concat(values),frame=screen:frame_number(),
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
elseif command=="screen_snapshot" then
assert(snapdir and #snapdir>0,"Каталог снимков не настроен")
snapshot_sequence=snapshot_sequence+1
local screen=first_screen()
local path=string.format("%s/sdbg_%06d.png",snapdir,snapshot_sequence)
local err=screen:snapshot(path)
assert(err==nil,"Снимок не создан: "..tostring(err))
local size=lfs.attributes(path,"size")
assert(size and size>0 and size<=8388608,"Неверный размер снимка")
return {path=path,size=size,format="png",frame=screen:frame_number(),
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
elseif command=="console_print" then
stopped(request)
local text=args.text
@@ -192,7 +320,8 @@ function exports.startplugin()
end
return {disabled=count}
elseif command=="clear" then
stopped(request)
-- Удаление точки безопасно и при running: callback исполняется на
-- потоке MAME, а MCP должен очищать свои точки при закрытии stdio.
local id=number(args.id,0x7fffffff)
assert(owned[id],"Точка не принадлежит sdbg")
cpu().debug:bpclear(id);owned[id]=nil
+2
View File
@@ -0,0 +1,2 @@
# Необязательный stdio MCP-адаптер; основная сборка toolkit не зависит от SDK.
mcp>=2.2,<3
+7 -1
View File
@@ -6,7 +6,13 @@ if [ -f "$mame_root/.codex/mame.local.env" ]; then
. "$mame_root/.codex/mame.local.env"
fi
: "${MAME_UV:=uv}"
: "${MAME_MCP_SCRIPT:=../MAME/src/mame_mcp.py}"
if [ -z "${MAME_MCP_SCRIPT:-}" ]; then
if [ -f "$mame_root/../MAME.HT/src/mame_mcp.py" ]; then
MAME_MCP_SCRIPT=../MAME.HT/src/mame_mcp.py
else
MAME_MCP_SCRIPT=../MAME/src/mame_mcp.py
fi
fi
cd "$mame_root"
if ! command -v "$MAME_UV" >/dev/null 2>&1; then
echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2
+10 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
@@ -43,13 +44,21 @@ def main() -> int:
state = Path(directory)
for name in ("cfg", "nvram", "diff", "snapshot"):
(state / name).mkdir()
# Настройки и NVRAM установленной среды берутся копией: MAME
# перезаписывает их при выходе, а MAME_HOME должен остаться нетронутым.
for name in ("cfg", "nvram"):
source = profile.home / name if profile.home else None
if source and source.is_dir():
shutil.copytree(source, state / name, dirs_exist_ok=True)
snapshots = Path(args.snapshot_dir).resolve() if args.snapshot_dir else state / "snapshot"
snapshots.mkdir(parents=True, exist_ok=True)
write_keyboard_config(state / "cfg")
command = [str(profile.binary), "sprinter", "-noreadconfig",
"-rompath", str(profile.rompath), "-bios", profile.bios,
"-kbd", "ms_naturl,bios=sp2k", "-video", args.video,
"-window", "-skip_gameinfo", "-beta:wd179x:0", "35hd",
"-window", "-skip_gameinfo",
"-beta:wd179x:0", "35hd",
"-beta:wd179x:1", "35hd",
"-flop2", str(profile.dss_image),
"-hard1", str(profile.system_hdd_image)]
if "floppy" in media:
+18 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from .server import rpc_call
from .server import RPC_GENERATION_MUTATIONS, rpc_call
from .session import SessionError
@@ -19,7 +19,14 @@ class DapEngine:
return self._rpc_override(method, arguments or {})
if not self.rpc_path:
raise SessionError('DAP ещё не подключён к session server')
return rpc_call(self.rpc_path, method, arguments, timeout)
identity = {}
if self.attached is not None:
identity = {'session_id': self.attached['session_id'],
'build_id': self.attached['build_id']}
if method in RPC_GENERATION_MUTATIONS:
current = rpc_call(self.rpc_path, 'status', **identity)
identity['generation'] = current['generation']
return rpc_call(self.rpc_path, method, arguments, timeout, **identity)
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
events = []
@@ -54,6 +61,11 @@ class DapEngine:
'description': self._description(location)}))
return {}, events
if command == 'disconnect':
if self.attached is not None:
try:
self.rpc('release_control')
except (SessionError, OSError):
pass
return {}, events
if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
@@ -118,7 +130,8 @@ class DapEngine:
def poll_events(self, timeout=1):
previous = self.event_sequence
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout,
'owner': 'cli'},
timeout=timeout + 2)
self.event_sequence = result['last']
translated = []
@@ -140,6 +153,8 @@ class DapEngine:
elif event['event'] == 'output':
translated.append(('output', {'category': event['body'].get('category','console'),
'output': event['body']['output']}))
if result['closed'] and not any(name == 'terminated' for name, _ in translated):
translated.append(('terminated', {'restart': False}))
return translated, result['closed']
@staticmethod
+230
View File
@@ -0,0 +1,230 @@
"""Тонкий MCP-клиент общей source-debug сессии без зависимости от MCP SDK."""
from __future__ import annotations
from pathlib import Path
import time
import uuid
from .server import RPC_GENERATION_MUTATIONS, rpc_call
from .session import SessionError
class McpSession:
def __init__(self, socket_path: str, rpc=rpc_call):
self.socket_path = str(Path(socket_path).expanduser())
self.owner = 'mcp:' + uuid.uuid4().hex
self._rpc = rpc
self.identity: dict[str, str] = {}
def call(self, method: str, arguments: dict | None = None, timeout: float = 10):
if method != 'status' and not self.identity:
self.status()
generation = None
if method in RPC_GENERATION_MUTATIONS:
generation = self.status().get('generation')
identity = dict(self.identity)
if method in RPC_GENERATION_MUTATIONS:
identity['generation'] = generation
result = self._rpc(self.socket_path, method, arguments or {},
timeout=timeout, **identity)
if method == 'status' and 'session_id' in result:
self.identity = {'session_id': result['session_id'],
'build_id': result['build_id']}
return result
def status(self):
return self.call('status')
def where(self):
return self.call('where')
def registers(self):
return self.call('registers')
def read_memory(self, address: str, length: int = 16):
return self.call('read_memory', {'address': self._address(address),
'length': length})
@staticmethod
def _address(address: str) -> int:
try:
return int(address, 16 if address.lower().startswith('0x') else 10)
except (AttributeError, ValueError) as error:
raise SessionError('Адрес памяти должен быть десятичным или 0xHEX') from error
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 disassemble_logical(self, address: str, length: int = 32):
return self.call('disassemble_logical', {'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 list_breakpoints(self):
return self.call('list_breakpoints')
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 type_string(self, value: str):
"""Послать строку по физической клавиатуре с паузами между клавишами."""
from mame_interactive import resolve
if not isinstance(value, str) or not value or len(value) > 64:
raise SessionError('type_string принимает 1..64 символа')
try:
for char in value:
resolve(char)
except ValueError as error:
raise SessionError(str(error)) from error
if not self.status()['running']:
raise SessionError('Ввод строки возможен только при running CPU')
typed = 0
stopped = False
for char in value:
result = self.press_key(char)
typed += 1
stopped = result['stopped']
if stopped or typed == len(value):
break
snapshot = self.call('snapshot')
start = snapshot['frame']
deadline = time.monotonic() + 4
while snapshot['state'] == 'running' and snapshot['frame'] - start < 4:
if time.monotonic() >= deadline:
raise SessionError('Межклавишная пауза не получила кадры за 4 с')
time.sleep(.01)
snapshot = self.call('snapshot')
stopped = snapshot['state'] != 'running'
if stopped:
break
return {'requested': len(value), 'typed': typed,
'complete': typed == len(value), 'stopped': stopped}
def variables(self):
return self.call('variables')
def read_variable(self, name: str, module: str | None = None):
return self.call('read_variable', {'name': name, 'module': module})
def events(self, after: int = 0, timeout: float = 0):
if after < 0 or timeout < 0 or timeout > 30:
raise SessionError('after должен быть неотрицательным, timeout — от 0 до 30 с')
return self.call('events', {'after': after, 'timeout': timeout,
'owner': self.owner},
timeout=max(10, timeout + 2))
def console_tail(self, count: int = 40):
if count < 1 or count > 200:
raise SessionError('count должен быть от 1 до 200')
return self.call('mame_console_tail', {'count': count})
def break_line(self, file: str, line: int):
return self.call('break_line', {'file': file, 'line': line, 'owner': self.owner})
def break_function(self, name: str):
return self.call('break_function', {'name': name, 'owner': self.owner})
def clear_breakpoint(self, identifier: int):
return self.call('clear_breakpoint', {'id': identifier, 'owner': self.owner})
def clear_owned_breakpoints(self):
return self.call('clear_owned_breakpoints', {'owner': self.owner})
def continue_execution(self):
return self.call('continue', {'owner': self.owner})
def pause_execution(self):
return self.call('pause', {'owner': self.owner})
def step_instruction(self, count: int = 1):
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 64:
raise SessionError('Число машинных шагов должно быть 1..64')
return self.call('step', {'count': count, 'owner': self.owner})
def step_over_instruction(self, count: int = 1):
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 64:
raise SessionError('Число машинных шагов должно быть 1..64')
return self.call('step_over_instruction',
{'count': count, 'owner': self.owner})
def step_out_instruction(self):
return self.call('step_out_instruction', {'owner': self.owner})
def step_source(self, kind: str = 'into'):
if kind not in ('into', 'over', 'out'):
raise SessionError('kind должен быть into, over или out')
return self.call('source_step', {'kind': kind, 'owner': self.owner})
def claim_control(self):
return self.call('claim_control', {'owner': self.owner})
def renew_control(self):
return self.call('renew_control', {'owner': self.owner})
def release_control(self):
return self.call('release_control', {'owner': self.owner})
+319 -23
View File
@@ -9,6 +9,7 @@ import socketserver
import string
import threading
import time
import uuid
from .session import DebugSession, SessionError
from .transport import BridgeError
@@ -16,6 +17,8 @@ from .macros import validate_log_message
class SessionController:
CONTROL_TTL = 30.0
def __init__(self, session: DebugSession):
self.session = session
self.lock = threading.RLock()
@@ -26,6 +29,11 @@ class SessionController:
self.running = False
self.source_step = None
self.closed = False
self.session_id = uuid.uuid4().hex
self.control_owner: str | None = None
self.control_deadline = 0.0
self.owner_deadlines: dict[str, float] = {}
self.held_inputs: dict[str, set[tuple[str, int]]] = {}
self.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {}
@@ -53,6 +61,7 @@ class SessionController:
'kind': 'log', 'message': macro['message'],
'condition': macro['condition'], 'module': macro['module'],
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
'owner': 'build', 'guard_conditions': item['conditions'],
}
if created:
self.session.activate_breakpoints()
@@ -72,28 +81,40 @@ class SessionController:
self.changed.notify_all()
def _poll(self) -> None:
next_idle_snapshot = 0.0
while not self.closed:
if not self.running:
time.sleep(.02)
continue
delay = next_idle_snapshot - time.monotonic()
if delay > 0:
time.sleep(min(delay, .05))
continue
try:
with self.lock:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
if self.running:
self._release_all_inputs()
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
elif snapshot['state'] == 'running' and not self.running:
# Команда из родного окна MAME тоже меняет состояние CPU.
self.running = True
self._emit('continued', {'reason': 'external'})
elif snapshot['state'] == 'invalidated':
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': 'reset_or_load'})
if not self.running:
next_idle_snapshot = time.monotonic() + .5
self._reap_owners()
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None
@@ -104,10 +125,73 @@ class SessionController:
def close(self) -> None:
self.closed = True
self.poller.join(timeout=1)
with self.lock:
try:
self._release_all_inputs()
except (BridgeError, SessionError, OSError):
pass
self.session.bridge.close()
@staticmethod
def _owner(arguments: dict) -> str:
owner = arguments.get('owner', 'cli')
if not isinstance(owner, str) or not owner or len(owner) > 128:
raise SessionError('owner должен быть непустой строкой до 128 символов')
return owner
def _control(self, arguments: dict) -> dict:
"""Захватить или продлить управление; чужой живой lease не отбирать."""
owner = self._owner(arguments)
self._touch_owner(owner)
now = time.monotonic()
if self.control_owner is not None and self.control_deadline <= now:
self.control_owner = None
if self.control_owner not in (None, owner):
raise SessionError(f'CPU управляет {self.control_owner}; дождитесь release или истечения lease')
if self.control_owner != owner:
self.control_owner = owner
self._emit('control', {'owner': owner})
self.control_deadline = now + self.CONTROL_TTL
return {'owner': owner, 'expires_in': self.CONTROL_TTL}
def _touch_owner(self, owner: str) -> None:
if owner.startswith('mcp:'):
self.owner_deadlines[owner] = time.monotonic() + self.CONTROL_TTL
def _release_inputs(self, owner: str) -> None:
for tag, mask in self.held_inputs.pop(owner, set()):
self.session.bridge.request('key', tag=tag, mask=mask, down=False)
def _release_all_inputs(self) -> None:
for owner in list(self.held_inputs):
self._release_inputs(owner)
def _reap_owners(self) -> None:
now = time.monotonic()
for owner, deadline in list(self.owner_deadlines.items()):
if deadline > now:
continue
self._release_inputs(owner)
identifiers = [identifier for identifier, info in self.breakpoint_info.items()
if info.get('owner') == owner]
for identifier in identifiers:
self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
self.owner_deadlines.pop(owner, None)
self._emit('owner_expired', {'owner': owner, 'cleared_breakpoints': identifiers})
def call(self, method: str, arguments: dict) -> object:
if method == 'events':
owner = arguments.get('owner')
if owner is not None:
with self.lock:
if self.control_owner == self._owner(arguments) and \
self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout
@@ -121,36 +205,181 @@ class SessionController:
with self.lock:
if self.closed:
raise SessionError('Сессия закрыта или инвалидирована')
if method == 'claim_control':
return self._control(arguments)
if method == 'renew_control':
owner = self._owner(arguments)
self._touch_owner(owner)
if self.control_owner == owner and self.control_deadline > time.monotonic():
self.control_deadline = time.monotonic() + self.CONTROL_TTL
return {'owner': owner, 'has_control': True,
'expires_in': self.CONTROL_TTL}
return {'owner': owner, 'has_control': False}
if method == 'release_control':
owner = self._owner(arguments)
self._release_inputs(owner)
if self.control_owner == owner:
self.control_owner = None
self.control_deadline = 0.0
self._emit('control', {'owner': None})
return {'released': owner}
if self.running and method in ('where', 'registers', 'read_memory',
'read_program_memory', 'disassemble_logical',
'read_variable',
'break_line', 'break_function'):
raise SessionError('Операция требует остановленного CPU; сначала Pause')
if method == 'status':
return {**self.attached, 'running': self.running,
'event_sequence': self.sequence}
'generation': getattr(self.session.bridge, 'generation',
self.attached.get('generation')),
'event_sequence': self.sequence, 'session_id': self.session_id,
'control_owner': self.control_owner if
self.control_deadline > time.monotonic() else None}
if method == 'list_breakpoints':
breakpoints = []
for identifier, info in sorted(self.breakpoint_info.items()):
guards = info.get('guard_conditions', [])
locations = []
for index, location in enumerate(info['locations']):
locations.append({**location,
'bank_guard': guards[index] if index < len(guards)
else ''})
breakpoints.append({'id': identifier,
'owner': info.get('owner', 'dap'),
'kind': info['kind'], 'hits': info['hits'],
'locations': locations,
'tag': info.get('tag')})
return {'breakpoints': breakpoints}
if method == 'snapshot':
return self.session.bridge.request('snapshot')
if method == 'mame_console_tail':
return self.session.bridge.request(
'console_tail', count=int(arguments.get('count', 40)))
if method == 'list_ports':
return self.session.bridge.request('list_ports')
if method == 'list_shares':
return self.session.bridge.request('list_shares')
if method in ('read_share', 'read_vram'):
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0x10000000 or length < 1 or length > 4096:
raise SessionError('Share: адрес 0..0x10000000, длина 1..4096')
if method == 'read_share':
tag = arguments['tag']
if not isinstance(tag, str) or not tag or len(tag) > 128:
raise SessionError('Нужен точный tag share до 128 символов')
return self.session.bridge.request('read_share', tag=tag,
address=address, length=length)
return self.session.bridge.request('read_vram', address=address,
length=length)
if method == 'read_screen_pixels':
x, y = int(arguments['x']), int(arguments['y'])
width, height = int(arguments['width']), int(arguments['height'])
if x < 0 or y < 0 or x > 4095 or y > 4095 or \
width < 1 or height < 1 or width > 512 or height > 512 or \
width * height > 8192:
raise SessionError('Экран: координаты 0..4095, размер 1..512, максимум 8192 пикселей')
return self.session.bridge.request('screen_pixels', x=x, y=y,
width=width, height=height)
if method == 'screenshot':
result = self.session.bridge.request('screen_snapshot')
root = (Path(self.session.bridge.directory).parent / 'snapshot').resolve()
path = Path(result['path']).resolve()
if path.parent != root or path.suffix.lower() != '.png' or \
not path.is_file() or path.stat().st_size != result['size'] or \
result['size'] > 8 * 1024 * 1024:
raise SessionError('Снимок MAME вне каталога сессии или повреждён')
return result
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request(
'key', tag=arguments['tag'], mask=int(arguments['mask']),
down=arguments['down'])
self._control(arguments)
tag, mask = arguments['tag'], int(arguments['mask'])
result = self.session.bridge.request('key', tag=tag, mask=mask,
down=arguments['down'])
held = self.held_inputs.setdefault(self._owner(arguments), set())
if arguments['down']:
held.add((tag, mask))
else:
held.discard((tag, mask))
return result
if method == 'where':
return self.session.where()
if method == 'registers':
mapping = self.session.refresh()
return {'generation': mapping.generation, 'registers': mapping.registers,
'bank_pages': mapping.bank_pages}
if method == 'read_memory':
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0xffff or length < 1 or length > 256 or \
address + length > 0x10000:
raise SessionError('Чтение памяти: адрес 0..65535, длина 1..256 без выхода за 64 КБ')
mapping = self.session.refresh()
data = self.session.bridge.request('memory', address=address, length=length)
return {'address': address, 'length': length, 'hex': data['hex'],
'generation': mapping.generation, 'bank_pages': mapping.bank_pages}
if method == 'read_program_memory':
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0x3ffff or length < 1 or length > 4096 or \
address + length > 0x40000:
raise SessionError('Raw program: адрес 0..0x3ffff, длина 1..4096')
mapping = self.session.refresh()
result = self.session.bridge.request('program_memory',
address=address, length=length)
return {**result, 'bank_pages': mapping.bank_pages}
if method == 'disassemble_logical':
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0xffff or length < 1 or length > 256 or \
address + length > 0x10000:
raise SessionError('Дизассемблирование: адрес 0..0xffff, длина 1..256')
mapping = self.session.refresh()
result = self.session.bridge.request('disassemble_logical',
address=address, length=length)
return {**result, 'bank_pages': mapping.bank_pages}
if method == 'variables':
return self.session.model.variables
if method == 'read_variable':
return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line':
return self.session.break_line(arguments['file'], int(arguments['line']))
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_line(arguments['file'], int(arguments['line']))
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
'hits': 0, 'owner': owner,
'guard_conditions': item['conditions']}
return item
if method == 'break_function':
return self.session.break_function(arguments['name'])
owner = self._owner(arguments)
self._touch_owner(owner)
item = self.session.break_function(arguments['name'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'],
'hits': 0, 'owner': owner,
'guard_conditions': item['conditions']}
return item
if method == 'clear_breakpoint':
return self.session.clear_breakpoint(int(arguments['id']))
identifier = int(arguments['id'])
owner = arguments.get('owner')
if identifier in self.breakpoint_info and owner is not None and \
self.breakpoint_info[identifier].get('owner') != owner:
raise SessionError('Точка не принадлежит этому клиенту')
result = self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
return result
if method == 'clear_owned_breakpoints':
owner = arguments.get('owner')
if not isinstance(owner, str) or not owner:
raise SessionError('Для очистки точек требуется owner')
identifiers = [identifier for identifier, info in self.breakpoint_info.items()
if info.get('owner') == owner]
for identifier in identifiers:
self.session.clear_breakpoint(identifier)
self.breakpoint_info.pop(identifier, None)
return {'cleared': identifiers}
if method == 'set_source_breakpoints':
specifications = arguments.get('breakpoints')
if specifications is None:
@@ -161,21 +390,43 @@ class SessionController:
if method == 'continue':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self._control(arguments)
result = self.session.bridge.request('continue')
self.running = True
self._emit('continued', {})
return result
if method == 'pause':
self._control(arguments)
self.source_step = None
self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped())
self.running = False
self._emit('stopped', {'reason': 'pause', 'location': location})
return location
if method in ('step_over_instruction', 'step_out_instruction'):
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
command = {'step_over_instruction': 'step_over',
'step_out_instruction': 'step_out'}[method]
request_args = {}
if method != 'step_out_instruction':
count = arguments.get('count', 1)
if type(count) is not int or count < 1 or count > 64:
raise SessionError('Число машинных шагов должно быть 1..64')
request_args['count'] = count
self._control(arguments)
self.session.bridge.request(command, **request_args)
self.running = True
self._emit('continued', {'reason': command})
return {'accepted': True, 'command': command}
if method == 'step':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self.session.bridge.request('step')
count = arguments.get('count', 1)
if type(count) is not int or count < 1 or count > 64:
raise SessionError('Число машинных шагов должно быть 1..64')
self._control(arguments)
self.session.bridge.request('step', count=count)
self.running = True
self._emit('continued', {'reason': 'step'})
location = self.session.where(self.session.bridge.wait_stopped())
@@ -183,6 +434,7 @@ class SessionController:
self._emit('stopped', {'reason': 'step', 'location': location})
return location
if method == 'source_step':
self._control(arguments)
return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method)
@@ -276,6 +528,7 @@ class SessionController:
self.breakpoint_info[item['id']] = {
'kind': kind, 'message': specification['logMessage'],
'locations': item['locations'], 'hits': 0,
'owner': 'dap', 'guard_conditions': item['conditions'],
}
results.append({'line': line, 'verified': True,
'logMessage': specification['logMessage'], **item})
@@ -309,7 +562,9 @@ class SessionController:
item = self.session.break_function(name, enabled=False)
created.append(item['id'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'], 'hits': 0}
'kind': 'stop', 'message': None, 'locations': item['locations'],
'hits': 0, 'owner': 'dap',
'guard_conditions': item['conditions']}
results.append({'name': name, 'verified': True, **item})
except BaseException:
for identifier in created:
@@ -400,6 +655,18 @@ class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamSe
daemon_threads = True
RPC_MUTATIONS = frozenset({
'claim_control', 'renew_control', 'release_control', 'input_key',
'break_line', 'break_function', 'clear_breakpoint', 'clear_owned_breakpoints',
'set_source_breakpoints', 'set_function_breakpoints',
'continue', 'pause', 'step', 'step_over_instruction',
'step_out_instruction', 'source_step',
})
RPC_GENERATION_MUTATIONS = RPC_MUTATIONS - {
'claim_control', 'renew_control', 'release_control',
}
class SessionRpcServer:
def __init__(self, path, controller: SessionController):
self.path = Path(path)
@@ -428,8 +695,31 @@ class SessionRpcServer:
arguments = request.get('arguments', {})
if not isinstance(arguments, dict):
raise ValueError('RPC arguments должен быть объектом')
method = request['method']
with controller_ref.lock:
if method != 'unknown' and (method != 'status' or
request.get('session_id') is not None or
request.get('build_id') is not None):
if request.get('session_id') != controller_ref.session_id or \
request.get('build_id') != controller_ref.attached['build_id']:
raise SessionError('Устаревшая RPC-сессия или build ID; переподключитесь')
if method in RPC_GENERATION_MUTATIONS and \
request.get('generation') != \
getattr(controller_ref.session.bridge, 'generation', None):
raise SessionError('Устаревшая generation; обновите статус сессии')
if method != 'events':
result = controller_ref.call(method, arguments)
result_generation = getattr(controller_ref.session.bridge,
'generation', None)
if method == 'events':
result = controller_ref.call(method, arguments)
result_generation = getattr(controller_ref.session.bridge,
'generation', None)
response = {'id': request.get('id'), 'ok': True,
'result': controller_ref.call(request['method'], arguments)}
'session_id': controller_ref.session_id,
'build_id': controller_ref.attached['build_id'],
'generation': result_generation,
'result': result}
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
response.update(error=str(error))
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
@@ -448,8 +738,11 @@ class SessionRpcServer:
self.path.unlink(missing_ok=True)
def rpc_call(path, method: str, arguments=None, timeout=10):
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
def rpc_call(path, method: str, arguments=None, timeout=10, *,
session_id=None, build_id=None, generation=None):
request = {'id': 1, 'method': method, 'arguments': arguments or {},
'session_id': session_id, 'build_id': build_id,
'generation': generation}
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(timeout)
client.connect(str(path))
@@ -461,4 +754,7 @@ def rpc_call(path, method: str, arguments=None, timeout=10):
response = json.loads(raw)
if not response.get('ok'):
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
if method != 'status' and (response.get('session_id') != session_id or
response.get('build_id') != build_id):
raise SessionError('RPC-ответ от другой сессии или сборки')
return response['result']
+6 -3
View File
@@ -246,12 +246,15 @@ class DebugSession:
return self.bridge.request('deactivate_breakpoints')
def clear_breakpoint(self, identifier: int) -> dict:
ids = self.breakpoints.pop(identifier, None)
ids = self.breakpoints.get(identifier)
if ids is None:
raise SessionError('Неизвестная логическая точка остановки')
for backend_id in ids:
backend_ids = list(ids)
for backend_id in backend_ids:
self.bridge.request('clear', id=backend_id)
return {'cleared': identifier, 'backend_ids': ids}
ids.remove(backend_id)
self.breakpoints.pop(identifier)
return {'cleared': identifier, 'backend_ids': backend_ids}
def read_variable(self, name: str, module: str | None = None) -> dict:
matches = [variable for variable in self.model.variables
+173
View File
@@ -0,0 +1,173 @@
"""Владелец автономного launcher для MCP без запущенного VS Code."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import uuid
from .mcp_adapter import McpSession
from .session import SessionError
class SessionSupervisor:
def __init__(self, build: str, socket_path: str | None = None,
launcher_options: list[str] | None = None):
self.build = str(Path(build).expanduser().resolve())
self._temporary = None
if socket_path:
self.socket_path = str(Path(socket_path).expanduser())
else:
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
self.launcher_options = list(launcher_options or [])
self.lock = threading.RLock()
self.phase = 'idle'
self.error: str | None = None
self.entry = None
self.mame_pid = None
self.process: subprocess.Popen | None = None
self.monitor: threading.Thread | None = None
self.log = None
self.client: McpSession | None = None
self.launch_id = None
def status(self) -> dict:
with self.lock:
result = {'phase': self.phase, 'socket': self.socket_path,
'launch_id': self.launch_id}
if self.error:
result['error'] = self.error
if self.phase == 'ready' and self.client is not None:
try:
result.update(self.client.status())
result['mame_pid'] = self.mame_pid
result['entry'] = self.entry
except (SessionError, OSError) as error:
self.phase = 'failed'
self.error = str(error)
result.update(phase='failed', error=self.error)
return result
def require_client(self) -> McpSession:
with self.lock:
if self.phase != 'ready' or self.client is None:
raise SessionError('C-сессия ещё не готова: ' + self.phase)
return self.client
def start(self) -> dict:
with self.lock:
if self.phase in ('starting', 'ready', 'stopping'):
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
if self.process is not None and self.process.poll() is None:
raise SessionError('Предыдущий launcher ещё работает')
if self.monitor is not None and self.monitor.is_alive():
raise SessionError('Предыдущий launcher ещё завершает работу')
if not Path(self.build).is_dir():
raise SessionError('Debug-пакет не найден: ' + self.build)
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
'sdbg_launcher.py'), '--build', self.build,
'--socket', self.socket_path, *self.launcher_options]
if self.log is not None:
self.log.close()
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
self.phase = 'starting'
self.error = None
self.entry = None
self.mame_pid = None
self.client = None
self.launch_id = uuid.uuid4().hex
try:
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=self.log, text=True)
except OSError:
self.phase = 'failed'
raise
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
daemon=True)
self.monitor.start()
return {'accepted': True, 'phase': self.phase,
'socket': self.socket_path, 'launch_id': self.launch_id}
def _diagnostics(self) -> str:
if self.log is None:
return ''
self.log.flush()
self.log.seek(0)
return self.log.read()[-4000:].strip()
def _monitor(self) -> None:
process = self.process
assert process is not None and process.stdout is not None
try:
line = process.stdout.readline()
if not line:
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
ready = json.loads(line)
if not ready.get('ready') or ready.get('socket') != self.socket_path:
raise SessionError('Launcher вернул неверный ready')
client = McpSession(self.socket_path)
client.status()
with self.lock:
if self.phase == 'starting':
self.client = client
self.entry = ready['entry']
self.mame_pid = ready['pid']
self.phase = 'ready'
process.wait()
with self.lock:
if self.phase not in ('stopping', 'failed'):
self.phase = 'stopped'
self.error = 'Launcher завершился: ' + self._diagnostics()
elif self.phase == 'stopping':
self.phase = 'stopped'
self.client = None
except (OSError, ValueError, KeyError, SessionError) as error:
with self.lock:
if self.phase == 'stopping':
self.phase = 'stopped'
else:
self.phase = 'failed'
self.error = str(error)
self.client = None
def stop(self) -> dict:
with self.lock:
if self.phase not in ('starting', 'ready'):
return {'accepted': False, 'phase': self.phase}
self.phase = 'stopping'
if self.process is not None and self.process.poll() is None:
self.process.terminate()
return {'accepted': True, 'phase': 'stopping',
'socket': self.socket_path}
def close(self) -> None:
self.stop()
process = self.process
if process is not None:
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if self.monitor is not None:
self.monitor.join(timeout=2)
if self.log is not None:
self.log.close()
if self._temporary is not None:
self._temporary.cleanup()
class ManagedMcpSession:
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
def __init__(self, supervisor: SessionSupervisor):
self.supervisor = supervisor
def status(self) -> dict:
return self.supervisor.status()
def __getattr__(self, name: str):
return getattr(self.supervisor.require_client(), name)
+10 -2
View File
@@ -4,7 +4,7 @@ import argparse
import json
import sys
from sdbg.server import rpc_call
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call
from sdbg.session import SessionError
@@ -19,8 +19,16 @@ def main():
arguments = json.loads(args.arguments)
if not isinstance(arguments, dict):
raise ValueError('arguments должен быть JSON-объектом')
identity = {}
if args.method != 'status':
status = rpc_call(args.socket, 'status')
identity = {'session_id': status['session_id'],
'build_id': status['build_id']}
if args.method in RPC_GENERATION_MUTATIONS:
identity['generation'] = status['generation']
result = rpc_call(args.socket, args.method, arguments,
timeout=35 if args.method == 'events' else 10)
timeout=35 if args.method == 'events' else 10,
**identity)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (SessionError, ValueError, OSError) as error:
+24 -3
View File
@@ -221,7 +221,12 @@ def main():
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
args.dss_timeout)
session_id=uuid.uuid4().hex
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id)
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id,
SDBG_SNAP_DIR=str(state/'snapshot'))
# SDL3 превращает SIGTERM в SDL_EVENT_QUIT, который MAME SDL3 OSD
# не обрабатывает. Оставляем стандартное действие сигнала, чтобы
# DAP Stop не ждал принудительного kill остановленного MAME.
environment.setdefault('SDL_NO_SIGNAL_HANDLERS','1')
plugin_paths=[str(ROOT/'toolchain/mcp')]
installed_plugins=profile.home.parent/'plugins' if profile.home else None
if installed_plugins and installed_plugins.is_dir():
@@ -247,6 +252,7 @@ def main():
mame_process=subprocess.Popen(command,cwd=state,env=environment,
stdout=log,stderr=subprocess.STDOUT)
server=None
socket_identity=None
def mame_diagnostics():
log.flush()
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
@@ -257,8 +263,13 @@ def main():
if mame_process.poll() is not None:
raise RuntimeError('MAME завершился до main; лог: '+
str(state/'mame.log')+mame_diagnostics())
if (state/'main.json.error').exists() or time.monotonic()>deadline:
raise RuntimeError('Таймаут ожидания main; лог: '+
error_file=state/'main.json.error'
if error_file.exists():
raise RuntimeError('MAME не дошёл до main: '+
error_file.read_text(errors='replace').strip()+
'; лог: '+str(state/'mame.log')+mame_diagnostics())
if time.monotonic()>deadline:
raise RuntimeError('Таймаут ожидания main без ответа Lua; лог: '+
str(state/'mame.log')+mame_diagnostics())
time.sleep(.05)
entry=json.loads(ready.read_text())
@@ -269,6 +280,8 @@ def main():
if not line:
raise RuntimeError('Session server не запустился'+mame_diagnostics())
server_ready=json.loads(line)
socket_state=Path(args.socket).lstat()
socket_identity=(socket_state.st_dev,socket_state.st_ino)
print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid,
'entry':entry,'build_id':server_ready['build_id']},
ensure_ascii=False),flush=True)
@@ -280,6 +293,14 @@ def main():
server.terminate()
try: server.wait(timeout=3)
except subprocess.TimeoutExpired: server.kill();server.wait()
if socket_identity is not None:
socket_path=Path(args.socket)
try:
current=socket_path.lstat()
if (current.st_dev,current.st_ino)==socket_identity:
socket_path.unlink()
except FileNotFoundError:
pass
if mame_process.poll() is None:
mame_process.terminate()
try: mame_process.wait(timeout=5)
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""stdio MCP-адаптер к единственной source-debug сессии Sprinter."""
from __future__ import annotations
import argparse
from functools import wraps
import os
import sys
import threading
from typing import Any
from mame_profile import add_arguments
from sdbg.mcp_adapter import McpSession
from sdbg.session import SessionError
from sdbg.supervisor import ManagedMcpSession, SessionSupervisor
def make_server(client: McpSession | ManagedMcpSession,
supervisor: SessionSupervisor | None = None):
try:
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
except ImportError as error:
raise RuntimeError('Нужен официальный MCP SDK 2.x: python -m pip install "mcp>=2.2,<3"') from error
server = MCPServer('sprinter-source-debug',
instructions='Все команды работают через одну sdbg-сессию. '
'Чтение переменных и памяти требует остановленного CPU. '
'Для длинного шага используйте recent_events или pause_execution.')
reading = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
def tool(*, annotations=None):
def register(function):
@wraps(function)
def guarded(*args, **kwargs):
try:
return function(*args, **kwargs)
except (SessionError, OSError, ValueError) as error:
raise ToolError(str(error)) from error
return server.tool(annotations=annotations)(guarded)
return register
@tool(annotations=reading)
def session_status() -> dict[str, Any]:
"""Получить build ID, generation, состояние CPU и ограничения текущей сессии."""
return client.status()
if supervisor is not None:
@tool()
def start_session() -> dict[str, Any]:
"""Асинхронно запустить MAME→DSS→EXE→main из --build; готовность читать через session_status."""
return supervisor.start()
@tool()
def stop_session() -> dict[str, Any]:
"""Завершить только MAME, созданный этим MCP-сервером."""
return supervisor.stop()
@tool(annotations=reading)
def where() -> dict[str, Any]:
"""Получить проверенное соответствие PC исходнику C и текущему банку."""
return client.where()
@tool(annotations=reading)
def read_registers() -> dict[str, Any]:
"""Читать регистры Z80 и отображение банков на остановленном CPU."""
return client.registers()
@tool(annotations=reading)
def read_memory(address: str, length: int = 16) -> dict[str, Any]:
"""Читать 1..256 байт logical Z80 memory без side effects; адрес decimal или 0xHEX."""
return client.read_memory(address, length)
@tool(annotations=reading)
def read_program_memory(address: str, length: int = 16) -> dict[str, Any]:
"""Читать 1..4096 байт raw program space 0..0x3ffff; Z80 окна находятся в 0x10000+."""
return client.read_program_memory(address, length)
@tool(annotations=reading)
def disassemble_logical(address: str, length: int = 32) -> dict[str, Any]:
"""Дизассемблировать 1..256 байт текущей logical Z80 памяти при stop; ответ содержит bank_pages."""
return client.disassemble_logical(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()
def type_string(value: str) -> dict[str, Any]:
"""Ввести 1..64 символа физической PC-клавиатурой; '\\n' означает Enter."""
return client.type_string(value)
@tool(annotations=reading)
def list_variables() -> dict[str, Any]:
"""Перечислить известные global/static переменные и поддержанность типов."""
return {'variables': client.variables()}
@tool(annotations=reading)
def read_variable(name: str, module: str | None = None) -> dict[str, Any]:
"""Читать поддержанную global/static переменную; module разрешает неоднозначность."""
return client.read_variable(name, module)
@tool(annotations=reading)
def recent_events(after: int = 0, timeout: float = 0) -> dict[str, Any]:
"""Читать общий журнал stopped/continued/output с курсором seq; timeout 0..30 с."""
return client.events(after, timeout)
@tool(annotations=reading)
def mame_console_tail(count: int = 40) -> dict[str, Any]:
"""Получить последние строки консоли MAME, если bridge поддерживает её чтение."""
return client.console_tail(count)
@tool()
def set_line_breakpoint(file: str, line: int) -> dict[str, Any]:
"""Поставить личную точку по C-файлу и строке без замены точек VS Code."""
return client.break_line(file, line)
@tool()
def set_function_breakpoint(name: str) -> dict[str, Any]:
"""Поставить личную точку по имени C-функции."""
return client.break_function(name)
@tool(annotations=reading)
def list_breakpoints() -> dict[str, Any]:
"""Читать C-точки всех владельцев с логическими ID и условиями банка."""
return client.list_breakpoints()
@tool()
def clear_breakpoint(identifier: int) -> dict[str, Any]:
"""Удалить свою логическую точку по ID; чужие точки отклоняются."""
return client.clear_breakpoint(identifier)
@tool()
def clear_my_breakpoints() -> dict[str, Any]:
"""Удалить только точки текущего MCP-подключения."""
return client.clear_owned_breakpoints()
@tool()
def continue_execution() -> dict[str, Any]:
"""Продолжить CPU; событие остановки читать через recent_events."""
return client.continue_execution()
@tool()
def claim_control() -> dict[str, Any]:
"""Захватить управление CPU, если оно свободно; конфликт возвращает текущего владельца."""
return client.claim_control()
@tool()
def release_control() -> dict[str, Any]:
"""Освободить своё управление CPU для VS Code или другого MCP-клиента."""
return client.release_control()
@tool()
def pause_execution() -> dict[str, Any]:
"""Остановить CPU и вернуть текущую C-позицию."""
return client.pause_execution()
@tool()
def step_instruction(count: int = 1) -> dict[str, Any]:
"""Выполнить 1..64 машинных инструкций Z80 и вернуть текущую позицию."""
return client.step_instruction(count)
@tool()
def step_over_instruction(count: int = 1) -> dict[str, Any]:
"""Перешагнуть 1..64 машинных инструкций Z80 (вызовы — целиком)."""
return client.step_over_instruction(count)
@tool()
def step_out_instruction() -> dict[str, Any]:
"""Выйти из текущего машинного frame Z80; C source-step не используется."""
return client.step_out_instruction()
@tool()
def step_source(kind: str = 'into') -> dict[str, Any]:
"""Начать асинхронный C-шаг: into, over или out; результат в recent_events."""
return client.step_source(kind)
return server
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--socket', default=os.environ.get('SDBG_SOCKET'),
help='Unix socket сеанса; с --build можно не указывать')
parser.add_argument('--build', help='Debug-пакет для автономного MCP запуска')
add_arguments(parser)
parser.add_argument('--app-hdd')
parser.add_argument('--launch-path')
parser.add_argument('--data', action='append', default=[])
parser.add_argument('--debugger', default='sdbg')
parser.add_argument('--launch-at', type=float)
parser.add_argument('--dss-timeout', type=float)
args = parser.parse_args()
if not args.socket and not args.build:
parser.error('укажите --socket или SDBG_SOCKET')
supervisor = None
if args.build:
options = []
for name in ('mame_home', 'mame_bin', 'mame_rompath', 'mame_dss_image',
'mame_system_hdd_image', 'mame_bios', 'app_hdd', 'launch_path',
'debugger', 'launch_at', 'dss_timeout'):
value = getattr(args, name)
if value is not None:
options += ['--' + name.replace('_', '-'), str(value)]
for filename in args.data:
options += ['--data', filename]
supervisor = SessionSupervisor(args.build, args.socket, options)
client = ManagedMcpSession(supervisor)
else:
client = McpSession(args.socket)
heartbeat_stop = threading.Event()
heartbeat = None
try:
if supervisor is None:
client.status() # Attach-режим требует уже работающий session server.
def renew_control():
while not heartbeat_stop.wait(10):
try:
client.renew_control()
except (SessionError, OSError):
# Без lease обновлять нечего; следующая мутация захватит его.
pass
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
daemon=True)
heartbeat.start()
server = make_server(client, supervisor)
server.run(transport='stdio')
return 0
except (SessionError, OSError, RuntimeError) as error:
print('sdbg-mcp: ' + str(error), file=sys.stderr)
return 1
finally:
heartbeat_stop.set()
if heartbeat is not None:
heartbeat.join(timeout=1)
try:
client.release_control()
except (SessionError, OSError):
pass
try:
client.clear_owned_breakpoints()
except (SessionError, OSError):
pass
if supervisor is not None:
supervisor.close()
if __name__ == '__main__':
sys.exit(main())
+68 -21
View File
@@ -4,14 +4,18 @@ size_check.py — размерный регресс-тест: сравнение
docs/size_baseline.tsv.
Использование:
python3 toolchain/size_check.py # сверка с эталоном
python3 toolchain/size_check.py --update # перезаписать эталон
make size-check # собрать обязательные тесты и сверить с эталоном
make size-baseline # принять свежие размеры, сохранив необязательные
Логика: собираем _CODE из .map каждой программы под tests/, сравниваем с
эталоном. Любое отличие печатается; выход 1 если есть РОСТ (случайное
Логика: собираем _CODE из свежей .map каждой программы под tests/, сравниваем с
эталоном. Карты, оставшиеся от другой цели или более старые, чем входы
сборки, пропускаются. Любое отличие печатается; выход 1 если есть РОСТ (случайное
разжирение libc), 0 если только уменьшения/без изменений. Новые
программы (нет в эталоне) предупреждение, добавить через --update.
Запускать после `make all`.
--update заменяет размеры собранных тестов, сохраняя эталоны необязательных
тестов, которые сейчас не собирались.
Прямой запуск скрипта рассчитан на уже собранные тесты; Make-цели сами
собирают обязательный набор и передают его через --require.
ТОЛЬКО tests/, без отдельного репозитория Examples: регресс ловит разжирение libc, а для этого
хватает мелких тестов каждый тянет свой кусок библиотеки и пересобирается
@@ -20,18 +24,20 @@ docs/size_baseline.tsv.
`make all` они убраны (см. корневой Makefile).
"""
import argparse
import glob
import os
from pathlib import Path
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, "docs", "size_baseline.tsv")
def collect():
"""Имя программы → размер _CODE (байт) по свежим .map."""
"""Имя программы → размер _CODE (байт) по свежим .map своей цели."""
sizes = {}
skipped = set()
pats = [
os.path.join(ROOT, "tests", "*", ".sprinter-cc-*", "*.map"),
]
@@ -41,13 +47,43 @@ def collect():
# .map должен лежать в своей папке .sprinter-cc-<name>
if os.path.basename(os.path.dirname(map_path)) != ".sprinter-cc-" + name:
continue
project = Path(map_path).parents[1]
makefile = project / "Makefile"
if not makefile.is_file():
continue
declared = re.search(r"^EXAMPLE\s*[:?+]?=\s*(\S+)",
makefile.read_text(), re.M)
if not declared or declared.group(1) != name:
# Например, старый .sprinter-cc-w3bankgfx рядом с w3bgfx.
continue
flags = makefile.read_text()
safe = "--safe" in flags
gfx = "--gfx" in flags
variant = "_safe" if safe else ""
# app.mk сам по себе не является входом линкера: его изменения
# учитываются сборкой через sdbg-config и CC_FLAGS. Проверяем
# только реальные входы, иначе правка run-цели объявит все карты
# устаревшими, хотя размер программы не менялся.
dependencies = [makefile, Path(ROOT) / "bin/sprinter-cc",
Path(ROOT) / "toolchain/mkexe/mkexe",
Path(ROOT) / f"lib/sprinter{variant}.lib"]
if gfx:
dependencies.append(Path(ROOT) / f"lib/bgi256{variant}.lib")
dependencies.extend(project.glob("*.c"))
dependencies.extend(project.glob("*.h"))
dependencies.extend((Path(ROOT) / "runtime").glob("*.s"))
if any(not path.is_file() or
os.path.getmtime(map_path) + 1 < path.stat().st_mtime
for path in dependencies):
skipped.add(name)
continue
with open(map_path, encoding="utf-8", errors="replace") as f:
for line in f:
m = re.match(r"^_CODE\s+([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+)", line)
if m:
sizes[name] = int(m.group(2), 16)
break
return sizes
return sizes, skipped
def load_baseline():
@@ -64,23 +100,33 @@ def load_baseline():
def save_baseline(sizes):
accepted = load_baseline()
accepted.update(sizes)
with open(BASELINE, "w") as f:
f.write("# Эталон размеров _CODE (байт); обновление: "
"python3 toolchain/size_check.py --update\n")
for name in sorted(sizes):
f.write(f"{name}\t{sizes[name]}\n")
"make size-baseline\n")
for name in sorted(accepted):
f.write(f"{name}\t{accepted[name]}\n")
def main():
update = "--update" in sys.argv
sizes = collect()
parser = argparse.ArgumentParser(description="Проверка размера _CODE SDK-тестов")
parser.add_argument("--update", action="store_true")
parser.add_argument("--require", nargs="*", default=[], metavar="TEST")
args = parser.parse_args()
sizes, skipped = collect()
missing = set(args.require) - sizes.keys()
if missing:
print("size_check: нет свежей карты для обязательных тестов: " +
", ".join(sorted(missing)))
return 2
if not sizes:
print("size_check: .map не найдены — сначала `make all`")
print("size_check: свежие .map не найдены — сначала `make all`")
return 2
if update:
if args.update:
save_baseline(sizes)
print(f"size_check: эталон обновлён ({len(sizes)} программ) → "
print(f"size_check: эталон обновлён ({len(sizes)} свежих программ) → "
f"{os.path.relpath(BASELINE, ROOT)}")
return 0
@@ -90,17 +136,18 @@ def main():
return 2
grew = 0
for name in sorted(set(sizes) | set(base)):
for name in sorted(sizes):
new, old = sizes.get(name), base.get(name)
if new is None:
print(f" {name}: исчез из сборки (был {old} Б)")
elif old is None:
if old is None:
print(f" {name}: НОВЫЙ ({new} Б) — добавить в эталон (--update)")
elif new != old:
mark = "РОСТ" if new > old else "ок, меньше"
print(f" {name}: {old} -> {new} ({new - old:+d} Б) [{mark}]")
if new > old:
grew += 1
if skipped:
print(f"size_check: {len(skipped)} устаревших карт пропущено: " +
", ".join(sorted(skipped)))
if grew:
print(f"size_check: {grew} программ(ы) ВЫРОСЛИ — проверить правки libc")
return 1
@@ -109,4 +156,4 @@ def main():
if __name__ == "__main__":
sys.exit(main())
raise SystemExit(main())