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
|
$(PYTHON) -m unittest discover -s tests/sdbg -v
|
||||||
|
|
||||||
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
|
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
|
||||||
size-check:
|
size-check: tests
|
||||||
$(PYTHON) toolchain/size_check.py
|
$(PYTHON) toolchain/size_check.py --require $(TESTS)
|
||||||
|
|
||||||
# Принять текущие размеры как эталон (после осознанных изменений).
|
# Принять текущие размеры как эталон (после осознанных изменений).
|
||||||
size-baseline:
|
size-baseline: tests
|
||||||
$(PYTHON) toolchain/size_check.py --update
|
$(PYTHON) toolchain/size_check.py --update --require $(TESTS)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
$(MAKE) -C toolchain/mkexe clean
|
$(MAKE) -C toolchain/mkexe clean
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ $(error SPRINTER_ROOT=$(SPRINTER_ROOT): bin/sprinter-cc не найден)
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
MAME_HOME ?=
|
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_ROMPATH ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/roms,)
|
||||||
MAME_DSS_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/dss171u.img,)
|
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_SYSTEM_HDD_IMAGE ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/IMG/sp_hdd_sys.chd,)
|
||||||
|
|||||||
@@ -279,12 +279,52 @@ Quick wins:
|
|||||||
|
|
||||||
## Прочий backlog
|
## Прочий 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 (отдельная
|
- [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная
|
||||||
задача, позже).** Проверить два изолированных процесса с разными
|
задача, позже).** Проверить два изолированных процесса с разными
|
||||||
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
||||||
команду с PID/session ID, не допускать ответа от чужого процесса,
|
команду с PID/session ID, не допускать ответа от чужого процесса,
|
||||||
проверить сброс/exit и одновременные команды. До успешного end-to-end
|
проверить сброс/exit и одновременные команды. До успешного end-to-end
|
||||||
теста параллельная работа MAME через MCP не гарантируется.
|
теста параллельная работа 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
|
- [ ] factoring parse_argv из crt0/crt0_banked в общий argv.s
|
||||||
- [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность
|
- [ ] `restore SP on EXIT` (паттерн z88dk +pps) — проверить нужность
|
||||||
- [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~ —
|
- [x] ~~CI: MAME с -aviwrite для screenshot-сравнения без человека~~ —
|
||||||
|
|||||||
@@ -484,7 +484,11 @@ global/static переменной, ненулевое значение озна
|
|||||||
[руководство по SDBG_LOG](sdbg-log-macros.md).
|
[руководство по SDBG_LOG](sdbg-log-macros.md).
|
||||||
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
|
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
|
||||||
недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console
|
недопустимы. Сообщение видно в 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> — платформа
|
## <sprinter.h> — платформа
|
||||||
|
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ python3 toolchain/mame_interactive.py /путь/к/Examples/mdview2/mdview2.exe
|
|||||||
|
|
||||||
### 3.1 Запуск MAME
|
### 3.1 Запуск MAME
|
||||||
|
|
||||||
Профиль `MAME_HOME` выбирает `mame.arm`, `roms/`, DSS-дискету и системный
|
Профиль `MAME_HOME` выбирает `sprinter` (новый MAME.HT) либо `mame.arm`
|
||||||
CHD. A: — временная дискета этого запуска; B: — DSS. При наличии в
|
(прежняя установка), `roms/`, DSS-дискету и системный CHD. `MAME_BIN` явно
|
||||||
|
выбирает бинарник независимо от каталога ресурсов. A: — временная дискета
|
||||||
|
этого запуска; B: — DSS. При наличии в
|
||||||
установленной среде CD/NeoGS/медиа скрипт подключает их как необязательные
|
установленной среде CD/NeoGS/медиа скрипт подключает их как необязательные
|
||||||
устройства. `-cfg_directory`, `-nvram_directory`, `-diff_directory` и
|
устройства. `-cfg_directory`, `-nvram_directory`, `-diff_directory` и
|
||||||
`-snapshot_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-сессия,
|
Реализованы сборка/карта, проверенный транспорт, базовая 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 и расширенная отладка ещё не готовы.
|
безопасный restart и расширенная отладка ещё не готовы.
|
||||||
Этапы плана не считаются завершёнными целиком по одному успешному репро.
|
Этапы плана не считаются завершёнными целиком по одному успешному репро.
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
|
|
||||||
- **macOS:** текущий end-to-end цикл проверен, включая backend `sdbg` и
|
- **macOS:** текущий end-to-end цикл проверен, включая backend `sdbg` и
|
||||||
одновременную работу VS Code со штатным `osx` debugger MAME.
|
одновременную работу 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`, но полный
|
- **Linux:** ожидается работа через `sdbg`, `qt` или `imgui`, но полный
|
||||||
живой прогон ещё не выполнен.
|
живой прогон ещё не выполнен.
|
||||||
- **Windows:** весь функционал пока работать не будет. Provider
|
- **Windows:** весь функционал пока работать не будет. Provider
|
||||||
@@ -65,6 +70,11 @@
|
|||||||
логические группы и события. `set_source_breakpoints` и
|
логические группы и события. `set_source_breakpoints` и
|
||||||
`set_function_breakpoints` создают новый disabled-набор, при ошибке удаляют
|
`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,
|
- DAP MVP и минимальное VS Code-расширение: attach к session server,
|
||||||
source/function breakpoints, один достоверный frame, registers,
|
source/function breakpoints, один достоверный frame, registers,
|
||||||
поддержанные globals/statics, evaluate одного имени, continue/pause и
|
поддержанные globals/statics, evaluate одного имени, continue/pause и
|
||||||
@@ -80,8 +90,9 @@
|
|||||||
для приложений с `app.mk`, команду Build Active Project и автоматическую
|
для приложений с `app.mk`, команду Build Active Project и автоматическую
|
||||||
debug-сборку перед F5. Она использует `make SRC_DEBUG=1` и local Python 3.12;
|
debug-сборку перед F5. Она использует `make SRC_DEBUG=1` и local Python 3.12;
|
||||||
ошибки SDCC с файлом/строкой попадают в Problems. Код сборки проверяется
|
ошибки SDCC с файлом/строкой попадают в Problems. Код сборки проверяется
|
||||||
самим расширением: при ошибке MAME не запускается. Run-команда, расширенные
|
самим расширением: при ошибке MAME не запускается. VSIX 0.2.0 собран и
|
||||||
linker/assembler diagnostics, выбор profile/EXTRA_DATA и VSIX остаются
|
проверен в отдельном workspace SprPoP. Run-команда, расширенные
|
||||||
|
linker/assembler diagnostics и выбор profile/EXTRA_DATA остаются
|
||||||
следующими шагами.
|
следующими шагами.
|
||||||
- DAP `logMessage` без пересборки: разрешены литералы, `{{`/`}}` и только
|
- DAP `logMessage` без пересборки: разрешены литералы, `{{`/`}}` и только
|
||||||
подстановки `{variable}`. Session server читает типизированное значение,
|
подстановки `{variable}`. Session server читает типизированное значение,
|
||||||
@@ -112,7 +123,25 @@
|
|||||||
копию системного HDD и отдельные state-каталоги, показывает окно Sprinter,
|
копию системного HDD и отдельные state-каталоги, показывает окно Sprinter,
|
||||||
ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`,
|
ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`,
|
||||||
проверяет сигнатуру entry и запускает session server. Закрытие DAP
|
проверяет сигнатуру 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`,
|
Доступны `where`, `step`, `continue`, `break-line`, `break-function`,
|
||||||
`read-variable`, `activate-breakpoints` и `deactivate-breakpoints`.
|
`read-variable`, `activate-breakpoints` и `deactivate-breakpoints`.
|
||||||
Это диагностический однооперационный CLI: логические ID групп точек живут
|
Это диагностический однооперационный CLI: логические ID групп точек живут
|
||||||
только внутри процесса. Постоянное владение и DAP добавятся в общем session
|
только внутри процесса. Для долгой работы и DAP используйте общий session
|
||||||
server; до него для долгой ручной работы нужен один Python-процесс с
|
server ниже.
|
||||||
`DebugSession`.
|
|
||||||
|
|
||||||
Предпочтительный режим для нескольких клиентов — один server:
|
Предпочтительный режим для нескольких клиентов — один server:
|
||||||
|
|
||||||
@@ -165,7 +193,7 @@ pyenv exec python toolchain/sdbg_client.py \
|
|||||||
--socket /tmp/sprinter-sdbg.sock status
|
--socket /tmp/sprinter-sdbg.sock status
|
||||||
```
|
```
|
||||||
|
|
||||||
VS Code-расширение находится в `toolchain/vscode-sprinter-debug/`. В
|
VS Code-расширение находится в отдельном репозитории `../VSCode-Sprinter`. В
|
||||||
Extension Development Host используется attach-конфигурация:
|
Extension Development Host используется attach-конфигурация:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -177,9 +205,10 @@ Extension Development Host используется attach-конфигурац
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
В VS Code расширение `0.2.0` запускает адаптер через абсолютный путь к local
|
В VS Code расширение `0.2.0` находит SDK через `sprinterDebugger.sdkRoot`,
|
||||||
pyenv shim `~/.pyenv/shims/python` и задаёт корень workspace как `cwd`.
|
а Python 3.12 — через local pyenv shim или установленную pyenv-версию.
|
||||||
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock.
|
Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock,
|
||||||
|
и позволяет отлаживать приложение из отдельного workspace.
|
||||||
Выбранные пути видны в Output → `Sprinter MAME Debug`; при раннем сбое
|
Выбранные пути видны в Output → `Sprinter MAME Debug`; при раннем сбое
|
||||||
launcher сообщает stderr и последние строки MAME log.
|
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
|
||||||
pyenv exec python tests/sdbg/run_mame_probe.py --bridge --banked
|
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 --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
|
||||||
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --native-debugger
|
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 --source-step
|
||||||
pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --step-out
|
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
|
||||||
|
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
|
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 F11/F10 | `main`: `0x42bb` → `0x42c1`; F10 выполнил банковский `worker` и остановился в caller на `0x42c9` |
|
||||||
| Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `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 |
|
| 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 |
|
| F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg |
|
||||||
| VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` |
|
| VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` |
|
||||||
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
|
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
|
||||||
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
|
| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются |
|
||||||
|
|
||||||
Полный host-набор: 29 тестов прошли, один Unix-socket тест пропущен только
|
Полный host-набор в этом коммите: 45 тестов (44 прошли, один Unix-socket
|
||||||
из-за запрета `bind` в sandbox. Тот же socket path проверен живым DAP-запуском.
|
тест пропущен только из-за запрета `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 или проверка всех
|
Это репро доступности механизма, а не полный benchmark или проверка всех
|
||||||
runtime/mapping/lifecycle. Отдельная публикация debug-библиотек требует
|
runtime/mapping/lifecycle. Отдельная публикация debug-библиотек требует
|
||||||
@@ -280,9 +356,8 @@ runtime/mapping/lifecycle. Отдельная публикация debug-биб
|
|||||||
подтвердил, что при остановке в `main` окно снова открывается через
|
подтвердил, что при остановке в `main` окно снова открывается через
|
||||||
Cmd-Tab/Dock. Регрессионный DAP-проход через `getchar()` с клавишей `x`
|
Cmd-Tab/Dock. Регрессионный DAP-проход через `getchar()` с клавишей `x`
|
||||||
также завершился на следующей строке с кодом `0x78`.
|
также завершился на следующей строке с кодом `0x78`.
|
||||||
Воспроизводимый patch и идемпотентный установщик находятся в
|
Воспроизводимый patch, установщик и сборочные рецепты находятся в отдельном
|
||||||
`toolchain/mame-patches/` и `toolchain/apply-mame-sdbg-patch.sh`;
|
`../MAME/scripts/sprinter/`. Provider `osx` остаётся
|
||||||
`make mame-sdbg` собирает и устанавливает бинарник. Provider `osx` остаётся
|
|
||||||
доступной launch-опцией и проверен одновременно с DAP. Для непатченного MAME
|
доступной launch-опцией и проверен одновременно с DAP. Для непатченного MAME
|
||||||
можно использовать `auto`: штатные варианты — `osx` на macOS, `windows` в
|
можно использовать `auto`: штатные варианты — `osx` на macOS, `windows` в
|
||||||
native Windows build, `qt` или `imgui` в Linux. `qt` зависит от
|
native Windows build, `qt` или `imgui` в Linux. `qt` зависит от
|
||||||
@@ -291,20 +366,21 @@ host-моста пока не готова: FileBridge использует `fcn
|
|||||||
|
|
||||||
Также startup debugscript с `g` может продолжить первое реальное попадание;
|
Также startup debugscript с `g` может продолжить первое реальное попадание;
|
||||||
живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot
|
живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot
|
||||||
на reset защищена от дублирования callback. Старый mamebridge параллельно
|
на reset защищена от дублирования callback. Raw `-plugin mamebridge`
|
||||||
с sdbgbridge не загружать: общая арбитрирующая сессия ещё не реализована.
|
параллельно с `sdbgbridge` не загружать: общая арбитрирующая сессия для
|
||||||
|
двух Lua-мостов ещё не реализована.
|
||||||
Два одновременно запущенных процесса MAME через MCP bridge не проверялись;
|
Два одновременно запущенных процесса MAME через MCP bridge не проверялись;
|
||||||
корректная маршрутизация команд между ними не гарантируется. Это отдельная
|
корректная маршрутизация команд между ними не гарантируется. Это отдельная
|
||||||
отложенная задача в [TODO.md](TODO.md).
|
отложенная задача в [TODO.md](TODO.md).
|
||||||
|
|
||||||
## Размерный регресс и оставшаяся работа
|
## Размерный регресс и оставшаяся работа
|
||||||
|
|
||||||
`make size-check` запущен: сообщает рост у 12 программ относительно текущего
|
`make size-check` теперь проходит (46 свежих карт). Прежние 12 расхождений
|
||||||
эталона, отсутствующие и новые сборки. В списке роста нет пересобранного
|
разобраны: `open()` добавил 9 байт после исправления режимов DSS;
|
||||||
hello. Дополнительно cat и hello собраны исходной обёрткой из HEAD и новой
|
`openenv` содержит ещё новый регрессионный тест; прежний размер `w3bgfx`
|
||||||
на тех же runtime/библиотеках: EXE попарно идентичны. Эталон не изменялся.
|
был снят по несвежей карте. Подробное сравнение с исходной ревизией и
|
||||||
Этот общий check пока не считается прошедшим; расхождение существующих
|
правила обновления эталона — в
|
||||||
сборок с baseline отделено от побайтовых регрессий новой debug-сборки.
|
[плане разделения](project-reorganization.md).
|
||||||
|
|
||||||
Hard reset из RPC был удалён после воспроизводимого падения MAME 0.287:
|
Hard reset из RPC был удалён после воспроизводимого падения MAME 0.287:
|
||||||
старый Lua periodic callback обращался к `debugger_manager` во время нового
|
старый Lua periodic callback обращался к `debugger_manager` во время нового
|
||||||
@@ -316,9 +392,13 @@ Crash reports в 19:22/19:23 относились к неподдержанно
|
|||||||
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
|
строит проверенное условие через I/O port. После штатных прогонов в 20:43 и
|
||||||
позже новых `mame.arm-*.ips` не появилось.
|
позже новых `mame.arm-*.ips` не появилось.
|
||||||
|
|
||||||
Далее по плану: завершить инвалидацию на exit/state load, MCP-адаптер,
|
Далее по плану: завершить арбитраж управления CPU и cleanup для MCP-точек при
|
||||||
безопасный restart, расширенные выражения
|
аварийном выходе клиента, затем безопасный restart и расширенные выражения.
|
||||||
и упаковку VS Code-расширения. Для IDE ещё нужны Run-команда, выбор профиля
|
Низкоуровневые MAME MCP-инструменты (`src/mame_mcp.py` с
|
||||||
|
`-plugin mamebridge`) нельзя просто перенести: они должны
|
||||||
|
проходить через общий session server и получить проверенный контракт для
|
||||||
|
ввода, скриншотов, записи/watchpoints. VSIX уже упакован;
|
||||||
|
для IDE ещё нужны Run-команда, выбор профиля
|
||||||
сборки/данных и расширенная диагностика assembler/linker.
|
сборки/данных и расширенная диагностика assembler/linker.
|
||||||
Базовый attach уже проверяет принадлежность resident/current-bank к build,
|
Базовый attach уже проверяет принадлежность resident/current-bank к build,
|
||||||
но не умеет читать неотображённую physical RAM. Нет автоматического чтения
|
но не умеет читать неотображённую physical RAM. Нет автоматического чтения
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ CLI или MCP.
|
|||||||
[sprinter-cc](../bin/sprinter-cc), [app.mk](../app.mk),
|
[sprinter-cc](../bin/sprinter-cc), [app.mk](../app.mk),
|
||||||
[mame_interactive.py](../toolchain/mame_interactive.py),
|
[mame_interactive.py](../toolchain/mame_interactive.py),
|
||||||
[bank.s](../runtime/bank.s), [crt0_banked.s](../runtime/crt0_banked.s).
|
[bank.s](../runtime/bank.s), [crt0_banked.s](../runtime/crt0_banked.s).
|
||||||
MAME fork теперь самостоятельный проект: `MAME/plugins/mamebridge/init.lua`,
|
MAME fork теперь самостоятельный проект: новый `MAME.HT` содержит
|
||||||
`MAME/src/mame_mcp.py`; среда запуска задаётся `MAME_HOME`, а исходники
|
`plugins/mamebridge/init.lua`, `src/mame_mcp.py` и backend `sdbg`;
|
||||||
|
среда запуска задаётся `MAME_HOME`, бинарник — `MAME_BIN`, а исходники
|
||||||
fork приложению не нужны. Расширение VS Code находится в отдельном
|
fork приложению не нужны. Расширение VS Code находится в отдельном
|
||||||
`VSCode-Sprinter`; Python DAP backend остаётся в SDK.
|
`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 и развитие
|
### 8.2 DAP MVP и развитие
|
||||||
|
|
||||||
MVP: initialize, attach, configurationDone, disconnect, setBreakpoints,
|
MVP: initialize, attach, configurationDone, disconnect, setBreakpoints,
|
||||||
@@ -678,6 +699,20 @@ stepIn. Версионировать мост/патчи и установщик
|
|||||||
exit/reset/reload инвалидируют состояние; потеря клиента не блокирует MAME,
|
exit/reset/reload инвалидируют состояние; потеря клиента не блокирует MAME,
|
||||||
конкурентные операции не читают смешанный snapshot.
|
конкурентные операции не читают смешанный 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-отладка и логи
|
### Этап 3 — полезная CLI/MCP-отладка и логи
|
||||||
|
|
||||||
Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись
|
Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись
|
||||||
@@ -737,6 +772,17 @@ SDCC. Добавить launch/restart/stopOnMain и упаковку VSIX. До
|
|||||||
без side effects, после проверки размера, доступного банка, границ и image
|
без side effects, после проверки размера, доступного банка, границ и image
|
||||||
identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный
|
identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный
|
||||||
pointee дают явный `unavailable`/truncated, а не неверное значение.
|
pointee дают явный `unavailable`/truncated, а не неверное значение.
|
||||||
|
- Исследовать временную блокировку физического ввода в MAME до запуска
|
||||||
|
отлаживаемого EXE: окно может получить фокус, пока пользователь вводит
|
||||||
|
текст в другой программе. По возможности сохранить фокус и ввод за этой
|
||||||
|
программой до запуска EXE. Автоматический ввод команды launcher в DSS
|
||||||
|
должен работать и при блокировке. В момент запуска EXE сразу открыть
|
||||||
|
прямой ввод, включая ожидание `getchar()`; восстановить исходное состояние
|
||||||
|
при Stop, ошибке и restart. Если источники ввода нельзя разделить штатно,
|
||||||
|
проверить управление фокусом или узкий patch MAME. Приёмка — живой тест
|
||||||
|
ранних нажатий, ввода в другое приложение, переключения окон и ввода после
|
||||||
|
запуска. Подробная задача
|
||||||
|
сохранена в [TODO.md](TODO.md).
|
||||||
|
|
||||||
**Выход:** доказательства и ограничения каждой функции опубликованы.
|
**Выход:** доказательства и ограничения каждой функции опубликованы.
|
||||||
Эвристика не выдаётся за стек, anchors — за гарантированную идентичность,
|
Эвристика не выдаётся за стек, 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, MAME и приложений
|
||||||
|
|
||||||
Статус: физическое разделение выполнено; завершаются локальные коммиты,
|
Статус: разделение выполнено, локальные репозитории зафиксированы,
|
||||||
проверка документов и публикация Sprinter-CC. Дата обновления: 2026-09-16.
|
Sprinter-CC опубликован. Открытые проверки перечислены ниже. Дата обновления:
|
||||||
|
2026-09-16.
|
||||||
|
|
||||||
## Цель и границы
|
## Цель и границы
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ MAME приложению не нужны.
|
|||||||
```text
|
```text
|
||||||
~/Projects/DIY/Z80/Sprinter/
|
~/Projects/DIY/Z80/Sprinter/
|
||||||
├── C-Compiler/ # Sprinter-CC
|
├── C-Compiler/ # Sprinter-CC
|
||||||
├── MAME/ # fork MAME, сборки и локальная среда запуска
|
├── MAME/ # прежний fork MAME 0.287, локальные ресурсы
|
||||||
|
├── MAME.HT/ # новый fork Holub/Tolik, сборка sprinter
|
||||||
├── Examples/ # один Git-репозиторий примеров
|
├── Examples/ # один Git-репозиторий примеров
|
||||||
├── Applications/ # каталог без собственного Git
|
├── Applications/ # каталог без собственного Git
|
||||||
│ ├── SprPoP/ # самостоятельный Git-репозиторий
|
│ ├── SprPoP/ # самостоятельный Git-репозиторий
|
||||||
@@ -42,8 +44,9 @@ MAME приложению не нужны.
|
|||||||
| SprPoP | `4507ba9` | `bd300b3` |
|
| SprPoP | `4507ba9` | `bd300b3` |
|
||||||
| Volkov | `a71e3c5` | `3c1956b` |
|
| Volkov | `a71e3c5` | `3c1956b` |
|
||||||
| PoP-Archive | `716c639` | `54d0b3d` |
|
| PoP-Archive | `716c639` | `54d0b3d` |
|
||||||
| VSCode-Sprinter | `68a821e` | `901d91a` |
|
| VSCode-Sprinter | `68a821e` | `abfc29d` (`901d91a` — функциональные изменения) |
|
||||||
| MAME | baseline `b0c4527c`, backend/MCP `fe7ee37a` | `37b89b78` |
|
| 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
|
Первые пять base-коммитов получены из подготовительного коммита Sprinter-CC
|
||||||
через `git subtree split`; поэтому их история прослеживается до исходного
|
через `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`; дальнейшее выделение возможно после стабильного интерфейса. |
|
| 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 с общей версией. Один репозиторий избегает множества мелких релизов. |
|
| Examples | Выделенный `../Examples`: `balls`, `mdview`, `mdview2`, `rpgwalk`, `scroll`, `space` и относящиеся к ним ресурсы/документы | Это демонстрации SDK с общей версией. Один репозиторий избегает множества мелких релизов. |
|
||||||
| SprPoP | Всё из прежнего `applications/SprPoP/`: исходники, конверторы, тесты, собственные ресурсы и планы | Уже почти автономное приложение; оригинальные ресурсы и дальше остаются внешними. |
|
| SprPoP | Всё из прежнего `applications/SprPoP/`: исходники, конверторы, тесты, собственные ресурсы и планы | Уже почти автономное приложение; оригинальные ресурсы и дальше остаются внешними. |
|
||||||
| Volkov | Всё из прежнего `applications/Volkov/`: исходники, сценарии MAME, собственные тестовые носители и документы | Продукт и его проверки должны развиваться без дерева тулкита. |
|
| Volkov | Всё из прежнего `applications/Volkov/`: исходники, сценарии MAME, собственные тестовые носители и документы | Продукт и его проверки должны развиваться без дерева тулкита. |
|
||||||
@@ -87,13 +91,15 @@ MAME, а не к его исходникам. Стандартные пути в
|
|||||||
контракт установленной среды и допускают переопределение из окружения, аргументов `make` или
|
контракт установленной среды и допускают переопределение из окружения, аргументов `make` или
|
||||||
локального игнорируемого файла настроек. Если `MAME_HOME` не задан,
|
локального игнорируемого файла настроек. Если `MAME_HOME` не задан,
|
||||||
необходимые пути можно указать отдельно; отсутствие обоих источников
|
необходимые пути можно указать отдельно; отсутствие обоих источников
|
||||||
диагностировать в цели запуска, не превращая пустое значение в `/mame.arm`:
|
диагностировать в цели запуска, не превращая пустое значение в путь от
|
||||||
|
корня файловой системы:
|
||||||
|
|
||||||
```make
|
```make
|
||||||
MAME_BIN ?= $(MAME_HOME)/mame.arm
|
MAME_HOME ?=
|
||||||
MAME_ROMPATH ?= $(MAME_HOME)/roms
|
MAME_BIN ?=
|
||||||
MAME_DSS_IMAGE ?= $(MAME_HOME)/IMG/dss171u.img
|
MAME_ROMPATH ?= $(if $(strip $(MAME_HOME)),$(MAME_HOME)/roms,)
|
||||||
MAME_SYSTEM_HDD_IMAGE ?= $(MAME_HOME)/IMG/sp_hdd_sys.chd
|
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
|
MAME_BIOS ?= v3.06
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,8 +112,13 @@ MAME_BIOS ?= v3.06
|
|||||||
DAP-launch должны брать один источник настройки, даже если конкретный профиль
|
DAP-launch должны брать один источник настройки, даже если конкретный профиль
|
||||||
не монтирует DSS-дискету.
|
не монтирует DSS-дискету.
|
||||||
|
|
||||||
`MAME_HOME/mame.arm` — стандартный установленный бинарник, а stock/sdbg
|
В новом `MAME.HT` команда
|
||||||
сборки находятся отдельно и выбираются явным `MAME_BIN`. Сборка одного
|
`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 в каталог приложения. Все цели запуска валидируют свои
|
копируя ROM/CHD в каталог приложения. Все цели запуска валидируют свои
|
||||||
@@ -135,6 +146,10 @@ host-зависимости и проверять их перед упаковк
|
|||||||
разные локальные диски. Не требовать закрывать все чужие процессы MAME по
|
разные локальные диски. Не требовать закрывать все чужие процессы MAME по
|
||||||
общему шаблону: предотвращать конфликт только за конкретный изменяемый
|
общему шаблону: предотвращать конфликт только за конкретный изменяемый
|
||||||
носитель/IPC-сеанс. Закрытие MAME не должно терять лог и снимки теста.
|
носитель/IPC-сеанс. Закрытие MAME не должно терять лог и снимки теста.
|
||||||
|
Обычный `run_sprinter_mame.py` перед стартом копирует `cfg` и `nvram` из
|
||||||
|
`MAME_HOME` в каталог сессии, затем включает обе клавиатуры в копии
|
||||||
|
`sprinter.cfg`, сохраняя остальные настройки. Исходная установка не меняется;
|
||||||
|
отладчик DAP по-прежнему начинает с чистого изолированного состояния.
|
||||||
|
|
||||||
## Граница MAME и отладки
|
## Граница MAME и отладки
|
||||||
|
|
||||||
@@ -248,14 +263,14 @@ debug packages, носители, снимки, внешние источник
|
|||||||
их документы и историю. Критерий: каждый продукт собирается и проходит
|
их документы и историю. Критерий: каждый продукт собирается и проходит
|
||||||
доступные host/MAME проверки из изолированного клона без Examples и
|
доступные host/MAME проверки из изолированного клона без Examples и
|
||||||
других приложений.
|
других приложений.
|
||||||
6. **Реализовано; остаётся ручная проверка установленного VSIX после разделения — выделить extension.** Научить VS Code находить SDK DAP независимо от
|
6. **Выполнено — выделить extension.** Научить VS Code находить SDK DAP независимо от
|
||||||
workspace, запускать build/run/debug приложения и показывать ошибки
|
workspace, запускать build/run/debug приложения и показывать ошибки
|
||||||
разрешения путей/версий. Проверить VSIX в отдельном workspace SprPoP
|
разрешения путей/версий. Проверить VSIX в отдельном workspace SprPoP
|
||||||
или Volkov, а не только в `C-Compiler`. Критерий: F5 строит debug package,
|
или Volkov, а не только в `C-Compiler`. Критерий: F5 строит debug package,
|
||||||
ждёт DSS, доходит до `main`, принимает breakpoint/logpoint и клавиатуру;
|
ждёт DSS, доходит до `main`, принимает breakpoint/logpoint и клавиатуру;
|
||||||
опция родного окна MAME работает на macOS. Windows остаётся явно
|
опция родного окна MAME работает на macOS. Windows остаётся явно
|
||||||
ограниченной до отдельной реализации host transport.
|
ограниченной до отдельной реализации host transport.
|
||||||
7. **Завершается — очистить SDK и документы.** Заменить корневые цели, release-скрипт,
|
7. **Выполнено — очистить SDK и документы.** Заменить корневые цели, release-скрипт,
|
||||||
README, `AGENTS.md`, инструкции автотеста/отладки и рабочие ссылки.
|
README, `AGENTS.md`, инструкции автотеста/отладки и рабочие ссылки.
|
||||||
Проверить `make`, `make -C libc`, `make -C libbgi`, `make size-check`,
|
Проверить `make`, `make -C libc`, `make -C libbgi`, `make size-check`,
|
||||||
SDK host/sdbg tests и отдельные проекты. Критерий: в SDK нет tracked
|
SDK host/sdbg tests и отдельные проекты. Критерий: в SDK нет tracked
|
||||||
@@ -273,13 +288,27 @@ SprPoP, Volkov и PoP-Archive PoC. Живые последовательные D
|
|||||||
`SDBG_LOG` в обеих консолях и запуск SprPoP с собственного `hard2` до
|
`SDBG_LOG` в обеих консолях и запуск SprPoP с собственного `hard2` до
|
||||||
`main`.
|
`main`.
|
||||||
|
|
||||||
`make size-check` пока не принят: текущий baseline показывает 12 старых
|
Extension воспроизводимо упакован через `npm ci && npm run package` и VSIX
|
||||||
увеличений (обычно +9 байт, `openenv` +188) и несколько исчезнувших прежних
|
версии 0.2.0 установлен в локальный VS Code. Рецепт и `package-lock.json`
|
||||||
тестов. Реорганизация не меняла libc/libbgi, поэтому эталон автоматически не
|
хранятся в VSCode-Sprinter. Изолированный профиль VS Code с файлами
|
||||||
перезаписывался; расхождения нужно разобрать отдельно. После разделения ещё
|
установленного VSIX выполнил F5-сценарий SprPoP: задача `make SRC_DEBUG=1
|
||||||
нужна ручная проверка установленного VSIX в чистом workspace. Несколько
|
hdd` завершилась, DAP остановился в `src/sprpop.c:264` (`main`). Проверка
|
||||||
одновременных MAME, маршрутизация MCP между ними и Windows transport остаются
|
шла через 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
|
Каждый этап заканчивается проверкой в отдельном репозитории и просмотром Git
|
||||||
diff. Новые Git remote для выделенных репозиториев и публикация их релизов
|
diff. Новые Git remote для выделенных репозиториев и публикация их релизов
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
|
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
|
||||||
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
|
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
|
||||||
`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME,
|
`"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
|
accfill 3797
|
||||||
accop 7067
|
accop 7067
|
||||||
argv 3445
|
argv 3445
|
||||||
@@ -14,18 +14,18 @@ bgitest 3762
|
|||||||
bios_text 4475
|
bios_text 4475
|
||||||
blitperf 5860
|
blitperf 5860
|
||||||
blitw 4939
|
blitw 4939
|
||||||
cat 927
|
cat 936
|
||||||
cblstream 5944
|
cblstream 5953
|
||||||
cbltest 6090
|
cbltest 6090
|
||||||
cblwav 6183
|
cblwav 6192
|
||||||
conio 4619
|
conio 4619
|
||||||
conio2 3943
|
conio2 3943
|
||||||
convbench 3508
|
convbench 3508
|
||||||
dec_test 874
|
dec_test 874
|
||||||
errno 5980
|
errno 5989
|
||||||
fbench 8149
|
fbench 8158
|
||||||
fdmax 6087
|
fdmax 6096
|
||||||
filetest 10611
|
filetest 10620
|
||||||
fpsdiv 4870
|
fpsdiv 4870
|
||||||
gets 523
|
gets 523
|
||||||
gfx_dbuf 5207
|
gfx_dbuf 5207
|
||||||
@@ -40,26 +40,26 @@ ls 4849
|
|||||||
malloc 4475
|
malloc 4475
|
||||||
mem_test 4569
|
mem_test 4569
|
||||||
mouse 4396
|
mouse 4396
|
||||||
openenv 6138
|
openenv 6326
|
||||||
pageflip 6334
|
pageflip 6334
|
||||||
palfile 5406
|
palfile 5406
|
||||||
ptime 5758
|
ptime 5758
|
||||||
rt_test 5216
|
rt_test 5216
|
||||||
scroll 6744
|
scroll 6744
|
||||||
seek 4202
|
seek 4211
|
||||||
simple 969
|
simple 969
|
||||||
solidt 11595
|
solidt 11604
|
||||||
spranim 9431
|
spranim 9431
|
||||||
spriteclip 4119
|
spriteclip 4119
|
||||||
sprites 6791
|
sprites 6791
|
||||||
stattest 7563
|
stattest 7572
|
||||||
stdlib 6657
|
stdlib 6657
|
||||||
stest2 3658
|
stest2 3658
|
||||||
strtest 1354
|
strtest 1354
|
||||||
text_palette 5038
|
text_palette 5038
|
||||||
timedir 5468
|
timedir 5468
|
||||||
w0page 8831
|
w0page 8831
|
||||||
w3bgfx 5243
|
w3bgfx 5257
|
||||||
w3big 3673
|
w3big 3673
|
||||||
w3huge 3700
|
w3huge 3700
|
||||||
w3probe 3556
|
w3probe 3556
|
||||||
|
|||||||
|
@@ -59,6 +59,8 @@ code --extensionDevelopmentPath="/путь/к/VSCode-Sprinter" "$PWD"
|
|||||||
второй — на среду `MAME/runtime` с бинарником, ROM и DSS/CHD. Для выбора
|
второй — на среду `MAME/runtime` с бинарником, ROM и DSS/CHD. Для выбора
|
||||||
stock/sdbg или нестандартной установки используются поля `mameBin`,
|
stock/sdbg или нестандартной установки используются поля `mameBin`,
|
||||||
`mameRompath`, `mameDssImage`, `mameSystemHddImage`, `mameBios` профиля launch.
|
`mameRompath`, `mameDssImage`, `mameSystemHddImage`, `mameBios` профиля launch.
|
||||||
|
Для нового fork можно оставить `mameHome` на подготовленной прежней среде и
|
||||||
|
задать `"mameBin": "/путь/к/MAME.HT/sprinter"` в launch-конфигурации.
|
||||||
|
|
||||||
Расширение в режиме `auto` использует `~/.pyenv/shims/python` при наличии
|
Расширение в режиме `auto` использует `~/.pyenv/shims/python` при наличии
|
||||||
local `.python-version`; для внешнего workspace ищет установленный
|
local `.python-version`; для внешнего workspace ищет установленный
|
||||||
@@ -99,6 +101,15 @@ service-точка `main` и вводится `a:\\HELLO.EXE`. После сов
|
|||||||
а VS Code показывает остановку на entry. Завершение debug session останавливает
|
а VS Code показывает остановку на entry. Завершение debug session останавливает
|
||||||
только созданные ей MAME/server.
|
только созданные ей 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 эмулируемых секунд).
|
`dssTimeout` задаёт предельное время ожидания prompt (30 эмулируемых секунд).
|
||||||
`launchAt` можно задать как необязательную нижнюю границу времени запуска;
|
`launchAt` можно задать как необязательную нижнюю границу времени запуска;
|
||||||
наличие prompt всё равно обязательно. Перед вводом сохраняется диагностический
|
наличие prompt всё равно обязательно. Перед вводом сохраняется диагностический
|
||||||
@@ -142,7 +153,8 @@ Launcher монтирует временную копию CHD как `-hard2`,
|
|||||||
Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу
|
Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу
|
||||||
Sprinter Build. После успешной сборки должно появиться окно Sprinter;
|
Sprinter Build. После успешной сборки должно появиться окно Sprinter;
|
||||||
launcher дождётся prompt DSS, введёт
|
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
|
4. Проверьте Call Stack, scope Registers и Debug Console. После Continue
|
||||||
должна сработать подтверждённая точка строки 31.
|
должна сработать подтверждённая точка строки 31.
|
||||||
5. На остановке проверьте F11 и F10. Курсор должен переходить только после
|
5. На остановке проверьте F11 и F10. Курсор должен переходить только после
|
||||||
@@ -153,6 +165,19 @@ Launcher монтирует временную копию CHD как `-hard2`,
|
|||||||
6. Завершите сессию кнопкой Stop. Затем повторите профиль с `osx`: вместе с
|
6. Завершите сессию кнопкой Stop. Затем повторите профиль с `osx`: вместе с
|
||||||
тем же VS Code-сеансом должно открыться штатное Cocoa-окно debugger MAME.
|
тем же 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` собирает приложение по
|
Команда палитры `Sprinter: Build Active Project` собирает приложение по
|
||||||
Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в
|
Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в
|
||||||
`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию,
|
`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию,
|
||||||
@@ -232,19 +257,25 @@ reset в native console обходят модель состояния VS Code;
|
|||||||
локального RPC и launcher; выбор `windows` решает только сторону MAME и не
|
локального RPC и launcher; выбор `windows` решает только сторону MAME и не
|
||||||
обеспечивает работу VS Code-интеграции.
|
обеспечивает работу VS Code-интеграции.
|
||||||
|
|
||||||
Backend `sdbg` входит как воспроизводимый patch к MAME 0.287. Исходники и
|
Backend `sdbg` уже входит в новый fork `MAME.HT` на базе MAME 0.289
|
||||||
рецепты теперь принадлежат самостоятельному проекту MAME:
|
(Holub/Tolik). Его штатная сборка и имя исполняемого файла:
|
||||||
|
|
||||||
```sh
|
```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 stock
|
||||||
scripts/sprinter/build-variants.sh sdbg
|
scripts/sprinter/build-variants.sh sdbg
|
||||||
```
|
```
|
||||||
|
|
||||||
В fork patch уже зафиксирован в Git; для чистого baseline сохранён
|
Результат `make` — `./sprinter`, при запуске система по-прежнему задаётся
|
||||||
`scripts/sprinter/apply-sdbg-patch.sh`. Каждый вариант копируется в
|
аргументом `sprinter`. Для проверки на имеющихся ROM/DSS можно оставить
|
||||||
`MAME_HOME/bin/stock/mame.arm` или `MAME_HOME/bin/sdbg/mame.arm` без подмены
|
старый `MAME_HOME` и указать `MAME_BIN=/путь/к/MAME.HT/sprinter`.
|
||||||
активного `MAME_HOME/mame.arm`.
|
В новом 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
|
## Logpoints
|
||||||
|
|
||||||
@@ -278,7 +309,8 @@ trampoline до первой C-позиции вызывающей функци
|
|||||||
|
|
||||||
## Ручной attach
|
## Ручной attach
|
||||||
|
|
||||||
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py`, затем:
|
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py` либо
|
||||||
|
автономный MCP `start_session` и дождитесь `session_status.phase=ready`, затем:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -290,7 +322,9 @@ trampoline до первой C-позиции вызывающей функци
|
|||||||
```
|
```
|
||||||
|
|
||||||
Команды server и диагностического CLI приведены в
|
Команды server и диагностического CLI приведены в
|
||||||
[mame-source-debug-status.md](mame-source-debug-status.md).
|
[mame-source-debug-status.md](mame-source-debug-status.md). Автономный
|
||||||
|
MCP-запуск описан в [sdbg-mcp.md](sdbg-mcp.md); DAP disconnect не завершает
|
||||||
|
MAME, созданный MCP, а `stop_session` завершает его вместе с socket.
|
||||||
|
|
||||||
## Ограничения MVP
|
## Ограничения MVP
|
||||||
|
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ include $(SPRINTER_ROOT)/app.mk
|
|||||||
|
|
||||||
`make floppy` creates an image under the application's `build/media/`.
|
`make floppy` creates an image under the application's `build/media/`.
|
||||||
For an emulator launch, set `MAME_HOME` to a prepared MAME runtime with
|
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`,
|
`sprinter` or `mame.arm`, `roms/` and `IMG/`; `MAME_BIN`, `MAME_ROMPATH`,
|
||||||
`MAME_SYSTEM_HDD_IMAGE` and `MAME_BIOS` can override each part.
|
`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:
|
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`
|
* **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.
|
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
|
* **Real Sprinter:** copy `.exe` to a floppy or HDD partition that DSS can
|
||||||
see, then `RUN HELLO` from the shell.
|
see, then `RUN HELLO` from the shell.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ include $(SPRINTER_ROOT)/app.mk
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make floppy` создаёт образ в локальном `build/media/` приложения. Для
|
`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_BIN`, `MAME_ROMPATH`, `MAME_DSS_IMAGE`, `MAME_SYSTEM_HDD_IMAGE` и
|
||||||
`MAME_BIOS` позволяют переопределить отдельные пути.
|
`MAME_BIOS` позволяют переопределить отдельные пути.
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ Sprinter / MAME / в любом ESTEX DSS шелле.
|
|||||||
они большие и имеют свои лицензии. Для тестирования:
|
они большие и имеют свои лицензии. Для тестирования:
|
||||||
|
|
||||||
* **MAME:** установите сборку с драйвером Sprinter и подготовьте `MAME_HOME`
|
* **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` монтирует его.
|
локальный диск SDK-тестов; `make run MAME_HOME=/путь/к/runtime` монтирует его.
|
||||||
* **Реальный Sprinter:** скопируйте `.exe` на флоп или раздел HDD, видимый
|
* **Реальный Sprinter:** скопируйте `.exe` на флоп или раздел HDD, видимый
|
||||||
для DSS, затем `RUN HELLO` в шелле.
|
для 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 завершился до авторского лога')
|
raise RuntimeError('DAP завершился до авторского лога')
|
||||||
if event.get('event') == 'output': outputs.append(event)
|
if event.get('event') == 'output': outputs.append(event)
|
||||||
socket = launched['body']['socket']
|
socket = launched['body']['socket']
|
||||||
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6)
|
identity = rpc_call(socket, 'status')
|
||||||
|
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6,
|
||||||
|
session_id=identity['session_id'],
|
||||||
|
build_id=identity['build_id'])
|
||||||
if not any('total=1' in line for line in tail['lines']):
|
if not any('total=1' in line for line in tail['lines']):
|
||||||
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
|
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
|
||||||
print(json.dumps({'event': 'macro_dual_console_verified',
|
print(json.dumps({'event': 'macro_dual_console_verified',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ os.environ['SPRINTER_PYTHON'] = sys.executable
|
|||||||
sys.path.insert(0,str(ROOT/'toolchain'))
|
sys.path.insert(0,str(ROOT/'toolchain'))
|
||||||
from sdbg.model import DebugMap
|
from sdbg.model import DebugMap
|
||||||
from sdbg.image import read_ihx
|
from sdbg.image import read_ihx
|
||||||
from sdbg.session import DebugSession
|
from sdbg.session import DebugSession, SessionError
|
||||||
from mame_interactive import build_events
|
from mame_interactive import build_events
|
||||||
from make_disk import create_floppy_image
|
from make_disk import create_floppy_image
|
||||||
from mame_profile import MameProfile, write_keyboard_config
|
from mame_profile import MameProfile, write_keyboard_config
|
||||||
@@ -42,6 +42,12 @@ def wait_dap_stop(engine, timeout=15):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
banked_mode = '--banked' in sys.argv
|
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=ROOT/'build/sdbg-live'
|
||||||
work.mkdir(parents=True,exist_ok=True)
|
work.mkdir(parents=True,exist_ok=True)
|
||||||
source=work/'probe.c'
|
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)
|
ev=',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events)
|
||||||
report=work/'result.jsonl'
|
report=work/'result.jsonl'
|
||||||
report.write_text('')
|
report.write_text('')
|
||||||
|
control=work/('control-'+uuid.uuid4().hex)
|
||||||
|
state_file=work/('state-'+uuid.uuid4().hex+'.sta')
|
||||||
lua=work/'probe.lua'
|
lua=work/'probe.lua'
|
||||||
lua.write_text('''if _G.sdbg_probe_loaded then return end
|
lua.write_text('''if _G.sdbg_probe_loaded then return end
|
||||||
_G.sdbg_probe_loaded=true
|
_G.sdbg_probe_loaded=true
|
||||||
@@ -180,14 +188,39 @@ local bp=nil
|
|||||||
local false_hits=0
|
local false_hits=0
|
||||||
local launch_reported=false
|
local launch_reported=false
|
||||||
local active=true
|
local active=true
|
||||||
|
local state_loaded=false
|
||||||
|
local state_load_reported=false
|
||||||
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
|
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
|
||||||
-- Первый notifier относится к начальному machine start. После arm это
|
-- Первый notifier относится к начальному machine start. После arm это
|
||||||
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
|
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
|
||||||
if phase~="booting" then active=false end
|
if phase~="booting" then active=false end
|
||||||
end)
|
end)
|
||||||
|
_G.sdbg_probe_load_subscription=emu.add_machine_post_load_notifier(function()
|
||||||
|
state_loaded=true
|
||||||
|
end)
|
||||||
debug.execution_state="run"
|
debug.execution_state="run"
|
||||||
emu.register_periodic(function()
|
emu.register_periodic(function()
|
||||||
if not active then return end
|
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
|
ticks=ticks+1
|
||||||
-- До окна запуска приложения DSS работает вообще без наших точек.
|
-- До окна запуска приложения DSS работает вообще без наших точек.
|
||||||
-- Первая клавиша launch sequence придёт через полсекунды после arm.
|
-- Первая клавиша launch sequence придёт через полсекунды после arm.
|
||||||
@@ -234,11 +267,13 @@ emu.register_periodic(function()
|
|||||||
if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end
|
if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end
|
||||||
end)
|
end)
|
||||||
'''.replace('REPORT',json.dumps(str(report))).replace('EVENTS',ev)
|
'''.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',
|
command=[str(profile.binary),'sprinter','-noreadconfig',
|
||||||
'-rompath',str(profile.rompath),'-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k',
|
'-rompath',str(profile.rompath),'-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k',
|
||||||
'-video','none','-sound','none','-nothrottle','-skip_gameinfo',
|
'-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),
|
'-flop2',str(profile.dss_image),
|
||||||
'-hard1',str(system),'-debug','-debugger','none',
|
'-hard1',str(system),'-debug','-debugger','none',
|
||||||
'-autoboot_delay','0','-autoboot_script',str(lua)]
|
'-autoboot_delay','0','-autoboot_script',str(lua)]
|
||||||
@@ -296,6 +331,56 @@ end)
|
|||||||
dap=DapEngine()
|
dap=DapEngine()
|
||||||
attached,_=dap.handle('attach',{'socket':str(socket_path)})
|
attached,_=dap.handle('attach',{'socket':str(socket_path)})
|
||||||
assert attached['buildId']==model.manifest['build_id']
|
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()
|
started=time.monotonic()
|
||||||
points,_=dap.handle('setFunctionBreakpoints',
|
points,_=dap.handle('setFunctionBreakpoints',
|
||||||
{'breakpoints':[{'name':'worker'}]})
|
{'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
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -18,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
PYTHON = Path.home()/'.pyenv/shims/python'
|
PYTHON = Path.home()/'.pyenv/shims/python'
|
||||||
sys.path.insert(0, str(ROOT/'toolchain'))
|
sys.path.insert(0, str(ROOT/'toolchain'))
|
||||||
from mame_interactive import resolve
|
from mame_interactive import resolve
|
||||||
from sdbg.server import rpc_call
|
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call as raw_rpc_call
|
||||||
|
from sdbg.session import SessionError
|
||||||
|
|
||||||
|
|
||||||
def send(process, sequence, command, arguments=None):
|
def send(process, sequence, command, arguments=None):
|
||||||
@@ -88,8 +90,26 @@ def wait_event(process, buffer, event, timeout):
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--build', default=None)
|
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('--app-hdd', default=None)
|
||||||
parser.add_argument('--launch-path', 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()
|
options, _ = parser.parse_known_args()
|
||||||
if not PYTHON.is_file():
|
if not PYTHON.is_file():
|
||||||
raise RuntimeError('Нет local pyenv shim: '+str(PYTHON))
|
raise RuntimeError('Нет local pyenv shim: '+str(PYTHON))
|
||||||
@@ -104,12 +124,22 @@ def main():
|
|||||||
_, buffer, _ = wait_response(process, buffer, 'initialize', 10)
|
_, buffer, _ = wait_response(process, buffer, 'initialize', 10)
|
||||||
debugger = 'osx' if '--osx' in sys.argv else 'sdbg'
|
debugger = 'osx' if '--osx' in sys.argv else 'sdbg'
|
||||||
launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30}
|
launch = {'build': str(build), 'debugger': debugger, 'dssTimeout': 30}
|
||||||
|
if options.socket:
|
||||||
|
launch['socket'] = options.socket
|
||||||
if options.app_hdd:
|
if options.app_hdd:
|
||||||
launch['appHdd'] = str(Path(options.app_hdd).resolve())
|
launch['appHdd'] = str(Path(options.app_hdd).resolve())
|
||||||
if options.launch_path:
|
if options.launch_path:
|
||||||
launch['launchPath'] = options.launch_path
|
launch['launchPath'] = options.launch_path
|
||||||
send(process, 2, 'launch', launch)
|
send(process, 2, 'launch', launch)
|
||||||
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
|
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
|
||||||
|
session_identity = raw_rpc_call(launched['body']['socket'], 'status')
|
||||||
|
def session_rpc(path, method, arguments=None, timeout=10):
|
||||||
|
identity = {'session_id': session_identity['session_id'],
|
||||||
|
'build_id': session_identity['build_id']}
|
||||||
|
if method in RPC_GENERATION_MUTATIONS:
|
||||||
|
current = raw_rpc_call(path, 'status', **identity)
|
||||||
|
identity['generation'] = current['generation']
|
||||||
|
return raw_rpc_call(path, method, arguments, timeout, **identity)
|
||||||
if not any(item.get('event') == 'initialized' for item in events):
|
if not any(item.get('event') == 'initialized' for item in events):
|
||||||
initialized, buffer = receive(process, buffer, 5)
|
initialized, buffer = receive(process, buffer, 5)
|
||||||
if initialized.get('event') != 'initialized':
|
if initialized.get('event') != 'initialized':
|
||||||
@@ -121,6 +151,130 @@ def main():
|
|||||||
frame = frame_response['body']['stackFrames'][0]
|
frame = frame_response['body']['stackFrames'][0]
|
||||||
if frame['name'] != 'main':
|
if frame['name'] != 'main':
|
||||||
raise RuntimeError('Не main: '+str(frame))
|
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:
|
if '--waitkey' in sys.argv:
|
||||||
send(process, 5, 'setBreakpoints', {
|
send(process, 5, 'setBreakpoints', {
|
||||||
'source': {'path': str(ROOT/'tests/hello/hello.c')},
|
'source': {'path': str(ROOT/'tests/hello/hello.c')},
|
||||||
@@ -139,23 +293,52 @@ def main():
|
|||||||
before = at_getchar['body']['stackFrames'][0]
|
before = at_getchar['body']['stackFrames'][0]
|
||||||
if before['line'] != 62:
|
if before['line'] != 62:
|
||||||
raise RuntimeError('Остановка не на getchar: '+str(before))
|
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()
|
started = time.monotonic()
|
||||||
send(process, 8, 'next')
|
send(process, 8, 'next')
|
||||||
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
||||||
response_ms = round((time.monotonic()-started)*1000, 2)
|
response_ms = round((time.monotonic()-started)*1000, 2)
|
||||||
|
if options.screen_mcp_python:
|
||||||
|
time.sleep(.6)
|
||||||
|
screen_command = [options.screen_mcp_python,
|
||||||
|
str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'),
|
||||||
|
'--socket', socket_path]
|
||||||
|
if options.screen_proof:
|
||||||
|
screen_command += ['--proof', options.screen_proof]
|
||||||
|
screen_probe = subprocess.run(screen_command, capture_output=True,
|
||||||
|
text=True, timeout=20, check=False)
|
||||||
|
if screen_probe.returncode:
|
||||||
|
raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:])
|
||||||
|
print(screen_probe.stdout.strip(), flush=True)
|
||||||
|
if clear_point:
|
||||||
|
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:
|
if '--emulated-key' in sys.argv:
|
||||||
socket_path = launched['body']['socket']
|
first = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||||
first = rpc_call(socket_path, 'snapshot', timeout=5)
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
second = rpc_call(socket_path, 'snapshot', timeout=5)
|
second = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||||
if second['paused'] or second['time'] <= first['time']:
|
if second['paused'] or second['time'] <= first['time']:
|
||||||
raise RuntimeError('MAME не обновляет input frames: '+
|
raise RuntimeError('MAME не обновляет input frames: '+
|
||||||
str((first, second)))
|
str((first, second)))
|
||||||
tag, mask, _ = resolve('x')
|
tag, mask, _ = resolve('x')
|
||||||
rpc_call(socket_path, 'input_key',
|
session_rpc(socket_path, 'input_key',
|
||||||
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
|
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
|
||||||
time.sleep(.15)
|
time.sleep(.15)
|
||||||
rpc_call(socket_path, 'input_key',
|
session_rpc(socket_path, 'input_key',
|
||||||
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
|
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
|
||||||
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
|
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
|
||||||
if not key_stop:
|
if not key_stop:
|
||||||
@@ -165,7 +348,7 @@ def main():
|
|||||||
after = after_response['body']['stackFrames'][0]
|
after = after_response['body']['stackFrames'][0]
|
||||||
if after['line'] != 63:
|
if after['line'] != 63:
|
||||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
||||||
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
|
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
|
||||||
ascii_code = ascii_code['registers']['DE'] & 0xff
|
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||||
if ascii_code != ord('x'):
|
if ascii_code != ord('x'):
|
||||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||||
@@ -173,14 +356,15 @@ def main():
|
|||||||
'at': before, 'after': after,
|
'at': before, 'after': after,
|
||||||
'ascii': ascii_code,
|
'ascii': ascii_code,
|
||||||
'emulated_time_advanced': second['time']-first['time'],
|
'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)
|
ensure_ascii=False), flush=True)
|
||||||
send(process, 10, 'disconnect')
|
send(process, 10, 'disconnect')
|
||||||
wait_response(process, buffer, 'disconnect', 10)
|
wait_response(process, buffer, 'disconnect', 10)
|
||||||
return 0
|
return 0
|
||||||
if '--manual-key' in sys.argv:
|
if '--manual-key' in sys.argv:
|
||||||
socket_path = launched['body']['socket']
|
socket_path = launched['body']['socket']
|
||||||
keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards']
|
keyboard_state = session_rpc(socket_path, 'snapshot', timeout=5)['keyboards']
|
||||||
if keyboard_state.get(':kbd:ms_naturl') is not True:
|
if keyboard_state.get(':kbd:ms_naturl') is not True:
|
||||||
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
|
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
|
||||||
str(keyboard_state))
|
str(keyboard_state))
|
||||||
@@ -197,7 +381,7 @@ def main():
|
|||||||
after = after_response['body']['stackFrames'][0]
|
after = after_response['body']['stackFrames'][0]
|
||||||
if after['line'] != 63:
|
if after['line'] != 63:
|
||||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
||||||
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
|
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
|
||||||
ascii_code = ascii_code['registers']['DE'] & 0xff
|
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||||
if ascii_code != ord('x'):
|
if ascii_code != ord('x'):
|
||||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ class DapTests(unittest.TestCase):
|
|||||||
self.assertIn('пропущено 2', events[0][1]['output'])
|
self.assertIn('пропущено 2', events[0][1]['output'])
|
||||||
self.assertEqual(events[1][1]['output'], 'total=42\n')
|
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):
|
def test_stdio_framing_without_attach(self):
|
||||||
requests = [
|
requests = [
|
||||||
{'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}},
|
{'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}},
|
||||||
|
|||||||
@@ -8,9 +8,25 @@ import xml.etree.ElementTree as ET
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / 'toolchain'))
|
sys.path.insert(0, str(ROOT / 'toolchain'))
|
||||||
from sdbg_launcher import disk_entries, lua_script, write_keyboard_config
|
from sdbg_launcher import disk_entries, lua_script, write_keyboard_config
|
||||||
|
from mame_profile import MameProfile
|
||||||
|
|
||||||
|
|
||||||
class LauncherTests(unittest.TestCase):
|
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):
|
def test_lua_tables_have_expected_shape(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / 'launch.lua'
|
path = Path(directory) / 'launch.lua'
|
||||||
@@ -44,5 +60,25 @@ class LauncherTests(unittest.TestCase):
|
|||||||
self.assertEqual(keyboards, {':': '1', ':kbd:ms_naturl': '1'})
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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 tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT / 'toolchain'))
|
sys.path.insert(0, str(ROOT / 'toolchain'))
|
||||||
@@ -18,11 +19,21 @@ class DummyModel:
|
|||||||
|
|
||||||
|
|
||||||
class DummyBridge:
|
class DummyBridge:
|
||||||
def __init__(self): self.calls = []
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.state = 'stopped'
|
||||||
|
self.generation = 1
|
||||||
def close(self): pass
|
def close(self): pass
|
||||||
def request(self, command, **arguments):
|
def request(self, command, **arguments):
|
||||||
self.calls.append((command, arguments))
|
self.calls.append((command, arguments))
|
||||||
|
if command == 'snapshot': return {'state': self.state}
|
||||||
if command == 'console_print': return {'printed': True}
|
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)
|
raise AssertionError(command)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,13 +48,24 @@ class DummySession:
|
|||||||
def attach(self):
|
def attach(self):
|
||||||
return {'build_id': 'test', 'location': {'status': 'mapped'}}
|
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):
|
def break_line(self, filename, line, enabled=True):
|
||||||
if line == 99:
|
if line == 99:
|
||||||
raise SessionError('нет адреса')
|
raise SessionError('нет адреса')
|
||||||
identifier = self.next_id
|
identifier = self.next_id
|
||||||
self.next_id += 1
|
self.next_id += 1
|
||||||
return {'id': identifier, 'backend_ids': [identifier],
|
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):
|
def clear_breakpoint(self, identifier):
|
||||||
self.cleared.append(identifier)
|
self.cleared.append(identifier)
|
||||||
@@ -89,13 +111,16 @@ class StepBridge:
|
|||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self.step_counts = []
|
||||||
|
|
||||||
def close(self): pass
|
def close(self): pass
|
||||||
|
|
||||||
def request(self, command, **arguments):
|
def request(self, command, **arguments):
|
||||||
self.calls.append(command)
|
self.calls.append(command)
|
||||||
if command in ('step', 'step_over', 'step_out'):
|
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)
|
len(self.owner.locations) - 1)
|
||||||
return {'accepted': True}
|
return {'accepted': True}
|
||||||
if command == 'snapshot':
|
if command == 'snapshot':
|
||||||
@@ -128,9 +153,10 @@ class StepSession:
|
|||||||
|
|
||||||
|
|
||||||
class WaitingBridge:
|
class WaitingBridge:
|
||||||
"""Машинный over ожидает внешний ввод, но pause должен остаться доступен."""
|
"""Машинный over/out ожидает ввод, но Pause должен остаться доступен."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.paused = False
|
self.paused = False
|
||||||
|
self.started = False
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def close(self): pass
|
def close(self): pass
|
||||||
@@ -138,11 +164,12 @@ class WaitingBridge:
|
|||||||
def request(self, command, **arguments):
|
def request(self, command, **arguments):
|
||||||
self.calls.append(command)
|
self.calls.append(command)
|
||||||
if command == 'snapshot':
|
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':
|
if command == 'pause':
|
||||||
self.paused = True
|
self.paused = True
|
||||||
return {'accepted': True}
|
return {'accepted': True}
|
||||||
if command == 'step_over':
|
if command in ('step_over', 'step_out'):
|
||||||
|
self.started = True
|
||||||
return {'accepted': True}
|
return {'accepted': True}
|
||||||
raise AssertionError(command)
|
raise AssertionError(command)
|
||||||
|
|
||||||
@@ -170,8 +197,26 @@ class ServerTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
status = rpc_call(server.path, 'status')
|
status = rpc_call(server.path, 'status')
|
||||||
self.assertEqual(status['build_id'], 'test')
|
self.assertEqual(status['build_id'], 'test')
|
||||||
events = rpc_call(server.path, 'events', {'after': 0})
|
identity = {'session_id': status['session_id'],
|
||||||
|
'build_id': status['build_id']}
|
||||||
|
events = rpc_call(server.path, 'events', {'after': 0}, **identity)
|
||||||
self.assertEqual(events['events'][0]['event'], 'stopped')
|
self.assertEqual(events['events'][0]['event'], 'stopped')
|
||||||
|
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||||
|
rpc_call(server.path, 'events', {'after': 0})
|
||||||
|
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||||
|
rpc_call(server.path, 'events', {'after': 0},
|
||||||
|
session_id='old', build_id=status['build_id'])
|
||||||
|
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
|
||||||
|
rpc_call(server.path, 'events', {'after': 0},
|
||||||
|
session_id=status['session_id'], build_id='old-build')
|
||||||
|
with self.assertRaisesRegex(SessionError, 'Устаревшая generation'):
|
||||||
|
rpc_call(server.path, 'break_line',
|
||||||
|
{'file': '/src/main.c', 'line': 3},
|
||||||
|
generation=0, **identity)
|
||||||
|
point = rpc_call(server.path, 'break_line',
|
||||||
|
{'file': '/src/main.c', 'line': 3},
|
||||||
|
generation=status['generation'], **identity)
|
||||||
|
self.assertEqual(point['id'], 1)
|
||||||
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
|
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
|
||||||
rpc_call(server.path, 'unknown')
|
rpc_call(server.path, 'unknown')
|
||||||
finally:
|
finally:
|
||||||
@@ -180,6 +225,165 @@ class ServerTests(unittest.TestCase):
|
|||||||
server.close()
|
server.close()
|
||||||
controller.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):
|
def test_source_breakpoints_replace_and_rollback(self):
|
||||||
session = DummySession()
|
session = DummySession()
|
||||||
controller = SessionController(session)
|
controller = SessionController(session)
|
||||||
@@ -281,6 +485,45 @@ class ServerTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
controller.close()
|
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):
|
def test_source_step_preserves_user_breakpoint_on_same_line(self):
|
||||||
session = StepSession()
|
session = StepSession()
|
||||||
controller = SessionController(session)
|
controller = SessionController(session)
|
||||||
@@ -312,6 +555,23 @@ class ServerTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
controller.close()
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -127,6 +127,25 @@ class SessionTests(unittest.TestCase):
|
|||||||
bridge.calls)
|
bridge.calls)
|
||||||
session.clear_breakpoint(result['id'])
|
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):
|
def test_stale_source_and_unmapped_bank_are_rejected(self):
|
||||||
session = DebugSession(self.model, FakeBridge(self.memory))
|
session = DebugSession(self.model, FakeBridge(self.memory))
|
||||||
session.attach()
|
session.attach()
|
||||||
|
|||||||
+50
-12
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -26,8 +28,15 @@ class MameProfile:
|
|||||||
def selected(key: str, suffix: str) -> Path | None:
|
def selected(key: str, suffix: str) -> Path | None:
|
||||||
return _path(values.get(key)) or (home / suffix if home else 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,
|
return cls(home=home,
|
||||||
binary=selected("MAME_BIN", "mame.arm"),
|
binary=binary,
|
||||||
rompath=selected("MAME_ROMPATH", "roms"),
|
rompath=selected("MAME_ROMPATH", "roms"),
|
||||||
dss_image=selected("MAME_DSS_IMAGE", "IMG/dss171u.img"),
|
dss_image=selected("MAME_DSS_IMAGE", "IMG/dss171u.img"),
|
||||||
system_hdd_image=selected("MAME_SYSTEM_HDD_IMAGE", "IMG/sp_hdd_sys.chd"),
|
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})
|
for name in names})
|
||||||
|
|
||||||
|
|
||||||
|
KEYBOARD_TAGS = (":", ":kbd:ms_naturl")
|
||||||
|
|
||||||
|
|
||||||
def write_keyboard_config(cfg_directory: Path) -> None:
|
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.mkdir(parents=True, exist_ok=True)
|
||||||
(cfg_directory / "sprinter.cfg").write_text(
|
path = cfg_directory / "sprinter.cfg"
|
||||||
'<?xml version="1.0"?>\n'
|
root = None
|
||||||
'<mameconfig version="10">\n'
|
if path.is_file():
|
||||||
' <system name="sprinter">\n'
|
try:
|
||||||
' <input>\n'
|
root = ET.parse(path).getroot()
|
||||||
' <keyboard tag=":" enabled="1" />\n'
|
except ET.ParseError as error:
|
||||||
' <keyboard tag=":kbd:ms_naturl" enabled="1" />\n'
|
print(f"mame_profile: {path} не разобран ({error}), создаётся заново",
|
||||||
' </input>\n'
|
file=sys.stderr)
|
||||||
' </system>\n'
|
if root is None or root.tag != "mameconfig":
|
||||||
'</mameconfig>\n', encoding="utf-8")
|
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 lfs = require("lfs")
|
||||||
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
|
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
|
||||||
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
|
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
|
||||||
|
local snapdir = os.getenv("SDBG_SNAP_DIR")
|
||||||
|
local snapshot_sequence = 0
|
||||||
local generation, sequence = 0, 0
|
local generation, sequence = 0, 0
|
||||||
local events, owned = {}, {}
|
local events, owned = {}, {}
|
||||||
local state, pending = "initializing", nil
|
local state, pending = "initializing", nil
|
||||||
@@ -53,6 +55,37 @@ function exports.startplugin()
|
|||||||
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
|
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
|
||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
local function first_screen()
|
||||||
|
for _,screen in pairs(machine().screens) do return screen end
|
||||||
|
error("Экран MAME не найден")
|
||||||
|
end
|
||||||
|
local function share_info()
|
||||||
|
local result={shares={},regions={}}
|
||||||
|
for tag,share in pairs(machine().memory.shares) do
|
||||||
|
result.shares[#result.shares+1]={tag=tag,size=share.size}
|
||||||
|
end
|
||||||
|
for tag,region in pairs(machine().memory.regions) do
|
||||||
|
result.regions[#result.regions+1]={tag=tag,size=region.size}
|
||||||
|
end
|
||||||
|
table.sort(result.shares,function(a,b) return a.tag<b.tag end)
|
||||||
|
table.sort(result.regions,function(a,b) return a.tag<b.tag end)
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
local function read_share(tag,address,length)
|
||||||
|
assert(type(tag)=="string" and #tag>0 and #tag<=128,"Нужен точный tag share")
|
||||||
|
local share=machine().memory.shares[tag]
|
||||||
|
assert(share,"Share не найден: "..tag)
|
||||||
|
local size=number(share.size,0x10000000)
|
||||||
|
address=number(address,size)
|
||||||
|
length=number(length,4096)
|
||||||
|
assert(length>=1 and address+length<=size,"Чтение за пределами share")
|
||||||
|
local bytes={}
|
||||||
|
for offset=0,length-1 do
|
||||||
|
bytes[#bytes+1]=string.format("%02x",share:read_u8(address+offset))
|
||||||
|
end
|
||||||
|
return {tag=tag,address=address,length=length,size=size,hex=table.concat(bytes),
|
||||||
|
generation=generation}
|
||||||
|
end
|
||||||
local invalidate
|
local invalidate
|
||||||
local function dispatch(request)
|
local function dispatch(request)
|
||||||
assert(request.session==session,"Чужая сессия")
|
assert(request.session==session,"Чужая сессия")
|
||||||
@@ -65,9 +98,13 @@ function exports.startplugin()
|
|||||||
session_started=true
|
session_started=true
|
||||||
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
|
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
|
||||||
instruction_step=true,step_over=true,step_out=true,
|
instruction_step=true,step_over=true,step_out=true,
|
||||||
bank_guard=true,deferred_breakpoints=true,console_print=true},state=state}
|
bank_guard=true,deferred_breakpoints=true,console_print=true,
|
||||||
|
shares=true,screen_pixels=true,screen_snapshot=snapdir~=nil,
|
||||||
|
program_memory=true,list_ports=true,disassemble_logical=true},state=state}
|
||||||
elseif command=="snapshot" then
|
elseif command=="snapshot" then
|
||||||
local result={state=state,time=now(),paused=machine().paused}
|
local result={state=state,time=now(),paused=machine().paused}
|
||||||
|
local screen=first_screen()
|
||||||
|
result.frame=screen:frame_number()
|
||||||
result.keyboards=setmetatable({}, {__jsontype="object"})
|
result.keyboards=setmetatable({}, {__jsontype="object"})
|
||||||
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
|
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
|
||||||
result.keyboards[tag]=kbd.enabled
|
result.keyboards[tag]=kbd.enabled
|
||||||
@@ -126,11 +163,16 @@ function exports.startplugin()
|
|||||||
state="running"
|
state="running"
|
||||||
event("running",{reason=command})
|
event("running",{reason=command})
|
||||||
if command=="step" then
|
if command=="step" then
|
||||||
|
local count=number(args.count or 1,64)
|
||||||
|
assert(count>=1,"step count должен быть 1..64")
|
||||||
pending={kind="step",time=now()}
|
pending={kind="step",time=now()}
|
||||||
cpu().debug:step(1)
|
cpu().debug:step(count)
|
||||||
elseif command=="step_over" then
|
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()}
|
pending={kind="step_over",time=now()}
|
||||||
machine().debugger:command("over 1")
|
-- В консоли MAME числа без префикса шестнадцатеричные.
|
||||||
|
machine().debugger:command("over #"..count)
|
||||||
elseif command=="step_out" then
|
elseif command=="step_out" then
|
||||||
pending={kind="step_out",time=now()}
|
pending={kind="step_out",time=now()}
|
||||||
machine().debugger:command("out")
|
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))
|
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true))
|
||||||
end
|
end
|
||||||
return {hex=table.concat(bytes)}
|
return {hex=table.concat(bytes)}
|
||||||
|
elseif command=="program_memory" then
|
||||||
|
stopped(request)
|
||||||
|
local address=number(args.address,0x3ffff)
|
||||||
|
local length=number(args.length,4096)
|
||||||
|
assert(length>=1 and address+length<=0x40000,
|
||||||
|
"Чтение за пределами raw program space")
|
||||||
|
local symbols=emu.symbol_table(cpu())
|
||||||
|
local bytes={}
|
||||||
|
for offset=0,length-1 do
|
||||||
|
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(
|
||||||
|
":maincpu","p",address+offset,1,true))
|
||||||
|
end
|
||||||
|
return {space="program",address=address,length=length,
|
||||||
|
hex=table.concat(bytes),generation=generation}
|
||||||
|
elseif command=="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
|
elseif command=="console_print" then
|
||||||
stopped(request)
|
stopped(request)
|
||||||
local text=args.text
|
local text=args.text
|
||||||
@@ -192,7 +320,8 @@ function exports.startplugin()
|
|||||||
end
|
end
|
||||||
return {disabled=count}
|
return {disabled=count}
|
||||||
elseif command=="clear" then
|
elseif command=="clear" then
|
||||||
stopped(request)
|
-- Удаление точки безопасно и при running: callback исполняется на
|
||||||
|
-- потоке MAME, а MCP должен очищать свои точки при закрытии stdio.
|
||||||
local id=number(args.id,0x7fffffff)
|
local id=number(args.id,0x7fffffff)
|
||||||
assert(owned[id],"Точка не принадлежит sdbg")
|
assert(owned[id],"Точка не принадлежит sdbg")
|
||||||
cpu().debug:bpclear(id);owned[id]=nil
|
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"
|
. "$mame_root/.codex/mame.local.env"
|
||||||
fi
|
fi
|
||||||
: "${MAME_UV:=uv}"
|
: "${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"
|
cd "$mame_root"
|
||||||
if ! command -v "$MAME_UV" >/dev/null 2>&1; then
|
if ! command -v "$MAME_UV" >/dev/null 2>&1; then
|
||||||
echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2
|
echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -43,13 +44,21 @@ def main() -> int:
|
|||||||
state = Path(directory)
|
state = Path(directory)
|
||||||
for name in ("cfg", "nvram", "diff", "snapshot"):
|
for name in ("cfg", "nvram", "diff", "snapshot"):
|
||||||
(state / name).mkdir()
|
(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 = Path(args.snapshot_dir).resolve() if args.snapshot_dir else state / "snapshot"
|
||||||
snapshots.mkdir(parents=True, exist_ok=True)
|
snapshots.mkdir(parents=True, exist_ok=True)
|
||||||
write_keyboard_config(state / "cfg")
|
write_keyboard_config(state / "cfg")
|
||||||
command = [str(profile.binary), "sprinter", "-noreadconfig",
|
command = [str(profile.binary), "sprinter", "-noreadconfig",
|
||||||
"-rompath", str(profile.rompath), "-bios", profile.bios,
|
"-rompath", str(profile.rompath), "-bios", profile.bios,
|
||||||
"-kbd", "ms_naturl,bios=sp2k", "-video", args.video,
|
"-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),
|
"-flop2", str(profile.dss_image),
|
||||||
"-hard1", str(profile.system_hdd_image)]
|
"-hard1", str(profile.system_hdd_image)]
|
||||||
if "floppy" in media:
|
if "floppy" in media:
|
||||||
|
|||||||
+18
-3
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .server import rpc_call
|
from .server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||||
from .session import SessionError
|
from .session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +19,14 @@ class DapEngine:
|
|||||||
return self._rpc_override(method, arguments or {})
|
return self._rpc_override(method, arguments or {})
|
||||||
if not self.rpc_path:
|
if not self.rpc_path:
|
||||||
raise SessionError('DAP ещё не подключён к session server')
|
raise SessionError('DAP ещё не подключён к session server')
|
||||||
return rpc_call(self.rpc_path, method, arguments, timeout)
|
identity = {}
|
||||||
|
if self.attached is not None:
|
||||||
|
identity = {'session_id': self.attached['session_id'],
|
||||||
|
'build_id': self.attached['build_id']}
|
||||||
|
if method in RPC_GENERATION_MUTATIONS:
|
||||||
|
current = rpc_call(self.rpc_path, 'status', **identity)
|
||||||
|
identity['generation'] = current['generation']
|
||||||
|
return rpc_call(self.rpc_path, method, arguments, timeout, **identity)
|
||||||
|
|
||||||
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
|
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
|
||||||
events = []
|
events = []
|
||||||
@@ -54,6 +61,11 @@ class DapEngine:
|
|||||||
'description': self._description(location)}))
|
'description': self._description(location)}))
|
||||||
return {}, events
|
return {}, events
|
||||||
if command == 'disconnect':
|
if command == 'disconnect':
|
||||||
|
if self.attached is not None:
|
||||||
|
try:
|
||||||
|
self.rpc('release_control')
|
||||||
|
except (SessionError, OSError):
|
||||||
|
pass
|
||||||
return {}, events
|
return {}, events
|
||||||
if command == 'threads':
|
if command == 'threads':
|
||||||
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
|
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
|
||||||
@@ -118,7 +130,8 @@ class DapEngine:
|
|||||||
|
|
||||||
def poll_events(self, timeout=1):
|
def poll_events(self, timeout=1):
|
||||||
previous = self.event_sequence
|
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)
|
timeout=timeout + 2)
|
||||||
self.event_sequence = result['last']
|
self.event_sequence = result['last']
|
||||||
translated = []
|
translated = []
|
||||||
@@ -140,6 +153,8 @@ class DapEngine:
|
|||||||
elif event['event'] == 'output':
|
elif event['event'] == 'output':
|
||||||
translated.append(('output', {'category': event['body'].get('category','console'),
|
translated.append(('output', {'category': event['body'].get('category','console'),
|
||||||
'output': event['body']['output']}))
|
'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']
|
return translated, result['closed']
|
||||||
|
|
||||||
@staticmethod
|
@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})
|
||||||
+319
-23
@@ -9,6 +9,7 @@ import socketserver
|
|||||||
import string
|
import string
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from .session import DebugSession, SessionError
|
from .session import DebugSession, SessionError
|
||||||
from .transport import BridgeError
|
from .transport import BridgeError
|
||||||
@@ -16,6 +17,8 @@ from .macros import validate_log_message
|
|||||||
|
|
||||||
|
|
||||||
class SessionController:
|
class SessionController:
|
||||||
|
CONTROL_TTL = 30.0
|
||||||
|
|
||||||
def __init__(self, session: DebugSession):
|
def __init__(self, session: DebugSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
@@ -26,6 +29,11 @@ class SessionController:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.source_step = None
|
self.source_step = None
|
||||||
self.closed = False
|
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.source_breakpoints: dict[str, list[int]] = {}
|
||||||
self.function_breakpoints: list[int] = []
|
self.function_breakpoints: list[int] = []
|
||||||
self.breakpoint_info: dict[int, dict] = {}
|
self.breakpoint_info: dict[int, dict] = {}
|
||||||
@@ -53,6 +61,7 @@ class SessionController:
|
|||||||
'kind': 'log', 'message': macro['message'],
|
'kind': 'log', 'message': macro['message'],
|
||||||
'condition': macro['condition'], 'module': macro['module'],
|
'condition': macro['condition'], 'module': macro['module'],
|
||||||
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
|
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
|
||||||
|
'owner': 'build', 'guard_conditions': item['conditions'],
|
||||||
}
|
}
|
||||||
if created:
|
if created:
|
||||||
self.session.activate_breakpoints()
|
self.session.activate_breakpoints()
|
||||||
@@ -72,28 +81,40 @@ class SessionController:
|
|||||||
self.changed.notify_all()
|
self.changed.notify_all()
|
||||||
|
|
||||||
def _poll(self) -> None:
|
def _poll(self) -> None:
|
||||||
|
next_idle_snapshot = 0.0
|
||||||
while not self.closed:
|
while not self.closed:
|
||||||
if not self.running:
|
if not self.running:
|
||||||
time.sleep(.02)
|
delay = next_idle_snapshot - time.monotonic()
|
||||||
continue
|
if delay > 0:
|
||||||
|
time.sleep(min(delay, .05))
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
snapshot = self.session.bridge.request('snapshot')
|
snapshot = self.session.bridge.request('snapshot')
|
||||||
if snapshot['state'] == 'stopped':
|
if snapshot['state'] == 'stopped':
|
||||||
location = self.session.where(snapshot)
|
if self.running:
|
||||||
if self.source_step is not None:
|
self._release_all_inputs()
|
||||||
self._source_step_stopped(location)
|
location = self.session.where(snapshot)
|
||||||
elif self._handle_logpoints(location):
|
if self.source_step is not None:
|
||||||
self.session.bridge.request('continue')
|
self._source_step_stopped(location)
|
||||||
continue
|
elif self._handle_logpoints(location):
|
||||||
else:
|
self.session.bridge.request('continue')
|
||||||
self.running = False
|
continue
|
||||||
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
|
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':
|
elif snapshot['state'] == 'invalidated':
|
||||||
self.running = False
|
self.running = False
|
||||||
self.source_step = None
|
self.source_step = None
|
||||||
self.closed = True
|
self.closed = True
|
||||||
self._emit('invalidated', {'reason': 'reset_or_load'})
|
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:
|
except (BridgeError, SessionError, ValueError, OSError) as error:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.source_step = None
|
self.source_step = None
|
||||||
@@ -104,10 +125,73 @@ class SessionController:
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.closed = True
|
self.closed = True
|
||||||
self.poller.join(timeout=1)
|
self.poller.join(timeout=1)
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self._release_all_inputs()
|
||||||
|
except (BridgeError, SessionError, OSError):
|
||||||
|
pass
|
||||||
self.session.bridge.close()
|
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:
|
def call(self, method: str, arguments: dict) -> object:
|
||||||
if method == 'events':
|
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))
|
after = int(arguments.get('after', 0))
|
||||||
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
|
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
@@ -121,36 +205,181 @@ class SessionController:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
if self.closed:
|
if self.closed:
|
||||||
raise SessionError('Сессия закрыта или инвалидирована')
|
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':
|
if method == 'status':
|
||||||
return {**self.attached, 'running': self.running,
|
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':
|
if method == 'snapshot':
|
||||||
return self.session.bridge.request('snapshot')
|
return self.session.bridge.request('snapshot')
|
||||||
if method == 'mame_console_tail':
|
if method == 'mame_console_tail':
|
||||||
return self.session.bridge.request(
|
return self.session.bridge.request(
|
||||||
'console_tail', count=int(arguments.get('count', 40)))
|
'console_tail', count=int(arguments.get('count', 40)))
|
||||||
|
if method == 'list_ports':
|
||||||
|
return self.session.bridge.request('list_ports')
|
||||||
|
if method == 'list_shares':
|
||||||
|
return self.session.bridge.request('list_shares')
|
||||||
|
if method in ('read_share', 'read_vram'):
|
||||||
|
address = int(arguments['address'])
|
||||||
|
length = int(arguments['length'])
|
||||||
|
if address < 0 or address > 0x10000000 or length < 1 or length > 4096:
|
||||||
|
raise SessionError('Share: адрес 0..0x10000000, длина 1..4096')
|
||||||
|
if method == 'read_share':
|
||||||
|
tag = arguments['tag']
|
||||||
|
if not isinstance(tag, str) or not tag or len(tag) > 128:
|
||||||
|
raise SessionError('Нужен точный tag share до 128 символов')
|
||||||
|
return self.session.bridge.request('read_share', tag=tag,
|
||||||
|
address=address, length=length)
|
||||||
|
return self.session.bridge.request('read_vram', address=address,
|
||||||
|
length=length)
|
||||||
|
if method == 'read_screen_pixels':
|
||||||
|
x, y = int(arguments['x']), int(arguments['y'])
|
||||||
|
width, height = int(arguments['width']), int(arguments['height'])
|
||||||
|
if x < 0 or y < 0 or x > 4095 or y > 4095 or \
|
||||||
|
width < 1 or height < 1 or width > 512 or height > 512 or \
|
||||||
|
width * height > 8192:
|
||||||
|
raise SessionError('Экран: координаты 0..4095, размер 1..512, максимум 8192 пикселей')
|
||||||
|
return self.session.bridge.request('screen_pixels', x=x, y=y,
|
||||||
|
width=width, height=height)
|
||||||
|
if method == 'screenshot':
|
||||||
|
result = self.session.bridge.request('screen_snapshot')
|
||||||
|
root = (Path(self.session.bridge.directory).parent / 'snapshot').resolve()
|
||||||
|
path = Path(result['path']).resolve()
|
||||||
|
if path.parent != root or path.suffix.lower() != '.png' or \
|
||||||
|
not path.is_file() or path.stat().st_size != result['size'] or \
|
||||||
|
result['size'] > 8 * 1024 * 1024:
|
||||||
|
raise SessionError('Снимок MAME вне каталога сессии или повреждён')
|
||||||
|
return result
|
||||||
if method == 'input_key':
|
if method == 'input_key':
|
||||||
if not self.running and arguments['down'] is not False:
|
if not self.running and arguments['down'] is not False:
|
||||||
raise SessionError('Нажатие возможно только при running CPU')
|
raise SessionError('Нажатие возможно только при running CPU')
|
||||||
return self.session.bridge.request(
|
self._control(arguments)
|
||||||
'key', tag=arguments['tag'], mask=int(arguments['mask']),
|
tag, mask = arguments['tag'], int(arguments['mask'])
|
||||||
down=arguments['down'])
|
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':
|
if method == 'where':
|
||||||
return self.session.where()
|
return self.session.where()
|
||||||
if method == 'registers':
|
if method == 'registers':
|
||||||
mapping = self.session.refresh()
|
mapping = self.session.refresh()
|
||||||
return {'generation': mapping.generation, 'registers': mapping.registers,
|
return {'generation': mapping.generation, 'registers': mapping.registers,
|
||||||
'bank_pages': mapping.bank_pages}
|
'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':
|
if method == 'variables':
|
||||||
return self.session.model.variables
|
return self.session.model.variables
|
||||||
if method == 'read_variable':
|
if method == 'read_variable':
|
||||||
return self.session.read_variable(arguments['name'], arguments.get('module'))
|
return self.session.read_variable(arguments['name'], arguments.get('module'))
|
||||||
if method == 'break_line':
|
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':
|
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':
|
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':
|
if method == 'set_source_breakpoints':
|
||||||
specifications = arguments.get('breakpoints')
|
specifications = arguments.get('breakpoints')
|
||||||
if specifications is None:
|
if specifications is None:
|
||||||
@@ -161,21 +390,43 @@ class SessionController:
|
|||||||
if method == 'continue':
|
if method == 'continue':
|
||||||
if self.running:
|
if self.running:
|
||||||
raise SessionError('CPU уже выполняется; сначала Pause')
|
raise SessionError('CPU уже выполняется; сначала Pause')
|
||||||
|
self._control(arguments)
|
||||||
result = self.session.bridge.request('continue')
|
result = self.session.bridge.request('continue')
|
||||||
self.running = True
|
self.running = True
|
||||||
self._emit('continued', {})
|
self._emit('continued', {})
|
||||||
return result
|
return result
|
||||||
if method == 'pause':
|
if method == 'pause':
|
||||||
|
self._control(arguments)
|
||||||
self.source_step = None
|
self.source_step = None
|
||||||
self.session.bridge.request('pause')
|
self.session.bridge.request('pause')
|
||||||
location = self.session.where(self.session.bridge.wait_stopped())
|
location = self.session.where(self.session.bridge.wait_stopped())
|
||||||
self.running = False
|
self.running = False
|
||||||
self._emit('stopped', {'reason': 'pause', 'location': location})
|
self._emit('stopped', {'reason': 'pause', 'location': location})
|
||||||
return 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 method == 'step':
|
||||||
if self.running:
|
if self.running:
|
||||||
raise SessionError('CPU уже выполняется; сначала Pause')
|
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.running = True
|
||||||
self._emit('continued', {'reason': 'step'})
|
self._emit('continued', {'reason': 'step'})
|
||||||
location = self.session.where(self.session.bridge.wait_stopped())
|
location = self.session.where(self.session.bridge.wait_stopped())
|
||||||
@@ -183,6 +434,7 @@ class SessionController:
|
|||||||
self._emit('stopped', {'reason': 'step', 'location': location})
|
self._emit('stopped', {'reason': 'step', 'location': location})
|
||||||
return location
|
return location
|
||||||
if method == 'source_step':
|
if method == 'source_step':
|
||||||
|
self._control(arguments)
|
||||||
return self._source_step(arguments.get('kind', 'into'))
|
return self._source_step(arguments.get('kind', 'into'))
|
||||||
raise SessionError('Неизвестный RPC-метод: ' + method)
|
raise SessionError('Неизвестный RPC-метод: ' + method)
|
||||||
|
|
||||||
@@ -276,6 +528,7 @@ class SessionController:
|
|||||||
self.breakpoint_info[item['id']] = {
|
self.breakpoint_info[item['id']] = {
|
||||||
'kind': kind, 'message': specification['logMessage'],
|
'kind': kind, 'message': specification['logMessage'],
|
||||||
'locations': item['locations'], 'hits': 0,
|
'locations': item['locations'], 'hits': 0,
|
||||||
|
'owner': 'dap', 'guard_conditions': item['conditions'],
|
||||||
}
|
}
|
||||||
results.append({'line': line, 'verified': True,
|
results.append({'line': line, 'verified': True,
|
||||||
'logMessage': specification['logMessage'], **item})
|
'logMessage': specification['logMessage'], **item})
|
||||||
@@ -309,7 +562,9 @@ class SessionController:
|
|||||||
item = self.session.break_function(name, enabled=False)
|
item = self.session.break_function(name, enabled=False)
|
||||||
created.append(item['id'])
|
created.append(item['id'])
|
||||||
self.breakpoint_info[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})
|
results.append({'name': name, 'verified': True, **item})
|
||||||
except BaseException:
|
except BaseException:
|
||||||
for identifier in created:
|
for identifier in created:
|
||||||
@@ -400,6 +655,18 @@ class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamSe
|
|||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
RPC_MUTATIONS = frozenset({
|
||||||
|
'claim_control', 'renew_control', 'release_control', 'input_key',
|
||||||
|
'break_line', 'break_function', 'clear_breakpoint', 'clear_owned_breakpoints',
|
||||||
|
'set_source_breakpoints', 'set_function_breakpoints',
|
||||||
|
'continue', 'pause', 'step', 'step_over_instruction',
|
||||||
|
'step_out_instruction', 'source_step',
|
||||||
|
})
|
||||||
|
RPC_GENERATION_MUTATIONS = RPC_MUTATIONS - {
|
||||||
|
'claim_control', 'renew_control', 'release_control',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SessionRpcServer:
|
class SessionRpcServer:
|
||||||
def __init__(self, path, controller: SessionController):
|
def __init__(self, path, controller: SessionController):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
@@ -428,8 +695,31 @@ class SessionRpcServer:
|
|||||||
arguments = request.get('arguments', {})
|
arguments = request.get('arguments', {})
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise ValueError('RPC arguments должен быть объектом')
|
raise ValueError('RPC arguments должен быть объектом')
|
||||||
|
method = request['method']
|
||||||
|
with controller_ref.lock:
|
||||||
|
if method != 'unknown' and (method != 'status' or
|
||||||
|
request.get('session_id') is not None or
|
||||||
|
request.get('build_id') is not None):
|
||||||
|
if request.get('session_id') != controller_ref.session_id or \
|
||||||
|
request.get('build_id') != controller_ref.attached['build_id']:
|
||||||
|
raise SessionError('Устаревшая RPC-сессия или build ID; переподключитесь')
|
||||||
|
if method in RPC_GENERATION_MUTATIONS and \
|
||||||
|
request.get('generation') != \
|
||||||
|
getattr(controller_ref.session.bridge, 'generation', None):
|
||||||
|
raise SessionError('Устаревшая generation; обновите статус сессии')
|
||||||
|
if method != 'events':
|
||||||
|
result = controller_ref.call(method, arguments)
|
||||||
|
result_generation = getattr(controller_ref.session.bridge,
|
||||||
|
'generation', None)
|
||||||
|
if method == 'events':
|
||||||
|
result = controller_ref.call(method, arguments)
|
||||||
|
result_generation = getattr(controller_ref.session.bridge,
|
||||||
|
'generation', None)
|
||||||
response = {'id': request.get('id'), 'ok': True,
|
response = {'id': request.get('id'), 'ok': True,
|
||||||
'result': controller_ref.call(request['method'], arguments)}
|
'session_id': controller_ref.session_id,
|
||||||
|
'build_id': controller_ref.attached['build_id'],
|
||||||
|
'generation': result_generation,
|
||||||
|
'result': result}
|
||||||
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
|
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
|
||||||
response.update(error=str(error))
|
response.update(error=str(error))
|
||||||
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
|
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
|
||||||
@@ -448,8 +738,11 @@ class SessionRpcServer:
|
|||||||
self.path.unlink(missing_ok=True)
|
self.path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def rpc_call(path, method: str, arguments=None, timeout=10):
|
def rpc_call(path, method: str, arguments=None, timeout=10, *,
|
||||||
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
|
session_id=None, build_id=None, generation=None):
|
||||||
|
request = {'id': 1, 'method': method, 'arguments': arguments or {},
|
||||||
|
'session_id': session_id, 'build_id': build_id,
|
||||||
|
'generation': generation}
|
||||||
with socket.socket(socket.AF_UNIX) as client:
|
with socket.socket(socket.AF_UNIX) as client:
|
||||||
client.settimeout(timeout)
|
client.settimeout(timeout)
|
||||||
client.connect(str(path))
|
client.connect(str(path))
|
||||||
@@ -461,4 +754,7 @@ def rpc_call(path, method: str, arguments=None, timeout=10):
|
|||||||
response = json.loads(raw)
|
response = json.loads(raw)
|
||||||
if not response.get('ok'):
|
if not response.get('ok'):
|
||||||
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
|
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
|
||||||
|
if method != 'status' and (response.get('session_id') != session_id or
|
||||||
|
response.get('build_id') != build_id):
|
||||||
|
raise SessionError('RPC-ответ от другой сессии или сборки')
|
||||||
return response['result']
|
return response['result']
|
||||||
|
|||||||
@@ -246,12 +246,15 @@ class DebugSession:
|
|||||||
return self.bridge.request('deactivate_breakpoints')
|
return self.bridge.request('deactivate_breakpoints')
|
||||||
|
|
||||||
def clear_breakpoint(self, identifier: int) -> dict:
|
def clear_breakpoint(self, identifier: int) -> dict:
|
||||||
ids = self.breakpoints.pop(identifier, None)
|
ids = self.breakpoints.get(identifier)
|
||||||
if ids is None:
|
if ids is None:
|
||||||
raise SessionError('Неизвестная логическая точка остановки')
|
raise SessionError('Неизвестная логическая точка остановки')
|
||||||
for backend_id in ids:
|
backend_ids = list(ids)
|
||||||
|
for backend_id in backend_ids:
|
||||||
self.bridge.request('clear', id=backend_id)
|
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:
|
def read_variable(self, name: str, module: str | None = None) -> dict:
|
||||||
matches = [variable for variable in self.model.variables
|
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 json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from sdbg.server import rpc_call
|
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||||
from sdbg.session import SessionError
|
from sdbg.session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +19,16 @@ def main():
|
|||||||
arguments = json.loads(args.arguments)
|
arguments = json.loads(args.arguments)
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise ValueError('arguments должен быть JSON-объектом')
|
raise ValueError('arguments должен быть JSON-объектом')
|
||||||
|
identity = {}
|
||||||
|
if args.method != 'status':
|
||||||
|
status = rpc_call(args.socket, 'status')
|
||||||
|
identity = {'session_id': status['session_id'],
|
||||||
|
'build_id': status['build_id']}
|
||||||
|
if args.method in RPC_GENERATION_MUTATIONS:
|
||||||
|
identity['generation'] = status['generation']
|
||||||
result = rpc_call(args.socket, args.method, arguments,
|
result = rpc_call(args.socket, args.method, arguments,
|
||||||
timeout=35 if args.method == 'events' else 10)
|
timeout=35 if args.method == 'events' else 10,
|
||||||
|
**identity)
|
||||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
except (SessionError, ValueError, OSError) as error:
|
except (SessionError, ValueError, OSError) as error:
|
||||||
|
|||||||
@@ -221,7 +221,12 @@ def main():
|
|||||||
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
|
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
|
||||||
args.dss_timeout)
|
args.dss_timeout)
|
||||||
session_id=uuid.uuid4().hex
|
session_id=uuid.uuid4().hex
|
||||||
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id)
|
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id,
|
||||||
|
SDBG_SNAP_DIR=str(state/'snapshot'))
|
||||||
|
# SDL3 превращает SIGTERM в SDL_EVENT_QUIT, который MAME SDL3 OSD
|
||||||
|
# не обрабатывает. Оставляем стандартное действие сигнала, чтобы
|
||||||
|
# DAP Stop не ждал принудительного kill остановленного MAME.
|
||||||
|
environment.setdefault('SDL_NO_SIGNAL_HANDLERS','1')
|
||||||
plugin_paths=[str(ROOT/'toolchain/mcp')]
|
plugin_paths=[str(ROOT/'toolchain/mcp')]
|
||||||
installed_plugins=profile.home.parent/'plugins' if profile.home else None
|
installed_plugins=profile.home.parent/'plugins' if profile.home else None
|
||||||
if installed_plugins and installed_plugins.is_dir():
|
if installed_plugins and installed_plugins.is_dir():
|
||||||
@@ -247,6 +252,7 @@ def main():
|
|||||||
mame_process=subprocess.Popen(command,cwd=state,env=environment,
|
mame_process=subprocess.Popen(command,cwd=state,env=environment,
|
||||||
stdout=log,stderr=subprocess.STDOUT)
|
stdout=log,stderr=subprocess.STDOUT)
|
||||||
server=None
|
server=None
|
||||||
|
socket_identity=None
|
||||||
def mame_diagnostics():
|
def mame_diagnostics():
|
||||||
log.flush()
|
log.flush()
|
||||||
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
|
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
|
||||||
@@ -257,8 +263,13 @@ def main():
|
|||||||
if mame_process.poll() is not None:
|
if mame_process.poll() is not None:
|
||||||
raise RuntimeError('MAME завершился до main; лог: '+
|
raise RuntimeError('MAME завершился до main; лог: '+
|
||||||
str(state/'mame.log')+mame_diagnostics())
|
str(state/'mame.log')+mame_diagnostics())
|
||||||
if (state/'main.json.error').exists() or time.monotonic()>deadline:
|
error_file=state/'main.json.error'
|
||||||
raise RuntimeError('Таймаут ожидания main; лог: '+
|
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())
|
str(state/'mame.log')+mame_diagnostics())
|
||||||
time.sleep(.05)
|
time.sleep(.05)
|
||||||
entry=json.loads(ready.read_text())
|
entry=json.loads(ready.read_text())
|
||||||
@@ -269,6 +280,8 @@ def main():
|
|||||||
if not line:
|
if not line:
|
||||||
raise RuntimeError('Session server не запустился'+mame_diagnostics())
|
raise RuntimeError('Session server не запустился'+mame_diagnostics())
|
||||||
server_ready=json.loads(line)
|
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,
|
print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid,
|
||||||
'entry':entry,'build_id':server_ready['build_id']},
|
'entry':entry,'build_id':server_ready['build_id']},
|
||||||
ensure_ascii=False),flush=True)
|
ensure_ascii=False),flush=True)
|
||||||
@@ -280,6 +293,14 @@ def main():
|
|||||||
server.terminate()
|
server.terminate()
|
||||||
try: server.wait(timeout=3)
|
try: server.wait(timeout=3)
|
||||||
except subprocess.TimeoutExpired: server.kill();server.wait()
|
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:
|
if mame_process.poll() is None:
|
||||||
mame_process.terminate()
|
mame_process.terminate()
|
||||||
try: mame_process.wait(timeout=5)
|
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.
|
docs/size_baseline.tsv.
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
python3 toolchain/size_check.py # сверка с эталоном
|
make size-check # собрать обязательные тесты и сверить с эталоном
|
||||||
python3 toolchain/size_check.py --update # перезаписать эталон
|
make size-baseline # принять свежие размеры, сохранив необязательные
|
||||||
|
|
||||||
Логика: собираем _CODE из .map каждой программы под tests/, сравниваем с
|
Логика: собираем _CODE из свежей .map каждой программы под tests/, сравниваем с
|
||||||
эталоном. Любое отличие печатается; выход 1 — если есть РОСТ (случайное
|
эталоном. Карты, оставшиеся от другой цели или более старые, чем входы
|
||||||
|
сборки, пропускаются. Любое отличие печатается; выход 1 — если есть РОСТ (случайное
|
||||||
разжирение libc), 0 — если только уменьшения/без изменений. Новые
|
разжирение libc), 0 — если только уменьшения/без изменений. Новые
|
||||||
программы (нет в эталоне) — предупреждение, добавить через --update.
|
программы (нет в эталоне) — предупреждение, добавить через --update.
|
||||||
Запускать после `make all`.
|
--update заменяет размеры собранных тестов, сохраняя эталоны необязательных
|
||||||
|
тестов, которые сейчас не собирались.
|
||||||
|
Прямой запуск скрипта рассчитан на уже собранные тесты; Make-цели сами
|
||||||
|
собирают обязательный набор и передают его через --require.
|
||||||
|
|
||||||
ТОЛЬКО tests/, без отдельного репозитория Examples: регресс ловит разжирение libc, а для этого
|
ТОЛЬКО tests/, без отдельного репозитория Examples: регресс ловит разжирение libc, а для этого
|
||||||
хватает мелких тестов — каждый тянет свой кусок библиотеки и пересобирается
|
хватает мелких тестов — каждый тянет свой кусок библиотеки и пересобирается
|
||||||
@@ -20,18 +24,20 @@ docs/size_baseline.tsv.
|
|||||||
`make all` они убраны (см. корневой Makefile).
|
`make all` они убраны (см. корневой Makefile).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
BASELINE = os.path.join(ROOT, "docs", "size_baseline.tsv")
|
BASELINE = os.path.join(ROOT, "docs", "size_baseline.tsv")
|
||||||
|
|
||||||
|
|
||||||
def collect():
|
def collect():
|
||||||
"""Имя программы → размер _CODE (байт) по свежим .map."""
|
"""Имя программы → размер _CODE (байт) по свежим .map своей цели."""
|
||||||
sizes = {}
|
sizes = {}
|
||||||
|
skipped = set()
|
||||||
pats = [
|
pats = [
|
||||||
os.path.join(ROOT, "tests", "*", ".sprinter-cc-*", "*.map"),
|
os.path.join(ROOT, "tests", "*", ".sprinter-cc-*", "*.map"),
|
||||||
]
|
]
|
||||||
@@ -41,13 +47,43 @@ def collect():
|
|||||||
# .map должен лежать в своей папке .sprinter-cc-<name>
|
# .map должен лежать в своей папке .sprinter-cc-<name>
|
||||||
if os.path.basename(os.path.dirname(map_path)) != ".sprinter-cc-" + name:
|
if os.path.basename(os.path.dirname(map_path)) != ".sprinter-cc-" + name:
|
||||||
continue
|
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:
|
with open(map_path, encoding="utf-8", errors="replace") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
m = re.match(r"^_CODE\s+([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+)", line)
|
m = re.match(r"^_CODE\s+([0-9A-Fa-f]+)\s+([0-9A-Fa-f]+)", line)
|
||||||
if m:
|
if m:
|
||||||
sizes[name] = int(m.group(2), 16)
|
sizes[name] = int(m.group(2), 16)
|
||||||
break
|
break
|
||||||
return sizes
|
return sizes, skipped
|
||||||
|
|
||||||
|
|
||||||
def load_baseline():
|
def load_baseline():
|
||||||
@@ -64,23 +100,33 @@ def load_baseline():
|
|||||||
|
|
||||||
|
|
||||||
def save_baseline(sizes):
|
def save_baseline(sizes):
|
||||||
|
accepted = load_baseline()
|
||||||
|
accepted.update(sizes)
|
||||||
with open(BASELINE, "w") as f:
|
with open(BASELINE, "w") as f:
|
||||||
f.write("# Эталон размеров _CODE (байт); обновление: "
|
f.write("# Эталон размеров _CODE (байт); обновление: "
|
||||||
"python3 toolchain/size_check.py --update\n")
|
"make size-baseline\n")
|
||||||
for name in sorted(sizes):
|
for name in sorted(accepted):
|
||||||
f.write(f"{name}\t{sizes[name]}\n")
|
f.write(f"{name}\t{accepted[name]}\n")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
update = "--update" in sys.argv
|
parser = argparse.ArgumentParser(description="Проверка размера _CODE SDK-тестов")
|
||||||
sizes = collect()
|
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:
|
if not sizes:
|
||||||
print("size_check: .map не найдены — сначала `make all`")
|
print("size_check: свежие .map не найдены — сначала `make all`")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
if update:
|
if args.update:
|
||||||
save_baseline(sizes)
|
save_baseline(sizes)
|
||||||
print(f"size_check: эталон обновлён ({len(sizes)} программ) → "
|
print(f"size_check: эталон обновлён ({len(sizes)} свежих программ) → "
|
||||||
f"{os.path.relpath(BASELINE, ROOT)}")
|
f"{os.path.relpath(BASELINE, ROOT)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -90,17 +136,18 @@ def main():
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
grew = 0
|
grew = 0
|
||||||
for name in sorted(set(sizes) | set(base)):
|
for name in sorted(sizes):
|
||||||
new, old = sizes.get(name), base.get(name)
|
new, old = sizes.get(name), base.get(name)
|
||||||
if new is None:
|
if old is None:
|
||||||
print(f" {name}: исчез из сборки (был {old} Б)")
|
|
||||||
elif old is None:
|
|
||||||
print(f" {name}: НОВЫЙ ({new} Б) — добавить в эталон (--update)")
|
print(f" {name}: НОВЫЙ ({new} Б) — добавить в эталон (--update)")
|
||||||
elif new != old:
|
elif new != old:
|
||||||
mark = "РОСТ" if new > old else "ок, меньше"
|
mark = "РОСТ" if new > old else "ок, меньше"
|
||||||
print(f" {name}: {old} -> {new} ({new - old:+d} Б) [{mark}]")
|
print(f" {name}: {old} -> {new} ({new - old:+d} Б) [{mark}]")
|
||||||
if new > old:
|
if new > old:
|
||||||
grew += 1
|
grew += 1
|
||||||
|
if skipped:
|
||||||
|
print(f"size_check: {len(skipped)} устаревших карт пропущено: " +
|
||||||
|
", ".join(sorted(skipped)))
|
||||||
if grew:
|
if grew:
|
||||||
print(f"size_check: {grew} программ(ы) ВЫРОСЛИ — проверить правки libc")
|
print(f"size_check: {grew} программ(ы) ВЫРОСЛИ — проверить правки libc")
|
||||||
return 1
|
return 1
|
||||||
@@ -109,4 +156,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
Reference in New Issue
Block a user