Compare commits
19 Commits
2e7ffd64a4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a8d0692eb2 | |||
| 259c782190 | |||
| e8189b137a | |||
| 958ad0ae0f | |||
| 113d468c48 | |||
| 0ade3b4821 | |||
| 5dc7998324 | |||
| a43e7bda89 | |||
| 619c4c9af5 | |||
| 12667c865a | |||
| 7970a27c98 | |||
| 9ad5a018a2 | |||
| addc00a0f3 | |||
| f91d296476 | |||
| 60762a7c0d | |||
| 0a71f94240 | |||
| d25f28e1cd | |||
| 4ee45f0ace | |||
| a849a7b71d |
@@ -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
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -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-сравнения без человека~~ —
|
||||
|
||||
@@ -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> — платформа
|
||||
|
||||
|
||||
@@ -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` указывают в изолированный каталог сессии;
|
||||
|
||||
@@ -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. Нет автоматического чтения
|
||||
|
||||
@@ -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 — за гарантированную идентичность,
|
||||
|
||||
@@ -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 одновременно не проверялись.
|
||||
@@ -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 и прямой
|
||||
ввод после запуска приложения.
|
||||
@@ -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,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).
|
||||
|
||||
## Быстрый пример
|
||||
|
||||
|
||||
@@ -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`, 1–256 байт 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
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` позволяют переопределить отдельные пути.
|
||||
|
||||
@@ -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` в шелле.
|
||||
|
||||
@@ -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())
|
||||
@@ -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',
|
||||
|
||||
@@ -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'}]})
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)')
|
||||
|
||||
@@ -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': {}},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Необязательный stdio MCP-адаптер; основная сборка toolkit не зависит от SDK.
|
||||
mcp>=2.2,<3
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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})
|
||||
+308
-12
@@ -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,14 +81,19 @@ 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)
|
||||
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':
|
||||
if self.running:
|
||||
self._release_all_inputs()
|
||||
location = self.session.where(snapshot)
|
||||
if self.source_step is not None:
|
||||
self._source_step_stopped(location)
|
||||
@@ -89,11 +103,18 @@ class SessionController:
|
||||
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']),
|
||||
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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Владелец автономного launcher для MCP без запущенного VS Code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from .mcp_adapter import McpSession
|
||||
from .session import SessionError
|
||||
|
||||
|
||||
class SessionSupervisor:
|
||||
def __init__(self, build: str, socket_path: str | None = None,
|
||||
launcher_options: list[str] | None = None):
|
||||
self.build = str(Path(build).expanduser().resolve())
|
||||
self._temporary = None
|
||||
if socket_path:
|
||||
self.socket_path = str(Path(socket_path).expanduser())
|
||||
else:
|
||||
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
|
||||
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
|
||||
self.launcher_options = list(launcher_options or [])
|
||||
self.lock = threading.RLock()
|
||||
self.phase = 'idle'
|
||||
self.error: str | None = None
|
||||
self.entry = None
|
||||
self.mame_pid = None
|
||||
self.process: subprocess.Popen | None = None
|
||||
self.monitor: threading.Thread | None = None
|
||||
self.log = None
|
||||
self.client: McpSession | None = None
|
||||
self.launch_id = None
|
||||
|
||||
def status(self) -> dict:
|
||||
with self.lock:
|
||||
result = {'phase': self.phase, 'socket': self.socket_path,
|
||||
'launch_id': self.launch_id}
|
||||
if self.error:
|
||||
result['error'] = self.error
|
||||
if self.phase == 'ready' and self.client is not None:
|
||||
try:
|
||||
result.update(self.client.status())
|
||||
result['mame_pid'] = self.mame_pid
|
||||
result['entry'] = self.entry
|
||||
except (SessionError, OSError) as error:
|
||||
self.phase = 'failed'
|
||||
self.error = str(error)
|
||||
result.update(phase='failed', error=self.error)
|
||||
return result
|
||||
|
||||
def require_client(self) -> McpSession:
|
||||
with self.lock:
|
||||
if self.phase != 'ready' or self.client is None:
|
||||
raise SessionError('C-сессия ещё не готова: ' + self.phase)
|
||||
return self.client
|
||||
|
||||
def start(self) -> dict:
|
||||
with self.lock:
|
||||
if self.phase in ('starting', 'ready', 'stopping'):
|
||||
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
|
||||
if self.process is not None and self.process.poll() is None:
|
||||
raise SessionError('Предыдущий launcher ещё работает')
|
||||
if self.monitor is not None and self.monitor.is_alive():
|
||||
raise SessionError('Предыдущий launcher ещё завершает работу')
|
||||
if not Path(self.build).is_dir():
|
||||
raise SessionError('Debug-пакет не найден: ' + self.build)
|
||||
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
|
||||
'sdbg_launcher.py'), '--build', self.build,
|
||||
'--socket', self.socket_path, *self.launcher_options]
|
||||
if self.log is not None:
|
||||
self.log.close()
|
||||
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
|
||||
self.phase = 'starting'
|
||||
self.error = None
|
||||
self.entry = None
|
||||
self.mame_pid = None
|
||||
self.client = None
|
||||
self.launch_id = uuid.uuid4().hex
|
||||
try:
|
||||
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
||||
stderr=self.log, text=True)
|
||||
except OSError:
|
||||
self.phase = 'failed'
|
||||
raise
|
||||
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
|
||||
daemon=True)
|
||||
self.monitor.start()
|
||||
return {'accepted': True, 'phase': self.phase,
|
||||
'socket': self.socket_path, 'launch_id': self.launch_id}
|
||||
|
||||
def _diagnostics(self) -> str:
|
||||
if self.log is None:
|
||||
return ''
|
||||
self.log.flush()
|
||||
self.log.seek(0)
|
||||
return self.log.read()[-4000:].strip()
|
||||
|
||||
def _monitor(self) -> None:
|
||||
process = self.process
|
||||
assert process is not None and process.stdout is not None
|
||||
try:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
|
||||
ready = json.loads(line)
|
||||
if not ready.get('ready') or ready.get('socket') != self.socket_path:
|
||||
raise SessionError('Launcher вернул неверный ready')
|
||||
client = McpSession(self.socket_path)
|
||||
client.status()
|
||||
with self.lock:
|
||||
if self.phase == 'starting':
|
||||
self.client = client
|
||||
self.entry = ready['entry']
|
||||
self.mame_pid = ready['pid']
|
||||
self.phase = 'ready'
|
||||
process.wait()
|
||||
with self.lock:
|
||||
if self.phase not in ('stopping', 'failed'):
|
||||
self.phase = 'stopped'
|
||||
self.error = 'Launcher завершился: ' + self._diagnostics()
|
||||
elif self.phase == 'stopping':
|
||||
self.phase = 'stopped'
|
||||
self.client = None
|
||||
except (OSError, ValueError, KeyError, SessionError) as error:
|
||||
with self.lock:
|
||||
if self.phase == 'stopping':
|
||||
self.phase = 'stopped'
|
||||
else:
|
||||
self.phase = 'failed'
|
||||
self.error = str(error)
|
||||
self.client = None
|
||||
|
||||
def stop(self) -> dict:
|
||||
with self.lock:
|
||||
if self.phase not in ('starting', 'ready'):
|
||||
return {'accepted': False, 'phase': self.phase}
|
||||
self.phase = 'stopping'
|
||||
if self.process is not None and self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
return {'accepted': True, 'phase': 'stopping',
|
||||
'socket': self.socket_path}
|
||||
|
||||
def close(self) -> None:
|
||||
self.stop()
|
||||
process = self.process
|
||||
if process is not None:
|
||||
try:
|
||||
process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
if self.monitor is not None:
|
||||
self.monitor.join(timeout=2)
|
||||
if self.log is not None:
|
||||
self.log.close()
|
||||
if self._temporary is not None:
|
||||
self._temporary.cleanup()
|
||||
|
||||
|
||||
class ManagedMcpSession:
|
||||
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
|
||||
def __init__(self, supervisor: SessionSupervisor):
|
||||
self.supervisor = supervisor
|
||||
|
||||
def status(self) -> dict:
|
||||
return self.supervisor.status()
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.supervisor.require_client(), name)
|
||||
@@ -4,7 +4,7 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from sdbg.server import rpc_call
|
||||
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||
from sdbg.session import SessionError
|
||||
|
||||
|
||||
@@ -19,8 +19,16 @@ def main():
|
||||
arguments = json.loads(args.arguments)
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError('arguments должен быть JSON-объектом')
|
||||
identity = {}
|
||||
if args.method != 'status':
|
||||
status = rpc_call(args.socket, 'status')
|
||||
identity = {'session_id': status['session_id'],
|
||||
'build_id': status['build_id']}
|
||||
if args.method in RPC_GENERATION_MUTATIONS:
|
||||
identity['generation'] = status['generation']
|
||||
result = rpc_call(args.socket, args.method, arguments,
|
||||
timeout=35 if args.method == 'events' else 10)
|
||||
timeout=35 if args.method == 'events' else 10,
|
||||
**identity)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (SessionError, ValueError, OSError) as error:
|
||||
|
||||
@@ -221,7 +221,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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user