diff --git a/docs/TODO.md b/docs/TODO.md index eecf8ec..1f746c4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -280,7 +280,7 @@ Quick wins: ## Прочий backlog - [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый - stdio-адаптер с 29 инструментами использует общую sdbg-сессию и + stdio-адаптер с 31 инструментом использует общую sdbg-сессию и owner ID личных точек. Полный план и матрица всех 30 raw-возможностей — [mcp-convergence-plan.md](mcp-convergence-plan.md). control lease для DAP/MCP и cleanup точек по истечении heartbeat @@ -288,6 +288,8 @@ Quick wins: экранные 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-загрузка diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index 3e94d7f..6dcdf25 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени проверяет сигнатуру entry и запускает session server. Закрытие DAP завершает только созданные им server/MAME, удаляет временный каталог и свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс). -- `sdbg_mcp.py` предоставляет 29 инструментов через официальный MCP SDK +- `sdbg_mcp.py` предоставляет 31 инструмент через официальный MCP SDK 2.x поверх того же session server: статус, C-позиция, регистры, logical memory, переменные, события/логи, личные точки и команды исполнения. MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном @@ -136,7 +136,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени шага после клавиши. Повторный запуск на том же фиксированном socket после прежнего stale-файла прошёл; после Stop путь удалён. - В автономном режиме доступен 31 инструмент: официальный MCP-клиент + В автономном режиме доступны 33 инструмента: официальный MCP-клиент запустил `hello`, ввёл `x` через публичный `press_key` во время `getchar()` и остановился на следующей C-строке. Остальные raw-возможности ввода перечислены в [матрице](mcp-capability-matrix.md). @@ -298,6 +298,7 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | Logical Z80 disassembly MCP, 2026-09-17 | Через официальный MCP-клиент в видимом MAME прочитаны инструкции `main` с `0x8224` (`call $852C`); байты памяти до/после совпали. Адрес/длина ограничены, возвращаются bank_pages и generation. Всего 30 автономных инструментов; raw program disassembly ещё не поддержано | | Строковый ввод MCP, 2026-09-17 | `tests/gets` с C-картой в видимом MAME получил `Ab9` и Enter через один MCP `type_string`; скриншот показал ввод и печать строки, CPU остановился на строке 16 после `gets()`. 31 автономный инструмент; natural keyboard/мышь остаются в плане | | Счётчик машинных шагов MCP, 2026-09-17 | На видимом `hello` официальный MCP-клиент передал `step_instruction(count=3)`, получил следующую остановку и затем довёл приложение до `getchar()` и выхода; диапазон 1..64 проверяется клиентом, сервером и Lua backend | +| Машинные over/out MCP, 2026-09-17 | На видимом `hello` `over 1` перешагнул call пролога, `over count` дошёл до следующего call, `out` из `_gettextmode` вернулся в caller. `out` из `___sdcc_enter_ix` ждать нельзя: helper делает tail-jump `jp (hl)`; отдельный MCP-прогон получил асинхронный ответ за 8 мс и успешно прервал его Pause. Исправлена передача десятичных count для MAME console (`#`) и длины `dasm` | | F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg | | VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` | | Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index 6e9fb92..0be7b79 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -536,7 +536,7 @@ console_log и загрузка набора точек. Возвращать bu в арбитраж, а не обходят его. Первый stdio MCP-адаптер уже работает через общий session server и отдаёт -29 проверенных C-инструментов (31 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой +31 проверенный C-инструмент (33 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, точки и последующее срабатывание DAP-точки. Добавлен control lease для команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat @@ -546,6 +546,8 @@ VRAM доступны через общий bridge, что подтвержде ввёл `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` проверены. diff --git a/docs/mcp-capability-matrix.md b/docs/mcp-capability-matrix.md index ab4a76e..068d0fd 100644 --- a/docs/mcp-capability-matrix.md +++ b/docs/mcp-capability-matrix.md @@ -20,8 +20,8 @@ | `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_source(kind="over")` | Проверен C-шаг F10; raw машинный `over count` пока отличается | -| `step_out` | `step_source(kind="out")` | Проверен C-выход; raw выход из одного машинного frame отличается | +| `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 | diff --git a/docs/mcp-convergence-plan.md b/docs/mcp-convergence-plan.md index 0463a45..7316314 100644 --- a/docs/mcp-convergence-plan.md +++ b/docs/mcp-convergence-plan.md @@ -11,8 +11,8 @@ `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30 raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop. -`toolchain/sdbg_mcp.py` публикует 29 C-инструментов через общую с DAP сессию -(31 в автономном режиме с командами start/stop). +`toolchain/sdbg_mcp.py` публикует 31 C-инструмент через общую с DAP сессию +(33 в автономном режиме с командами start/stop). **Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не механически скопировать имена и небезопасную семантику.** Полный raw MCP остаётся отдельным режимом для задач без C-пакета и других машин MAME. @@ -169,6 +169,17 @@ C-строке. Это покрывает типовой строковый вв сохраняет 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 diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md index 4ca813d..f223d13 100644 --- a/docs/sdbg-mcp.md +++ b/docs/sdbg-mcp.md @@ -82,7 +82,8 @@ MCP-сервером MAME и удаляет socket. Закрытие самог `list_breakpoints`, `clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`, `continue_execution`, -`pause_execution`, `step_instruction`, `step_source`. Ответы SDK 2.x содержат +`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` приходят как события @@ -90,9 +91,16 @@ MCP-сервером MAME и удаляет socket. Закрытие самог `step_instruction(count)` синхронно выполняет 1..64 машинных шагов (`count=1` по умолчанию) и возвращает позицию остановки. Если раньше сработает breakpoint, выполнение может остановиться до заданного числа. -Команды DAP F11/F10/Shift+F11 сохраняют прежнюю C-семантику. +`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-семантику. -Число 29 в attach-режиме (31 в автономном с `start_session`/`stop_session`) +Число 31 в attach-режиме (33 в автономном с `start_session`/`stop_session`) не означает полного переноса более нового `-plugin mamebridge`: его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md index 5d536b5..5bdc97f 100644 --- a/docs/vscode-sprinter-debug.md +++ b/docs/vscode-sprinter-debug.md @@ -175,7 +175,7 @@ launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, е фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите `toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же session server; его личные точки не заменяют точки VS Code. Порядок запуска, -29 доступных инструментов и ограничения совместного управления описаны в +31 доступный инструмент и ограничения совместного управления описаны в [руководстве по MCP](sdbg-mcp.md). Команда палитры `Sprinter: Build Active Project` собирает приложение по diff --git a/tests/sdbg/run_machine_out_pause_probe.py b/tests/sdbg/run_machine_out_pause_probe.py new file mode 100644 index 0000000..4926965 --- /dev/null +++ b/tests/sdbg/run_machine_out_pause_probe.py @@ -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()) diff --git a/tests/sdbg/run_managed_mcp_probe.py b/tests/sdbg/run_managed_mcp_probe.py index 229f12e..4e04974 100644 --- a/tests/sdbg/run_managed_mcp_probe.py +++ b/tests/sdbg/run_managed_mcp_probe.py @@ -109,8 +109,43 @@ 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') + 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'] == location['pc'] or \ + if stepped['pc'] == returned['pc'] or \ (await call('session_status'))['running']: raise RuntimeError('Три машинных шага не остановились: ' + repr(stepped)) source = str(ROOT / 'tests/hello/hello.c') diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py index b85d2b5..861fb58 100644 --- a/tests/sdbg/test_mcp_adapter.py +++ b/tests/sdbg/test_mcp_adapter.py @@ -64,6 +64,15 @@ class McpAdapterTests(unittest.TestCase): 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 = [] diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index f2c88eb..05a346d 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -118,7 +118,7 @@ class StepBridge: def request(self, command, **arguments): self.calls.append(command) if command in ('step', 'step_over', 'step_out'): - count = arguments.get('count', 1) if command == 'step' else 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) @@ -153,7 +153,7 @@ class StepSession: class WaitingBridge: - """Машинный over ожидает внешний ввод, но pause должен остаться доступен.""" + """Машинный over/out ожидает ввод, но Pause должен остаться доступен.""" def __init__(self): self.paused = False self.started = False @@ -168,7 +168,7 @@ class WaitingBridge: if command == 'pause': self.paused = True return {'accepted': True} - if command == 'step_over': + if command in ('step_over', 'step_out'): self.started = True return {'accepted': True} raise AssertionError(command) @@ -500,6 +500,30 @@ class ServerTests(unittest.TestCase): finally: controller.close() + def test_machine_over_and_out_are_separate_from_source_steps(self): + session = StepSession() + controller = SessionController(session) + try: + for bad in (0, 65, 1.5): + with self.assertRaisesRegex(SessionError, '1..64'): + controller.call('step_over_instruction', + {'count': bad, 'owner': 'mcp:test'}) + over = controller.call('step_over_instruction', + {'count': 2, 'owner': 'mcp:test'}) + self.assertEqual(over['accepted'], True) + self.wait_source_step(controller) + self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 4) + self.assertEqual(session.bridge.step_counts, [2]) + session.index = 0 + out = controller.call('step_out_instruction', {'owner': 'mcp:test'}) + self.assertEqual(out['accepted'], True) + self.wait_source_step(controller) + self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 3) + self.assertEqual([name for name in session.bridge.calls + if name != 'snapshot'], ['step_over', 'step_out']) + finally: + controller.close() + def test_source_step_preserves_user_breakpoint_on_same_line(self): session = StepSession() controller = SessionController(session) @@ -531,6 +555,23 @@ class ServerTests(unittest.TestCase): finally: controller.close() + def test_waiting_machine_over_out_keep_pause_available(self): + for method in ('step_over_instruction', 'step_out_instruction'): + with self.subTest(method=method): + session = StepSession() + session.bridge = WaitingBridge() + controller = SessionController(session) + try: + started = controller.call(method, {'owner': 'mcp:test'}) + self.assertTrue(started['accepted']) + self.assertTrue(controller.running) + stopped = controller.call('pause', {'owner': 'mcp:test'}) + self.assertEqual(stopped['sources'][0]['line'], 3) + self.assertFalse(controller.running) + self.assertIn('pause', session.bridge.calls) + finally: + controller.close() + if __name__ == '__main__': unittest.main() diff --git a/toolchain/mcp/sdbgbridge/init.lua b/toolchain/mcp/sdbgbridge/init.lua index da78a5f..57794b1 100644 --- a/toolchain/mcp/sdbgbridge/init.lua +++ b/toolchain/mcp/sdbgbridge/init.lua @@ -168,8 +168,11 @@ function exports.startplugin() pending={kind="step",time=now()} cpu().debug:step(count) elseif command=="step_over" then + local count=number(args.count or 1,64) + assert(count>=1,"over count должен быть 1..64") pending={kind="step_over",time=now()} - machine().debugger:command("over 1") + -- В консоли MAME числа без префикса шестнадцатеричные. + machine().debugger:command("over #"..count) elseif command=="step_out" then pending={kind="step_out",time=now()} machine().debugger:command("out") @@ -210,7 +213,7 @@ function exports.startplugin() "Дизассемблирование за пределами logical Z80") local path=directory.."/_disasm.txt" os.remove(path) -- Не возвращать файл от прежней команды при ошибке MAME. - machine().debugger:command(string.format("dasm %s,0x%X,%d",path,address,length)) + 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")) diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py index e58176b..f1dae7c 100644 --- a/toolchain/sdbg/mcp_adapter.py +++ b/toolchain/sdbg/mcp_adapter.py @@ -206,6 +206,15 @@ class McpSession: 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') diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index d62e645..a46990d 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -403,6 +403,22 @@ class SessionController: self.running = False self._emit('stopped', {'reason': 'pause', 'location': location}) return location + if method in ('step_over_instruction', 'step_out_instruction'): + if self.running: + raise SessionError('CPU уже выполняется; сначала Pause') + command = {'step_over_instruction': 'step_over', + 'step_out_instruction': 'step_out'}[method] + request_args = {} + if method != 'step_out_instruction': + count = arguments.get('count', 1) + if type(count) is not int or count < 1 or count > 64: + raise SessionError('Число машинных шагов должно быть 1..64') + request_args['count'] = count + self._control(arguments) + self.session.bridge.request(command, **request_args) + self.running = True + self._emit('continued', {'reason': command}) + return {'accepted': True, 'command': command} if method == 'step': if self.running: raise SessionError('CPU уже выполняется; сначала Pause') @@ -643,7 +659,8 @@ RPC_MUTATIONS = frozenset({ 'claim_control', 'renew_control', 'release_control', 'input_key', 'break_line', 'break_function', 'clear_breakpoint', 'clear_owned_breakpoints', 'set_source_breakpoints', 'set_function_breakpoints', - 'continue', 'pause', 'step', 'source_step', + 'continue', 'pause', 'step', 'step_over_instruction', + 'step_out_instruction', 'source_step', }) RPC_GENERATION_MUTATIONS = RPC_MUTATIONS - { 'claim_control', 'renew_control', 'release_control', diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py index 2e159aa..39b728a 100644 --- a/toolchain/sdbg_mcp.py +++ b/toolchain/sdbg_mcp.py @@ -193,6 +193,16 @@ def make_server(client: McpSession | ManagedMcpSession, """Выполнить 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."""