From 259c782190ca3386103c1bf004c2736d432a01e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B5=D1=82=D1=80=D0=BE=D0=B2?= Date: Thu, 17 Sep 2026 23:22:37 +0300 Subject: [PATCH] Support bounded machine instruction step counts in MCP --- docs/TODO.md | 3 ++- docs/mame-source-debug-status.md | 1 + docs/mame-source-debug.md | 2 ++ docs/mcp-capability-matrix.md | 2 +- docs/mcp-convergence-plan.md | 5 +++++ docs/sdbg-mcp.md | 4 ++++ tests/sdbg/run_managed_mcp_probe.py | 4 ++++ tests/sdbg/test_mcp_adapter.py | 10 ++++++++++ tests/sdbg/test_server.py | 20 +++++++++++++++++++- toolchain/mcp/sdbgbridge/init.lua | 4 +++- toolchain/sdbg/mcp_adapter.py | 6 ++++-- toolchain/sdbg/server.py | 5 ++++- toolchain/sdbg_mcp.py | 6 +++--- 13 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 4ca02ca..eecf8ec 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -287,7 +287,8 @@ Quick wins: проверены живым прогоном. Привязка RPC к session/build/generation и экранные API, одиночный `press_key` и читающий список C-точек уже добавлены; logical Z80 disassembly и ограниченный `type_string` - проверены. Далее natural keyboard, управляемые поля портов и мышь, + проверены, машинный `step_instruction(count=1..64)` проверен на `hello`. + Далее natural keyboard, управляемые поля портов и мышь, затем доказанные watchpoints, запись typed values, disassemble_src и batch-загрузка точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index bc5a451..3e94d7f 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -297,6 +297,7 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | Список 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 | | F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg | | VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` | | Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index 7ab3069..6e9fb92 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -544,6 +544,8 @@ DAP-точки отклоняется. Совместный живой прог VRAM доступны через общий bridge, что подтверждено снимком работающего `hello`; одиночный `press_key` проверен на ожидающем `getchar()`, а `type_string` ввёл `Ab9` и Enter в `gets()` с остановкой после печати строки; +машинный `step_instruction` принимает ограниченный `count` 1..64, не меняя +семантику DAP source-step; остальные интерфейсы этого раздела ещё не реализованы. Автономный MCP `start_session` поднимает DSS→EXE→`main` без VS Code; DAP attach к созданному socket и последующий `stop_session` проверены. diff --git a/docs/mcp-capability-matrix.md b/docs/mcp-capability-matrix.md index 495d581..ab4a76e 100644 --- a/docs/mcp-capability-matrix.md +++ b/docs/mcp-capability-matrix.md @@ -19,7 +19,7 @@ | `list_breakpoints` | `list_breakpoints` | Проверено на `hello`: логические C-точки всех владельцев, owner, адреса/банк и фактическое guard-условие только для чтения. Точки из родного окна MAME сюда не входят | | `set_watchpoint` | — | Нужны owner, space/access, bank guard и проверка PC при попадании | | `clear_watchpoint` | — | Зависит от owner-модели watchpoint | -| `step` | `step_instruction` | Один машинный шаг проверен; счётчик `count` ещё не поддержан | +| `step` | `step_instruction` | Проверено на `hello` с `count=3`: 1..64 машинных инструкций при stop, общий control lease и событие остановки; при breakpoint возможно раннее завершение | | `step_over` | `step_source(kind="over")` | Проверен C-шаг F10; raw машинный `over count` пока отличается | | `step_out` | `step_source(kind="out")` | Проверен C-выход; raw выход из одного машинного frame отличается | | `resume` | `continue_execution` | Проверено с control lease и DAP-событиями | diff --git a/docs/mcp-convergence-plan.md b/docs/mcp-convergence-plan.md index 60b4a4b..0463a45 100644 --- a/docs/mcp-convergence-plan.md +++ b/docs/mcp-convergence-plan.md @@ -164,6 +164,11 @@ C-строке. Это покрывает типовой строковый вв `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 не менялся. ## Г. Сделать запуск пригодным для Codex и Claude без VS Code diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md index 58debcb..4ca813d 100644 --- a/docs/sdbg-mcp.md +++ b/docs/sdbg-mcp.md @@ -87,6 +87,10 @@ MCP-сервером MAME и удаляет socket. Закрытие самог возвращает принятие команды; итоговую остановку получите через `recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события `output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`. +`step_instruction(count)` синхронно выполняет 1..64 машинных шагов +(`count=1` по умолчанию) и возвращает позицию остановки. Если раньше +сработает breakpoint, выполнение может остановиться до заданного числа. +Команды DAP F11/F10/Shift+F11 сохраняют прежнюю C-семантику. Число 29 в attach-режиме (31 в автономном с `start_session`/`stop_session`) не означает полного переноса более нового `-plugin mamebridge`: diff --git a/tests/sdbg/run_managed_mcp_probe.py b/tests/sdbg/run_managed_mcp_probe.py index a40e366..229f12e 100644 --- a/tests/sdbg/run_managed_mcp_probe.py +++ b/tests/sdbg/run_managed_mcp_probe.py @@ -109,6 +109,10 @@ async def probe() -> None: 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') + stepped = await call('step_instruction', {'count': 3}) + if stepped['pc'] == location['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') diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py index 8db143b..b85d2b5 100644 --- a/tests/sdbg/test_mcp_adapter.py +++ b/tests/sdbg/test_mcp_adapter.py @@ -55,6 +55,16 @@ class McpAdapterTests(unittest.TestCase): 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) + def test_press_key_releases_shift_after_snapshot_error(self): calls = [] diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index af91ec4..f2c88eb 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -111,13 +111,16 @@ class StepBridge: def __init__(self, owner): self.owner = owner self.calls = [] + self.step_counts = [] def close(self): pass def request(self, command, **arguments): self.calls.append(command) if command in ('step', 'step_over', 'step_out'): - self.owner.index = min(self.owner.index + 1, + count = arguments.get('count', 1) if command == 'step' else 1 + self.step_counts.append(count) + self.owner.index = min(self.owner.index + count, len(self.owner.locations) - 1) return {'accepted': True} if command == 'snapshot': @@ -482,6 +485,21 @@ class ServerTests(unittest.TestCase): finally: controller.close() + def test_instruction_step_count_is_bounded_and_forwarded(self): + session = StepSession() + controller = SessionController(session) + try: + with self.assertRaisesRegex(SessionError, '1..64'): + controller.call('step', {'count': 0, 'owner': 'mcp:test'}) + for bad in (65, 1.5, True): + with self.assertRaisesRegex(SessionError, '1..64'): + controller.call('step', {'count': bad, 'owner': 'mcp:test'}) + result = controller.call('step', {'count': 2, 'owner': 'mcp:test'}) + self.assertEqual(result['sources'][0]['line'], 4) + self.assertEqual(session.bridge.step_counts, [2]) + finally: + controller.close() + def test_source_step_preserves_user_breakpoint_on_same_line(self): session = StepSession() controller = SessionController(session) diff --git a/toolchain/mcp/sdbgbridge/init.lua b/toolchain/mcp/sdbgbridge/init.lua index d33a575..da78a5f 100644 --- a/toolchain/mcp/sdbgbridge/init.lua +++ b/toolchain/mcp/sdbgbridge/init.lua @@ -163,8 +163,10 @@ function exports.startplugin() state="running" event("running",{reason=command}) if command=="step" then + local count=number(args.count or 1,64) + assert(count>=1,"step count должен быть 1..64") pending={kind="step",time=now()} - cpu().debug:step(1) + cpu().debug:step(count) elseif command=="step_over" then pending={kind="step_over",time=now()} machine().debugger:command("over 1") diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py index b824e92..e58176b 100644 --- a/toolchain/sdbg/mcp_adapter.py +++ b/toolchain/sdbg/mcp_adapter.py @@ -201,8 +201,10 @@ class McpSession: def pause_execution(self): return self.call('pause', {'owner': self.owner}) - def step_instruction(self): - return self.call('step', {'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_source(self, kind: str = 'into'): if kind not in ('into', 'over', 'out'): diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index 96c2ff1..d62e645 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -406,8 +406,11 @@ class SessionController: if method == 'step': if self.running: raise SessionError('CPU уже выполняется; сначала Pause') + 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') + self.session.bridge.request('step', count=count) self.running = True self._emit('continued', {'reason': 'step'}) location = self.session.where(self.session.bridge.wait_stopped()) diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py index d321510..2e159aa 100644 --- a/toolchain/sdbg_mcp.py +++ b/toolchain/sdbg_mcp.py @@ -189,9 +189,9 @@ def make_server(client: McpSession | ManagedMcpSession, return client.pause_execution() @tool() - def step_instruction() -> dict[str, Any]: - """Выполнить одну машинную инструкцию Z80 и вернуть текущую позицию.""" - return client.step_instruction() + def step_instruction(count: int = 1) -> dict[str, Any]: + """Выполнить 1..64 машинных инструкций Z80 и вернуть текущую позицию.""" + return client.step_instruction(count) @tool() def step_source(kind: str = 'into') -> dict[str, Any]: