From e4695b8281d029719724063a9d0fc6d456ad43ab Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Tue, 15 Sep 2026 17:58:41 +0300 Subject: [PATCH] =?UTF-8?q?Sprinter:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BE=D1=82=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= =?UTF-8?q?=20C-=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20VS=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/README.md | 27 + .codex/config.toml | 15 +- .gitignore | 7 + .python-version | 1 + AGENTS.md | 10 + Makefile | 17 +- app.mk | 33 +- bin/sprinter-cc | 75 +- docs/libc-reference.md | 15 + docs/mame-autotest.md | 21 +- docs/mame-source-debug-status.md | 326 +++++++ docs/mame-source-debug.md | 817 ++++++++++++++++++ docs/sdbg-log-macros.md | 204 +++++ docs/vscode-sprinter-debug.md | 272 ++++++ libc/include/sdbg.h | 26 + tests/hello/hello.c | 2 +- tests/sdbg/fixtures/common.h | 5 + tests/sdbg/fixtures/helper.c | 5 + tests/sdbg/fixtures/left/utils.c | 2 + tests/sdbg/fixtures/logmacro.c | 17 + tests/sdbg/fixtures/main.c | 12 + tests/sdbg/fixtures/right/utils.c | 2 + tests/sdbg/run_macro_log_probe.py | 86 ++ tests/sdbg/run_mame_probe.py | 389 +++++++++ tests/sdbg/run_vscode_dap_probe.py | 239 +++++ tests/sdbg/test_dap.py | 146 ++++ tests/sdbg/test_launcher.py | 48 + tests/sdbg/test_sdbg.py | 231 +++++ tests/sdbg/test_server.py | 317 +++++++ tests/sdbg/test_session.py | 143 +++ tests/sdbg/test_transport.py | 71 ++ toolchain/apply-mame-sdbg-patch.sh | 18 + .../0001-sdbg-debugger-backend.patch | 129 +++ toolchain/mcp/sdbgbridge/init.lua | 242 ++++++ toolchain/mcp/sdbgbridge/plugin.json | 1 + toolchain/run-mame-mcp.sh | 19 + toolchain/sdbg.py | 39 + toolchain/sdbg/__init__.py | 1 + toolchain/sdbg/build.py | 113 +++ toolchain/sdbg/dap.py | 198 +++++ toolchain/sdbg/image.py | 51 ++ toolchain/sdbg/macros.py | 152 ++++ toolchain/sdbg/model.py | 193 +++++ toolchain/sdbg/server.py | 464 ++++++++++ toolchain/sdbg/session.py | 276 ++++++ toolchain/sdbg/transport.py | 84 ++ toolchain/sdbg_build.py | 31 + toolchain/sdbg_client.py | 32 + toolchain/sdbg_config.py | 31 + toolchain/sdbg_dap.py | 162 ++++ toolchain/sdbg_driver.py | 139 +++ toolchain/sdbg_launcher.py | 279 ++++++ toolchain/sdbg_server.py | 48 + toolchain/sdbg_session.py | 67 ++ toolchain/vscode-sprinter-debug/README.md | 81 ++ toolchain/vscode-sprinter-debug/build.js | 53 ++ toolchain/vscode-sprinter-debug/build.test.js | 40 + toolchain/vscode-sprinter-debug/extension.js | 195 +++++ .../vscode-sprinter-debug/extension.test.js | 134 +++ toolchain/vscode-sprinter-debug/package.json | 170 ++++ toolchain/vscode-sprinter-debug/runtime.js | 84 ++ .../vscode-sprinter-debug/runtime.test.js | 67 ++ 62 files changed, 7147 insertions(+), 27 deletions(-) create mode 100644 .codex/README.md create mode 100644 .python-version create mode 100644 docs/mame-source-debug-status.md create mode 100644 docs/mame-source-debug.md create mode 100644 docs/sdbg-log-macros.md create mode 100644 docs/vscode-sprinter-debug.md create mode 100644 libc/include/sdbg.h create mode 100644 tests/sdbg/fixtures/common.h create mode 100644 tests/sdbg/fixtures/helper.c create mode 100644 tests/sdbg/fixtures/left/utils.c create mode 100644 tests/sdbg/fixtures/logmacro.c create mode 100644 tests/sdbg/fixtures/main.c create mode 100644 tests/sdbg/fixtures/right/utils.c create mode 100644 tests/sdbg/run_macro_log_probe.py create mode 100644 tests/sdbg/run_mame_probe.py create mode 100644 tests/sdbg/run_vscode_dap_probe.py create mode 100644 tests/sdbg/test_dap.py create mode 100644 tests/sdbg/test_launcher.py create mode 100644 tests/sdbg/test_sdbg.py create mode 100644 tests/sdbg/test_server.py create mode 100644 tests/sdbg/test_session.py create mode 100644 tests/sdbg/test_transport.py create mode 100755 toolchain/apply-mame-sdbg-patch.sh create mode 100644 toolchain/mame-patches/0001-sdbg-debugger-backend.patch create mode 100644 toolchain/mcp/sdbgbridge/init.lua create mode 100644 toolchain/mcp/sdbgbridge/plugin.json create mode 100644 toolchain/run-mame-mcp.sh create mode 100644 toolchain/sdbg.py create mode 100644 toolchain/sdbg/__init__.py create mode 100644 toolchain/sdbg/build.py create mode 100644 toolchain/sdbg/dap.py create mode 100644 toolchain/sdbg/image.py create mode 100644 toolchain/sdbg/macros.py create mode 100644 toolchain/sdbg/model.py create mode 100644 toolchain/sdbg/server.py create mode 100644 toolchain/sdbg/session.py create mode 100644 toolchain/sdbg/transport.py create mode 100644 toolchain/sdbg_build.py create mode 100644 toolchain/sdbg_client.py create mode 100644 toolchain/sdbg_config.py create mode 100644 toolchain/sdbg_dap.py create mode 100644 toolchain/sdbg_driver.py create mode 100644 toolchain/sdbg_launcher.py create mode 100644 toolchain/sdbg_server.py create mode 100644 toolchain/sdbg_session.py create mode 100644 toolchain/vscode-sprinter-debug/README.md create mode 100644 toolchain/vscode-sprinter-debug/build.js create mode 100644 toolchain/vscode-sprinter-debug/build.test.js create mode 100644 toolchain/vscode-sprinter-debug/extension.js create mode 100644 toolchain/vscode-sprinter-debug/extension.test.js create mode 100644 toolchain/vscode-sprinter-debug/package.json create mode 100644 toolchain/vscode-sprinter-debug/runtime.js create mode 100644 toolchain/vscode-sprinter-debug/runtime.test.js diff --git a/.codex/README.md b/.codex/README.md new file mode 100644 index 0000000..60f6af9 --- /dev/null +++ b/.codex/README.md @@ -0,0 +1,27 @@ +# Настройки Codex + +`config.toml` и запускной скрипт `toolchain/run-mame-mcp.sh` хранятся в Git. +Запускайте Codex из корня репозитория или любой вложенной папки. +Корень определяется через `git rev-parse --show-toplevel`. + +На каждом компьютере установите `uv` и добавьте его в PATH процесса Codex. +По умолчанию сервер расположен в `mame/sources/MAME/src/mame_mcp.py`. +Каталог `mame/` не хранится в Git и должен быть установлен отдельно. +Первый запуск uv может потребовать сеть для установки Python 3.12 и mcp<2. + +Если расположение отличается, создайте `.codex/mame.local.env` (игнорируется Git): + +```sh +MAME_UV="/opt/homebrew/bin/uv" +MAME_MCP_SCRIPT="mame/sources/MAME/src/mame_mcp.py" +``` + +Путь к серверу может быть абсолютным или относительно корня проекта. +Путь к uv может быть абсолютным, относительным к корню или именем из PATH. +Переменные можно также передать через окружение Codex; локальный файл имеет +приоритет. Файл читается как shell-код запускным скриптом, а не самим Codex. +Не храните в нём секреты и не добавляйте его в Git. + +После изменения настроек перезапустите подключение MCP или Codex. +Инициализация MCP не требует запущенного эмулятора; для команд отладки нужен +MAME с `mame_bridge.lua` (см. `docs/mame-autotest.md`). diff --git a/.codex/config.toml b/.codex/config.toml index 14ff15f..b93e536 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,14 +1,10 @@ [mcp_servers.mame-z80] -command = "/Users/alex/.local/bin/uv" +command = "sh" args = [ - "run", - "--python", - "3.12", - "--no-project", - "--with", - "mcp<2", - "/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py", + "-c", + 'root=$(git rev-parse --show-toplevel) || exit; exec sh "$root/toolchain/run-mame-mcp.sh"', ] +startup_timeout_sec = 60 [mcp_servers.mame-z80.tools.clear_breakpoint] approval_mode = "approve" @@ -27,3 +23,6 @@ approval_mode = "approve" [mcp_servers.mame-z80.tools.pause] approval_mode = "approve" + +[mcp_servers.mame-z80.tools.status] +approval_mode = "approve" diff --git a/.gitignore b/.gitignore index 1be549f..af4a29e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,10 @@ toolchain/mkexe/tests/*.actual *.obj *.dSYM/ +# Python host-tools: bytecode всегда воспроизводим и не входит в исходники. +__pycache__/ +*.py[cod] + # =========================================================================== # Vendored / downloaded # =========================================================================== @@ -138,3 +142,6 @@ applications/PoP/R1/ # здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в # репозиторий не идут assets/orig/ (чужие данные — их выкачивает # `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть). + +# Локальные пути MCP; общая конфигурация .codex остаётся в Git. +/.codex/mame.local.env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/AGENTS.md b/AGENTS.md index f3b2158..a1f1a98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,16 @@ make size-baseline # принять текущие размеры эталон - Справочник API — docs/libc-reference.md (обновлять при добавлении функций). +## Документация source debugger + +При изменении ``, извлечения/форматирования logMessage, +поддержанных типов/регистров, чтения указателей или маршрутов MAME/DAP +одновременно обновлять `docs/sdbg-log-macros.md` и проверяемые примеры; +ссылки и краткий статус синхронизировать с `docs/mame-source-debug.md`, +`docs/vscode-sprinter-debug.md`, `docs/mame-source-debug-status.md` и +`docs/libc-reference.md`. Финальные задачи по hex и разыменованию +указателей пока только в плане, не считать их рабочим API. + ## ABI и платформа (кратко; детали в memory/) - SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные diff --git a/Makefile b/Makefile index 71d1fe6..680e29d 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ DATA_FILES := \ examples/mdview/SAMPLE.MD .PHONY: all tools lib tests examples check clean sdcc floppy \ - size-check size-baseline host-tests $(TESTS) $(APPS) + size-check size-baseline host-tests sdbg-tests mame-sdbg-patch mame-sdbg $(TESTS) $(APPS) all: tools lib tests @@ -87,6 +87,21 @@ HOST_TEST_DIRS := testkit applications/SprPoP/tests/host host-tests: @for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done +# Отладочная карта: реальные SDCC/linker и протокол транспорта без MAME. +# Для local pyenv: pyenv exec make sdbg-tests. +sdbg-tests: tools lib + python3 -m unittest discover -s tests/sdbg -v + +# Backend удерживает MAME в stopped-loop без Cocoa debugger; Lua bridge при +# этом обслуживается из periodic_check ядра. Повторный вызов безопасен. +mame-sdbg-patch: + sh toolchain/apply-mame-sdbg-patch.sh + +# Инкрементально собирает patched checkout и устанавливает бинарник в v306. +mame-sdbg: mame-sdbg-patch + $(MAKE) -C mame/sources/MAME + cp mame/sources/MAME/mame $(MAME_DIR)/mame.arm + # Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv. size-check: python3 toolchain/size_check.py diff --git a/app.mk b/app.mk index fcba1ef..4269feb 100644 --- a/app.mk +++ b/app.mk @@ -12,6 +12,8 @@ # # STACK_SIZE := 2048 # bytes reserved for the stack # # EXTRA_SRCS := helper.c util.c # additional .c files in this dir # # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc +# # SRC_DEBUG := 1 # карта C/asm и проверенный debug-пакет +# # SRC_DEBUG_FILES := helper.c # либо карта только выбранных TU # # EXTRA_DATA := test.txt # extra files to add to `make floppy` # # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd` # @@ -31,6 +33,7 @@ # (in the repo root) still packs all examples. # run floppy + launch MAME +PYTHON ?= python3 SPRINTER_CC := $(PROJ_ROOT)/bin/sprinter-cc MKEXE := $(PROJ_ROOT)/toolchain/mkexe/mkexe LIB := $(PROJ_ROOT)/lib/sprinter.lib @@ -77,6 +80,12 @@ ifneq ($(STACK_SIZE),) CC_FLAGS += --stack-size $(STACK_SIZE) endif CC_FLAGS += $(EXTRA_FLAGS) +ifeq ($(SRC_DEBUG),1) +CC_FLAGS += --src-debug +endif +ifneq ($(strip $(SRC_DEBUG_FILES)),) +CC_FLAGS += $(foreach src,$(SRC_DEBUG_FILES),--src-debug-file $(src)) +endif all: $(EXE) @@ -99,12 +108,24 @@ BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py # предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта. W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py -$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS) - $(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@)) - $(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) - @d=$(dir $@).sprinter-cc-$(EXAMPLE); \ - if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \ - if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi +# Команда/зависимости карты участвуют в пересборке, включая смену режима. +SDBG_CONFIG := $(dir $(EXE)).resource-stamps/$(EXAMPLE)-config.json +SDBG_MANIFEST := $(dir $(EXE)).sprinter-cc-$(EXAMPLE)/manifest.json +.PHONY: sdbg-config-force +SDBG_CONFIG_ARGS = $(SDBG_CONFIG) $(SPRINTER_CC) $(CC_FLAGS) $(SOURCES) $(SDBG_MANIFEST) + +# Проверка содержимого обязательна: mtime make может иметь точность в секунду. +# Сохранение fingerprint только после успешной сборки позволяет повторить сбой. +$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS) sdbg-config-force $(SPRINTER_CC) $(wildcard $(PROJ_ROOT)/toolchain/sdbg/*.py) $(wildcard $(PROJ_ROOT)/toolchain/sdbg_*.py) + @state=$$($(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py check $(SDBG_CONFIG_ARGS)) || exit $$?; \ + if [ "$$state" = changed ] || [ -n "$(filter-out sdbg-config-force,$?)" ]; then \ + mkdir -p $(dir $@); \ + SPRINTER_PYTHON="$(PYTHON)" $(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) || exit $$?; \ + d=$(dir $@).sprinter-cc-$(EXAMPLE); \ + if ls $$d/bank*_*.asm >/dev/null 2>&1; then $(PYTHON) $(BANK_CHECK) $$d || exit $$?; fi; \ + if ls $$d/*.map >/dev/null 2>&1; then $(PYTHON) $(W0ISR_CHECK) $$d || exit $$?; fi; \ + $(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py save $(SDBG_CONFIG_ARGS); \ + fi $(MKEXE): $(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe diff --git a/bin/sprinter-cc b/bin/sprinter-cc index 30819c0..4b0b4e4 100755 --- a/bin/sprinter-cc +++ b/bin/sprinter-cc @@ -65,6 +65,8 @@ # --max-allocs N SDCC --max-allocs-per-node (default: 100000 — smaller/ # faster code at the cost of compile time; pass a lower # value, e.g. SDCC's own default 3000, to compile faster). +# --src-debug карта C/asm для всех пользовательских модулей +# --src-debug-file FILE карта выбранного TU (повторяемый, вместо --src-debug) # --debug enable runtime diagnostics — defines DEBUG_RT for both # sdcc (-DDEBUG_RT) and the crt0 assembly (prepended # `DEBUG_RT = 1`). Exposes runtime introspection symbols @@ -81,6 +83,18 @@ set -eo pipefail +# Можно выбрать интерпретатор без личного пути в репозитории. +SPRINTER_PYTHON="${SPRINTER_PYTHON:-python3}" + +# Отладочная сборка публикуется транзакционно отдельным драйвером. +if [[ "${SPRINTER_SDBG_ACTIVE:-}" != 1 ]]; then + for arg in "$@"; do + if [[ "$arg" == --src-debug || "$arg" == --src-debug-file ]]; then + exec "$SPRINTER_PYTHON" "$(dirname "$0")/../toolchain/sdbg_driver.py" "$0" "$@" + fi + done +fi + # ------- Locate the toolchain ------------------------------------------------ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJ_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" @@ -109,6 +123,8 @@ LOAD_ADDR="" ENTRY_ADDR="" STACK_ADDR="0xBFFE" STACK_SIZE="" # if set, used to compute HEAP_TOP = STACK_ADDR + 1 - STACK_SIZE +SRC_DEBUG=0 +SRC_DEBUG_FILES=() DEBUG_RT=0 # if 1, prepend `DEBUG_RT = 1` to crt0 + pass -DDEBUG_RT to sdcc VERBOSE=0 USER_INCS=() @@ -135,6 +151,7 @@ SAFE_LIBS=0 # if 1, prefer *_safe.lib variants (fallback to standard) # ------- Parse args ---------------------------------------------------------- usage() { sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//' + echo ' --src-debug / --src-debug-file FILE: карта исходников C и asm' exit "${1:-0}" } @@ -166,6 +183,10 @@ while [[ $# -gt 0 ]]; do --mkexe) MKEXE_EXTRA+=("$2"); shift 2;; --max-allocs) MAX_ALLOCS="$2"; shift 2;; --gfx) GFX_MODE="$2"; shift 2;; + --src-debug) SRC_DEBUG=1; shift;; + --src-debug-file) + [[ $# -ge 2 ]] || { echo "--src-debug-file требует FILE" >&2; exit 1; } + SRC_DEBUG_FILES+=("$2"); shift 2;; --debug) DEBUG_RT=1; shift;; --safe) SAFE_LIBS=1; shift;; -v) VERBOSE=1; shift;; @@ -415,14 +436,44 @@ else fi run "$SDASZ80" -o "$HEAP_TOP_REL" "$HEAP_TOP_SRC" +# Единый путь компиляции выбранных C-модулей с картой исходников. +compile_c() { + local src="$1" rel="$2" selected=$SRC_DEBUG candidate + shift 2 + for candidate in "${SRC_DEBUG_FILES[@]}"; do + [[ "$src" -ef "$candidate" ]] && selected=1 + done + if [[ $selected -eq 1 ]]; then + run "$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_build.py" compile \ + --sdcc "$SDCC" --assembler "$SDASZ80" --source "$src" --output "$rel" \ + -- "${CC_FLAGS[@]}" "$@" + elif [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then + run "$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_build.py" compile --plain \ + --sdcc "$SDCC" --assembler "$SDASZ80" --source "$src" --output "$rel" \ + -- "${CC_FLAGS[@]}" "$@" + else + run "$SDCC" "${CC_FLAGS[@]}" "$@" -c -o "$rel" "$src" + fi +} + +# Уникальные имена объектов в debug-пакете допускают одинаковые basename. +object_path() { + local prefix="$1" src="$2" + if [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then + "$SPRINTER_PYTHON" -c 'import hashlib, pathlib, sys; p=pathlib.Path(sys.argv[3]); print(sys.argv[1]+"/"+sys.argv[2]+p.stem+"_"+hashlib.sha256(str(p.resolve()).encode()).hexdigest()[:12]+".rel")' "$WORK" "$prefix" "$src" + else + echo "$WORK/$prefix$(basename "$src" .c).rel" + fi +} + # 2. user sources → .rel (HOME) USER_RELS=() CC_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size -I "$INC_DIR" -I "$BGI_INC_DIR" "${USER_INCS[@]}" "${USER_DEFS[@]}") [[ $DEBUG_RT -eq 1 ]] && CC_FLAGS+=(-DDEBUG_RT) [[ -n "$MAX_ALLOCS" ]] && CC_FLAGS+=(--max-allocs-per-node "$MAX_ALLOCS") for src in "${SOURCES[@]}"; do - rel="$WORK/$(basename "$src" .c).rel" - run "$SDCC" "${CC_FLAGS[@]}" -c -o "$rel" "$src" + rel=$(object_path "" "$src") + compile_c "$src" "$rel" USER_RELS+=("$rel") done @@ -431,9 +482,8 @@ done # остаются в обычном _DATA (W2). Прямые вызовы, без трамплинов. if [[ ${#W3_SPECS[@]} -gt 0 ]]; then for src in "${W3_SPECS[@]}"; do - rel="$WORK/w3_$(basename "$src" .c).rel" - run "$SDCC" "${CC_FLAGS[@]}" --codeseg W3CODE --constseg W3CODE \ - -c -o "$rel" "$src" + rel=$(object_path "w3_" "$src") + compile_c "$src" "$rel" --codeseg W3CODE --constseg W3CODE W3_RELS+=("$rel") done W3_LD_FLAGS+=("-Wl-b_W3CODE=0xC000") @@ -469,7 +519,7 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then for spec in "${BANK_SPECS[@]}"; do bank_n="${spec%%=*}" bank_src="${spec#*=}" - rel="$WORK/bank${bank_n}_$(basename "$bank_src" .c).rel" + rel=$(object_path "bank${bank_n}_" "$bank_src") # По умолчанию --dataseg НЕ задаём: писучие данные банкового # модуля (глобалы, file-static, статики функций) остаются в # общем _DATA, то есть в W1/W2 — они замаплены всегда и видны @@ -484,10 +534,9 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then [[ "$bl" == "$bank_n" ]] && bank_local=1 done [[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}") - run "$SDCC" "${CC_FLAGS[@]}" \ + compile_c "$bank_src" "$rel" \ --codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \ - "${bank_data_flags[@]}" \ - -c -o "$rel" "$bank_src" + "${bank_data_flags[@]}" BANK_RELS+=("$rel") # Virtual address: bank_n in upper byte, BANK_LOW16 in low half. addr=$(printf "0x%X" $(( (bank_n << 16) | BANK_LOW16 ))) @@ -500,6 +549,7 @@ fi IHX="$WORK/$(basename "$OUT" .exe).ihx" LINK_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size --code-loc "$CODE_LOC" --data-loc "$DATA_LOC") +[[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]] && LINK_FLAGS+=(--debug) LINK_FLAGS+=("${BANK_LD_FLAGS[@]}") LINK_FLAGS+=("${W3_LD_FLAGS[@]}") for f in "${LD_EXTRA[@]}"; do LINK_FLAGS+=("$f"); done @@ -529,7 +579,7 @@ fi # Отчёт по раскладке памяти + проверка лимитов — для ЛЮБОЙ модели памяти # (сколько свободно в W1/W2, W3 при --w3, и в каждом банке при --bank). if [[ -f "${IHX%.ihx}.map" ]]; then - python3 "$CHECK_BANKS" "${IHX%.ihx}.map" \ + "$SPRINTER_PYTHON" "$CHECK_BANKS" "${IHX%.ihx}.map" \ --mode "$MEMORY_MODE" --heap-top "$HEAP_TOP_VAL" --stack "$STACK_ADDR" || true fi @@ -544,4 +594,9 @@ MK_PREFIX+=("${MKEXE_EXTRA[@]}") MK_PREFIX+=(-L "$LOAD_ADDR" -E "$ENTRY_ADDR" -S "$STACK_ADDR" -o "$OUT") run "$MKEXE" "${MK_PREFIX[@]}" "$IHX" +if [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then + "$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_driver.py" --metadata "$WORK" \ + "$OUT" "$MEMORY_MODE" "$CODE_LOC" "$DATA_LOC" "$LOAD_ADDR" "$ENTRY_ADDR" "$STACK_ADDR" +fi + echo "sprinter-cc: wrote $OUT" diff --git a/docs/libc-reference.md b/docs/libc-reference.md index 75284d5..a68313d 100644 --- a/docs/libc-reference.md +++ b/docs/libc-reference.md @@ -471,6 +471,21 @@ text_cursor load_cursor/get_cursor(mouse_cursor_t*) set_sensitivity get_sensitivity_x/y video_mode_changed`. Sensitivity = делитель (меньше = быстрее). Solid-C алиасы ms_* включены. +## — логи source debugger без кода вывода Sprinter + +`SDBG_LOG(tag, "text={global}")` и +`SDBG_LOGIF(tag, global_flag, "text={global}")` создают авторские +точки журналирования в выбранном `--src-debug` TU. `tag` — уникальный +C-идентификатор внутри TU; условие `LOGIF` пока только имя поддержанной +global/static переменной, ненулевое значение означает вывод. Подстановки +`{name}` читаются отладчиком при попадании; локальные и C-выражения пока +не поддержаны. Обычная сборка превращает макросы в пустое выражение. +Полный контракт и планы форматирования — +[руководство по SDBG_LOG](sdbg-log-macros.md). +Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них +недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console +при подключённой source-debug сессии; без неё макрос сам ничего не печатает. + ## — платформа Константы портов (PORT_PAGE_W0..W3, PORT_RGADR, PORT_RGMOD), номера diff --git a/docs/mame-autotest.md b/docs/mame-autotest.md index 7791caf..7f000c1 100644 --- a/docs/mame-autotest.md +++ b/docs/mame-autotest.md @@ -239,7 +239,6 @@ end Lua-REPL — удобно нащупывать поля/тайминги вживую перед скриптованием. - **Второй видеорежим/варианты BIOS** — при необходимости менять `COMMON_ARGS`. -``` ## 10. Тайминги MCP-моста (run_bridge.sh) — НЕ ждать дольше @@ -258,3 +257,23 @@ end Пересобрал HDD-образ (`make hdd`) → MAME **обязан** полный рестарт (`chdman -f` даёт новый inode; см. memory `mame_hdd_rebuild_restart`): остановка через `exit` в дебаггере, затем `run_bridge.sh` заново. + +## 11. Карта C/asm для отладки + +`SRC_DEBUG=1` в app.mk создаёт проверенный пакет исходников и адресов без +изменения EXE в проверенных конфигурациях. Пер-модульный вариант: +`SRC_DEBUG_FILES=helper.c`. Команды карты и результаты живых экспериментов: +[mame-source-debug-status.md](mame-source-debug-status.md); полный план: +[mame-source-debug.md](mame-source-debug.md). Базовый C-attach/where/точки и +чтение простых переменных доступны через `toolchain/sdbg_session.py`. +`sdbg_server.py`, DAP MVP, VS Code launch и development-расширение уже есть; +безопасный restart и MCP C-уровня пока не готовы. `-debugger none` +автоматически продолжает CPU и не подходит для ожидания команд пошаговой +сессии. + +Source-debug launcher ждёт до 10-й секунды эмулируемого времени, сохраняет +снимок готового DSS, только затем вооружает entry breakpoint и вводит команду +EXE. Пользовательские точки создаются после проверенного `main`, когда +`_bank_pages` уже заполнена. В обычной конфигурации это даёт 0 попаданий на +адрес entry во время загрузки DSS. Автоматического распознавания приглашения +`C:\\>` пока нет; `launchAt` в VS Code можно увеличить. diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md new file mode 100644 index 0000000..d953061 --- /dev/null +++ b/docs/mame-source-debug-status.md @@ -0,0 +1,326 @@ +# Отладка исходников: реализация и результаты + +Дата: 2026-09-15. План: [mame-source-debug.md](mame-source-debug.md). +Реализованы сборка/карта, проверенный транспорт, базовая C-сессия, +постоянный session server, DAP MVP, VS Code launch и build task. MCP C-уровня, +безопасный restart и расширенная отладка ещё не готовы. +Этапы плана не считаются завершёнными целиком по одному успешному репро. + +## Статус платформ + +- **macOS:** текущий end-to-end цикл проверен, включая backend `sdbg` и + одновременную работу VS Code со штатным `osx` debugger MAME. +- **Linux:** ожидается работа через `sdbg`, `qt` или `imgui`, но полный + живой прогон ещё не выполнен. +- **Windows:** весь функционал пока работать не будет. Provider + `debugger: "windows"` открывает штатный debugger MAME, однако host-часть + использует `fcntl`, Unix domain sockets и Unix launcher. Native Windows + launch/attach из VS Code официально не поддерживается до реализации + переносимого транспорта, блокировки, запуска и end-to-end тестов. + +## Доступно сейчас + +- `bin/sprinter-cc --src-debug` и повторяемый `--src-debug-file FILE`. +- `app.mk`: `SRC_DEBUG=1` либо `SRC_DEBUG_FILES=...`; выбор интерпретатора + через `PYTHON` в make и `SPRINTER_PYTHON` у обёртки. В проекте используется + local pyenv Python 3.12 (`.python-version`); личный путь не зашит в код. +- Уникальные имена CDB-позиций и TU, включая повторные метки внутри одного + модуля. Публичные C/asm-символы и инструкции не переименовываются. +- Сборка в отдельном временном каталоге с проверкой пакета перед публикацией. + Ошибка compiler/linker/парсера сохраняет предыдущие EXE и пакет. + Конкурентные debug-сборки одного output сериализованы; одновременная + обычная и debug-сборка одного output пока не поддерживается. +- Manifest: build ID, хеш EXE/артефактов/исходников/включённых заголовков, + сохранённые исходники, параметры памяти, команды, версия SDCC и выбранные TU. + Зависимости собираются также для TU без карты в пер-модульном режиме. +- Проверяемый JSON-индекс `.sdbg.json`, offline CLI функций, переменных, + символов и соответствия C/asm/адресов. Банковые адреса учитывают секцию + и окно huge/big; банк данных не трактуется как резидентная память. +- Пересборка make при смене команды/содержимого зависимостей. Быстрая правка + заголовка не зависит от секундной точности mtime; повторный make без + изменений не вызывает compiler. +- Отдельный плагин `toolchain/mcp/sdbgbridge`, загружаемый непосредственно + из репозитория через pluginspath. Он не заменяет существующий mamebridge + и не требует копирования изменённых файлов в игнорируемое дерево MAME. +- Версия протокола, ID сессии, generation, атомарные файловые ответы, + ограниченный журнал событий, stopped snapshots, чтение logical memory + с отключёнными side effects, pause/continue/instruction step и точки + с проверкой владения. FileBridge запрещает повтор mutations после timeout. +- `DebugSession` проверяет EXE, Intel HEX и исполняемые байты реально + загруженного resident/current-bank образа перед интерпретацией PC. Он читает + `_bank_pages`, отвергает нули/дубликаты и учитывает, что state MAME хранит + старшие флаги над 8-битным значением page-port. +- Точки по строке и функции, удаление логической группы, `where` и чтение + поддержанных 1/2/4-байтных global/static. Банковская точка получает условие + по I/O page-port (`ib@e2==...` для W3), формируемое самим plugin; произвольная + debugger expression через этот вызов не принимается. Resident-объект или + bank-data не читается, если его физическая страница сейчас закрыта. +- Bootstrap живого теста не держит entry-breakpoint во время загрузки DSS. + Он активирует единственную service-точку перед вводом команды запуска, + сверяет сигнатуру/образ на `main`, а исходные точки ставит после attach. + Банковые пользовательские точки нельзя безопасно активировать до заполнения + `_bank_pages`, поэтому они намеренно создаются после runtime init. +- `sdbg_server.py` — единственный долгоживущий владелец backend. Локальный + Unix JSON-RPC допускает несколько клиентов, сериализует команды, хранит + логические группы и события. `set_source_breakpoints` и + `set_function_breakpoints` создают новый disabled-набор, при ошибке удаляют + его, затем заменяют прежний набор и активируют точки. +- DAP MVP и минимальное VS Code-расширение: attach к session server, + source/function breakpoints, один достоверный frame, registers, + поддержанные globals/statics, evaluate одного имени, continue/pause и + instruction step. F11 идёт до другой C-позиции, F10 использует MAME `over`, + Shift+F11 выходит через bank trampoline до C-позиции caller. Автомат имеет + предел 512 машинных операций и сохраняет приоритет пользовательской точки. + Длинный вызов с ожиданием внешнего ввода не блокирует DAP; Pause доступен. + Неподдержанные setVariable, disassemble, restart/terminate не рекламируются + либо возвращают явную ошибку. +- Собственное расширение является обязательной Sprinter-частью интеграции: + готовые C/C++ или clangd можно добавить для редактирования, но они не знают + source-debug package, DSS, банки и MAME DAP. Версия 0.2.0 даёт TaskProvider + для приложений с `app.mk`, команду Build Active Project и автоматическую + debug-сборку перед F5. Она использует `make SRC_DEBUG=1` и local Python 3.12; + ошибки SDCC с файлом/строкой попадают в Problems. Код сборки проверяется + самим расширением: при ошибке MAME не запускается. Run-команда, расширенные + linker/assembler diagnostics, выбор profile/EXTRA_DATA и VSIX остаются + следующими шагами. +- DAP `logMessage` без пересборки: разрешены литералы, `{{`/`}}` и только + подстановки `{variable}`. Session server читает типизированное значение, + отправляет `output` и продолжает CPU. Совпавшая обычная точка имеет + приоритет остановки, поэтому logpoint её не проглатывает. Произвольные + выражения, format specifier и conversion намеренно отвергаются. +- ``: `SDBG_LOG(tag, "total={total}")` и ограниченный + `SDBG_LOGIF(tag, flag, "...")` создают нулевые asm-якоря только в + выбранном debug-TU. Метаданные приходят из активного препроцессорного + потока; package содержит связанный адрес/причину unverified. Session server + автоматически ставит эти точки после attach, зеркалирует текст в MAME + debugger console и DAP Debug Console, затем продолжает CPU. Совпавшая + пользовательская точка по-прежнему останавливает исполнение. Локальные, + C-выражения и printf-форматы пока не доступны; значения читаются из + поддержанных global/static объектов, исходное приложение не печатает в DSS. + DAP event ring ограничен 1024 событиями; если клиент отстал, Debug Console + показывает число пропущенных событий. Частые logpoints останавливают CPU + при каждом попадании, их overhead пока не измерен. DAP хранит точный UTF-8 + текст; MAME `printf` получает безопасную проекцию: кавычки заменяются + апострофом, управляющие символы — пробелами. + Практический контракт и таблица типов — + [руководство по SDBG_LOG](sdbg-log-macros.md). Decimal/hex formatter, + форматированный адрес и значение по ненулевому указателю — задачи финального + этапа, сейчас не поддерживаются. Для него `char *` задан как строка, + `int8_t *`/`uint8_t *` — как один 8-битный объект; текущий CDB не + различает `char *` и `uint8_t *`, поэтому нужна metadata declared type. +- `sdbg_launcher.py` реализует VS Code launch: создаёт временную дискету, + копию системного HDD и отдельные state-каталоги, показывает окно Sprinter, + ждёт DSS, ставит только service-точку `main`, вводит `a:\\NAME.EXE`, + проверяет сигнатуру entry и запускает session server. Закрытие DAP + завершает только созданные им server/MAME и удаляет временный каталог. + +## Использование карты + +В shell без инициализированных pyenv shims используйте `pyenv exec`: + +```sh +pyenv exec make -C tests/hello SRC_DEBUG=1 +pyenv exec python toolchain/sdbg.py --build tests/hello/.sprinter-cc-hello verify +pyenv exec python toolchain/sdbg.py --build tests/hello/.sprinter-cc-hello map +pyenv exec python toolchain/sdbg.py --build tests/hello/.sprinter-cc-hello vars +pyenv exec python toolchain/sdbg.py --build tests/hello/.sprinter-cc-hello line2addr hello.c 33 +``` + +`addr2line` принимает адрес **линковщика** с префиксом `0x`, например +`0x1c00c`, а не логический PC без информации о банке. Ответ включает +logical_address, bank, window, функцию, инструкцию `.asm` и все найденные +исходные позиции. Неизвестный адрес возвращает unknown. Неоднозначности +не скрываются; изменённый исходник имеет stale-статус, сохранённый текст +собранной версии остаётся в пакете. + +Для одной TU: `pyenv exec make -C tests/banked SRC_DEBUG_FILES=bank1.c`. +Режимы all/selected взаимоисключающие. `--debug` по-прежнему означает +runtime DEBUG_RT и не включает карту C. + +Низкоуровневый live CLI работает с уже запущенным `sdbgbridge`; его IPC и +session ID должны совпадать с окружением процесса MAME: + +```sh +pyenv exec python toolchain/sdbg_session.py \ + --build tests/hello/.sprinter-cc-hello \ + --ipc "$SDBG_IPC_DIR" --session "$SDBG_SESSION_ID" attach +``` + +Доступны `where`, `step`, `continue`, `break-line`, `break-function`, +`read-variable`, `activate-breakpoints` и `deactivate-breakpoints`. +Это диагностический однооперационный CLI: логические ID групп точек живут +только внутри процесса. Постоянное владение и DAP добавятся в общем session +server; до него для долгой ручной работы нужен один Python-процесс с +`DebugSession`. + +Предпочтительный режим для нескольких клиентов — один server: + +```sh +pyenv exec python toolchain/sdbg_server.py \ + --build tests/hello/.sprinter-cc-hello \ + --ipc "$SDBG_IPC_DIR" --session "$SDBG_SESSION_ID" \ + --socket /tmp/sprinter-sdbg.sock + +pyenv exec python toolchain/sdbg_client.py \ + --socket /tmp/sprinter-sdbg.sock status +``` + +VS Code-расширение находится в `toolchain/vscode-sprinter-debug/`. В +Extension Development Host используется attach-конфигурация: + +```json +{ + "type": "sprinter-mame", + "request": "attach", + "name": "Sprinter MAME: Attach", + "socket": "/tmp/sprinter-sdbg.sock" +} +``` + +В VS Code расширение `0.2.0` запускает адаптер через абсолютный путь к local +pyenv shim `~/.pyenv/shims/python` и задаёт корень workspace как `cwd`. +Это устраняет зависимость от `PATH` процесса VS Code, открытого из Dock. +Выбранные пути видны в Output → `Sprinter MAME Debug`; при раннем сбое +launcher сообщает stderr и последние строки MAME log. +Интегрированный запуск: + +```json +{ + "type": "sprinter-mame", + "request": "launch", + "name": "Sprinter MAME: Launch", + "build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello" +} +``` + +Launcher читает text-mode descriptors из VRAM и ждёт стабильный пустой prompt +`X:…>` 0,25 секунды. `dssTimeout` по умолчанию равен 30 эмулируемым секундам; +`launchAt` теперь только необязательная нижняя граница. Перед вводом launcher +сохраняет снимок DSS. +Пошаговый запуск development-расширения описан в +[vscode-sprinter-debug.md](vscode-sprinter-debug.md). + +## Воспроизводимые проверки + +```sh +pyenv exec python -m unittest discover -s tests/sdbg -v +pyenv exec python tests/sdbg/run_mame_probe.py +pyenv exec python tests/sdbg/run_mame_probe.py --bridge +pyenv exec python tests/sdbg/run_mame_probe.py --bridge --banked +pyenv exec python tests/sdbg/run_mame_probe.py --bridge --banked --server +pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher +pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --native-debugger +pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --source-step +pyenv exec python tests/sdbg/run_mame_probe.py --banked --launcher --step-out +pyenv exec python tests/sdbg/run_vscode_dap_probe.py +pyenv exec python tests/sdbg/run_vscode_dap_probe.py --waitkey --manual-key +``` + +Живой тест использует отдельную дискету, копию системного HDD и отдельные +каталоги состояния в `build/sdbg-live/`. Общие media и запущенный вручную +MAME не изменяются. Без `--bridge` проверяется шаг из Lua callback; +с `--bridge` — отдельный протокол и файловый клиент. `--native-debugger` +добавляет родное окно к тому же DAP-сценарию. Скрипт, запущенный системным Python <3.12, +перезапускает себя через local pyenv. Результаты — `result.jsonl`, `mame.log`. +Остановка процесса в конце bridge-теста относится только к MAME этого теста. + +Зафиксировано на SDCC 4.5.0 #15242 и MAME v0.287, +commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: + +| Проверка | Результат | +|---|---| +| Обычная debug-линковка двух TU с общим inline | Воспроизводится rc != 0, `Multiple definition of C$...` | +| Уникализация debug-символов | Успешная линковка; функции обоих экземпляров различаются | +| Повторные метки sprinter.h:111 | Сохранены начало и эпилог в каждом из трёх TU: 6 адресов | +| debug/non-debug probe, banked huge и big | Полные EXE идентичны | +| Одинаковые basename двух utils.c | Две функции и два независимых статика | +| bank-data в big | Переменная разрешена в банке 1, окне W1 | +| Ошибка линковки поверх опубликованной сборки | Предыдущие EXE и manifest не изменились | +| Испорченный артефакт / изменённый source | Ошибка проверки / явный stale; старый текст сохранён | +| Пер-модульная карта | Только выбранная TU в карте, все компиляционные зависимости учтены | +| Быстрая правка header / переключение debug | Пересборка; без изменений повторной компиляции нет | +| Архив с libc/string/strlwr.c | L-записи попадают в общий CDB, F/S из архивного adb не импортируются | +| Lua step на main | PC=0x8218 сразу после запроса; PC=0x821B на следующем callback | +| Порядок DSS → arm → launch | VRAM-detector нашёл стабильный prompt в строке 22 на 4,69 с; только затем включена entry-точка, ложных попаданий 0 | +| Новый мост, родной debugger | Проверены образ, регистры, `total`: 0 → 42, C-line breakpoint; instruction step около 32–124 мс в отдельных прогонах | +| Банковская точка huge | Условие через W3 page-port; остановка PC=0xC000 разрешена как linker 0x1C000, bank 1 | +| Session server + DAP | Owner lock удержан; DAP function breakpoint остановил `worker`; frame сохранил bank 1 | +| Production launcher | DSS prompt → `main` с `false_hits=0`; backend `sdbg` и опциональный `osx` прошли одинаковый DAP-сценарий, штатный terminate без нового crash report | +| DAP logpoint + breakpoint | В bank 1 напечатано `bank_value=0`; совпавшая function breakpoint сохранила остановку | +| Авторский `SDBG_LOG`, 2026-09-15 | Fixture `logmacro`: EXE с/без macro побайтово равны, сообщение отсутствует в EXE; активный wrapper и склейка литералов привязаны к linked-якорю, `#if 0` исключён. Живой DAP launch в `sdbg` и `osx`: `total=1` одновременно в MAME debugger console и VS Code Debug Console, `false_hits=0` | +| Source F11/F10 | `main`: `0x42bb` → `0x42c1`; F10 выполнил банковский `worker` и остановился в caller на `0x42c9` | +| Source Shift+F11 | Из `worker` bank 1 выполнен выход через trampoline в `main:6`, `0x42c9` | +| VS Code stdio DAP launch, 2026-09-15 | Local pyenv shim → DAP → изолированный MAME/DSS → `hello` → `main:17`, PC=`0x8224`; `false_hits=0`, штатный disconnect | +| F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg | +| VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` | +| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | +| Владение и generation | Второй FileBridge и чтение со stale generation отклоняются | + +Полный host-набор: 29 тестов прошли, один Unix-socket тест пропущен только +из-за запрета `bind` в sandbox. Тот же socket path проверен живым DAP-запуском. + +Это репро доступности механизма, а не полный benchmark или проверка всех +runtime/mapping/lifecycle. Отдельная публикация debug-библиотек требует +добавления метаданных извлечённых архивных модулей; одних флагов --debug +недостаточно. Режим библиотек пока не включён. + +## Новое ограничение MAME + +`src/osd/modules/debugger/none.cpp::wait_for_debugger()` вызывает `go()`. +Поэтому `-debugger none` автоматически продолжает CPU при остановке. +Автономные logpoint actions с ним возможны, ожидание интерактивных команд +в stopped-состоянии — нет. Поэтому добавлен provider `sdbg`: он возвращается +в stopped-loop после короткого sleep, а ядро вызывает Lua periodic перед каждой +итерацией. Во время остановки CPU обычная обработка событий окна не работает: +ранний `sdbg` только спал, отчего macOS помечала MAME «не отвечает» и окно +не открывалось через Cmd-Tab/Dock. Provider теперь опрашивает события +активного OSD примерно 100 раз в секунду (в текущем macOS SDL3 build: +`input_update(false)` и `process_events()`; в native macOS OSD: +`MacPollInputs()`). Новый бинарник проходит DAP launch до `main`; пользователь +подтвердил, что при остановке в `main` окно снова открывается через +Cmd-Tab/Dock. Регрессионный DAP-проход через `getchar()` с клавишей `x` +также завершился на следующей строке с кодом `0x78`. +Воспроизводимый patch и идемпотентный установщик находятся в +`toolchain/mame-patches/` и `toolchain/apply-mame-sdbg-patch.sh`; +`make mame-sdbg` собирает и устанавливает бинарник. Provider `osx` остаётся +доступной launch-опцией и проверен одновременно с DAP. Для непатченного MAME +можно использовать `auto`: штатные варианты — `osx` на macOS, `windows` в +native Windows build, `qt` или `imgui` в Linux. `qt` зависит от +`USE_QTDEBUG=1`, а `imgui` требует графическое окно. Windows-версия самого +host-моста пока не готова: FileBridge использует `fcntl`, server — Unix socket. + +Также startup debugscript с `g` может продолжить первое реальное попадание; +живой bridge-тест не использует такой скрипт. Повторная загрузка autoboot +на reset защищена от дублирования callback. Старый mamebridge параллельно +с sdbgbridge не загружать: общая арбитрирующая сессия ещё не реализована. + +## Размерный регресс и оставшаяся работа + +`make size-check` запущен: сообщает рост у 12 программ относительно текущего +эталона, отсутствующие и новые сборки. В списке роста нет пересобранного +hello. Дополнительно cat и hello собраны исходной обёрткой из HEAD и новой +на тех же runtime/библиотеках: EXE попарно идентичны. Эталон не изменялся. +Этот общий check пока не считается прошедшим; расхождение существующих +сборок с baseline отделено от побайтовых регрессий новой debug-сборки. + +Hard reset из RPC был удалён после воспроизводимого падения MAME 0.287: +старый Lua periodic callback обращался к `debugger_manager` во время нового +`running_machine::start()`. Reset notifier теперь только инвалидирует plugin, +а live-пробник прекращает работу callback до рестарта. До отдельного +безаварийного репро reset/restart capability не публикуется. +Crash reports в 19:22/19:23 относились к неподдержанному register symbol в +условии MAME; публичный протокол теперь принимает только window/page и plugin +строит проверенное условие через I/O port. После штатных прогонов в 20:43 и +позже новых `mame.arm-*.ips` не появилось. + +Далее по плану: завершить инвалидацию на exit/state load, MCP-адаптер, +безопасный restart, расширенные выражения +и упаковку VS Code-расширения. Для IDE ещё нужны Run-команда, выбор профиля +сборки/данных и расширенная диагностика assembler/linker. +Базовый attach уже проверяет принадлежность resident/current-bank к build, +но не умеет читать неотображённую physical RAM. Нет автоматического чтения +неотображённого bank-data, локальных или backtrace. + +Пути исходников в текущем пакете абсолютные. Снимки и уникальные TU уже +есть, переносимый source mapping — следующая доработка. Пока библиотечные +описания и отдельные форматы CDB не поддержаны, карта сообщает ограничения. diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md new file mode 100644 index 0000000..8202b36 --- /dev/null +++ b/docs/mame-source-debug.md @@ -0,0 +1,817 @@ +# Отладка C-приложений Sprinter в MAME и VS Code + +Редакция: **2026-09-14**, после технического ревью плана от 2026-09-13. +Статус: **ЧАСТИЧНАЯ РЕАЛИЗАЦИЯ**. Сборка/оффлайновая карта, транспорт, +live C-сессия, постоянный server, DAP и VS Code launch MVP реализованы; результаты — +[mame-source-debug-status.md](mame-source-debug-status.md). +Полный lifecycle/restart и расширенная отладка ещё не реализованы. +Основной маршрут включает CLI/MCP и VS Code через DAP; +GDB — самостоятельное расширение по отдельной потребности. + +### Статус платформ + +- **macOS** — полный текущий цикл build → DSS → MAME → DAP → VS Code + проверен живыми запусками. Доступны backend `sdbg` и совместный режим + VS Code + штатное окно debugger через `debugger: "osx"`. +- **Linux** — архитектура и используемые host-механизмы совместимы; для + штатного окна MAME выбирается `qt` либо `imgui`. Полный живой прогон на + Linux ещё требуется, поэтому статус остаётся экспериментальным. +- **Windows** — полный функционал пока **не поддерживается**. Значение + `debugger: "windows"` выбирает только штатное окно debugger MAME, но + текущие owner lock (`fcntl`), Unix domain socket, shell-команды запуска и + тесты рассчитаны на macOS/Linux. Нужны Windows-реализации блокировки и + локального RPC, переносимый launcher и отдельные end-to-end тесты. До + этого launch/attach из VS Code на native Windows не считаются рабочими. + +Прежние фазы 0–5A/5B заменены этапами §10. IDE учитывается в архитектуре +с самого начала; прежняя «опциональная фаза 4» теперь распределена между +этапами IDE и расширенной диагностики. Это изменение состава плана, +а не разрешение выполнять ранее отложенную реализацию. + +## 1. Цель и критерий доверия + +Целевой цикл: редактирование C → сборка sprinter-cc → упаковка приложения +и данных → запуск MAME/DSS → остановка на main → отладка из VS Code, +CLI или MCP. + +Планируемые возможности: + +- Точки по функции/строке C, условия, счётчики попаданий, логпоинты без + пересборки, watchpoint на поддержанные объекты. +- Текущий исходник, сгенерированный `.asm`, реальные инструкции, регистры. +- Чтение/изменение глобалов и статиков с учётом типов, банков и окон. +- Шаг по инструкции и исходнику; затем next/stepOut, проверенные локальные + переменные и восстановление стека в поддержанных случаях. +- Структурированные логи для автотестов/IDE, комментарии в родном + дизассемблере; позднее покрытие и профилирование. +- Общая семантика адресов и одна сессия для всех интерфейсов. + +**Критерий доверия:** неизвестная строка, неподтверждённая точка или +недоступная переменная лучше правдоподобного неверного результата. +Карта оптимизированного кода не обещает отдельную инструкцию для каждого +оператора C и наличие всех переменных в любой момент исполнения. + +Связанные файлы: [mame-autotest.md](mame-autotest.md), +[sprinter-cc](../bin/sprinter-cc), [app.mk](../app.mk), +[mame_interactive.py](../toolchain/mame_interactive.py), +[bank.s](../runtime/bank.s), [crt0_banked.s](../runtime/crt0_banked.s). +Локальная интеграция: `mame/sources/MAME/plugins/mamebridge/init.lua`, +`mame/sources/MAME/src/mame_mcp.py`. + +## 2. Доказательства и пределы выводов + +### 2.1 Результаты первоначальной разведки + +Ниже сохранены результаты редакции 2026-09-13. При обновлении плана +сборочные эксперименты не повторялись. Перед реализацией их требуется +воспроизвести с сохранением репро, команд и версий. + +| Эксперимент | Зафиксированный результат | Предел вывода | +|---|---|---| +| `sdcc --debug`, probe | `.exe` идентичен; `_CODE` 3354 → 3354 | Один пример, не гарантия для всех программ | +| `tests/banked`, huge, 3 TU, 2 банка | `.ihx` идентичен сборке без debug | rc=1 из-за C$; корректность карты не доказана | +| `.globl _dbg_x` и `_dbg_x:` в inline asm | Ошибка локальных меток `NNNNN$` в проверенных циклах/ветвлениях | Такую форму якоря не используем | +| `.globl _dbg_x` и `_dbg_x = .` | Релоцируемый символ; в probe байты идентичны, якорь у `ret` | Отдельно проверить влияние inline asm на оптимизацию | +| `;;SDBG x` | Код идентичен, адреса в `.map` нет | Для адреса нужен листинг/дополнительная привязка | +| SDCC 4.5.0 z80, `--out-fmt-elf` | Неизвестная опция | Готового ELF/DWARF этого target ожидать нельзя | + +SDCC сам использует `sym = .`. Это подтверждает пригодность ассемблерной +формы, но не доказывает отсутствие влияния пользовательского inline asm +на оптимизатор C и peephole. Раздельно проверяем идентичность бинарника, +точность карты и скорость программы под debugger. + +Примеры `.cdb` из первоначальной разведки: + +```text +L:C$probe.c$18$3_0$48:8236 C-строка → адрес линковщика +L:A$probe$111:8236 строка .asm → адрес +L:G$main$0$0:8222 начало функции +L:Fprobe$counter$0_0$0:8E40 статик модуля +L:G$total$0_0$0:8E42 глобал +S:G$total$0_0$0({2}SI:S),E,0,0 описание типа +S:Lprobe.add$s$1_0$44({2}SI:S),R,0,0,[e,d] описание регистровой локальной +``` + +Парсер разбирает семейства `L:`, `S:`, `F:` и диагностирует неизвестные +записи. Построчная форма не делает семантику типов, scope, инлайнинга +и границ функций тривиальной. Семантику конечных адресов установить репро, +внутренние диапазоны нормализовать к `[start, end)`. + +### 2.2 Проверено чтением текущих локальных исходников + +- MAME поддерживает breakpoint с condition/action, printf/logerror/tracelog, + source/debugscript, comadd и debugger без окна (`-debugger none`). + `none` автоматически вызывает go() при остановке: он пригоден для + автономных logpoint actions, но не для ожидания интерактивных команд. + Подтверждено исходниками и живым репро; прототип использует osx debugger. +- `device_debug::compute_opcode_crc32` в `src/emu/debug/debugcpu.cpp` + считает CRC **одной инструкции**. Одинаковый `ret` по одному адресу + в двух банках имеет одинаковый ключ комментария. CRC не определяет банк. +- `execute_trace` в `src/emu/debug/debugcmd.cpp` включает трассировку CPU, + а не просто открывает файл для сообщений. +- Мост содержит `clog` и обслуживает `register_periodic` при stopped. + Комментарий у `do_step` прямо указывает: инструкция выполняется после + возврата callback, поэтому чтение PC сразу после step даст старый PC. +- `emu.symbol_table` в `luaengine_debug.cpp` создаёт отдельную таблицу; + готового symadd для консоли нет. C-имена разрешаем на своей стороне. +- В штатных debug views нет окна C-исходника. Для родного UI используем + комментарии, для полноценного исходника — IDE. +- sprinter-cc размещает банки по `(bank << 16) | 0xC000` в huge и + `(bank << 16) | 0x4000` в big. Есть `--bank-data`: банки содержат и данные. +- `_bank_pages[1..N]` заполняется crt0 при загрузке банков, а не до entry. + Драйвер экспортирует PG0..PG3 и другие состояния отображения. +- `bootstrap_r/w` в sprinter.cpp при обычном исполнении перенаправляют + логический адрес в `0x10000 | addr`; загрузочный режим отличается. + Адрес CPU и адрес пространства MAME нельзя смешивать. + +### 2.3 Пакет доказательств этапа 0 + +Сохранить небольшие исходники-репро, команды, полные диагностики, +версии SDCC/ассемблера/линкера, commit MAME и патчи, хеши бинарников, +выбранные фрагменты `.asm/.map/.cdb`, протокол живой проверки MAME. +Большие артефакты допускается хранить вне Git с командой воспроизведения. +Ссылки на меняющиеся номера строк MAME заменять именами функций и +закреплённой ревизией в отчёте эксперимента. + +## 3. Реестр решений: что заменено и почему + +| ID | Риск / прежнее предложение | Решение и обоснование | +|---|---|---| +| R01 | Игнорировать rc=1 при C$, если есть `.ihx` | Устранить конфликт debug-имён; исправность кода не доказывает карту, старый `.ihx` может пережить ошибку | +| R02 | «Полная карта, ноль влияния» | Раздельные регрессии бинарника, карты и overhead; гарантия ограничена проверенными конфигурациями | +| R03 | Адрес — одно число, банки только у кода | Типизированные адреса и snapshot отображения, включая bank-data | +| R04 | Первый адрес строки / следующая строка вместо якоря | Все доказанные позиции, фактическое разрешение и unverified; другой путь исполнения не заменяет удалённую точку | +| R05 | Арминг по совпадению PC с entry | Проверка образа, готовности runtime/банков и выхода; DSS использует те же адреса | +| R06 | Цикл step внутри Lua | Асинхронный автомат с лимитами/отменой: шаг исполняется после callback | +| R07 | Разные ID файлов достаточно для нескольких клиентов | Общая сессия и арбитраж; ID не устраняют гонки run/stop | +| R08 | Безусловный `g` в логпоинте, clear-all | Реестр владельцев и диспетчер попаданий; логи не отменяют остановку | +| R09 | `trace` по умолчанию для логов | Отдельный журнал; instruction trace имеет другое назначение и стоимость | +| R10 | Формат C printf можно передать MAME | Ограниченная грамматика, типы, знак, длина строк и CP866; форматы различаются | +| R11 | CRC скрывает чужой банк | Сначала резидентные комментарии; затем обновление при remap или расширение ключа | +| R12 | Каталог сборки достаточен для attach | Manifest, хеши и фиксированный пакет сессии; исключить смешение сборок | +| R13 | Все DAP-запросы сразу, стек эвристикой | Честные capabilities и один достоверный frame в MVP; сложные функции отдельно | +| R14 | GDB не имеет логов/банков | Учесть dprintf/overlays; DAP выбран за модель Sprinter и общую сессию | +| R15 | Сокет автоматически ускорит шаг | Сначала измерить RTT и execution latency; callback сокетом не исправляется | +| R16 | Ручные правки в игнорируемом mame/ | Версионируемые исходники/патчи и установщик с проверкой расхождений | +| R17 | Библиотеки/локальные автоматически следуют из CDB | Эксперимент архивной линковки и доказанные location ranges | +| R18 | Один launch.json завершает интеграцию | Сборка, диагностики, язык, упаковка данных, запуск DSS и повторный F5 | +| R19 | debugger none держит stopped для IDE | В none wait_for_debugger вызывает go(); использовать родной debugger, для режима без окон реализовать отдельный backend ожидания | +| R20 | `debugger: "windows"` означает поддержку всей цепочки на Windows | Разделить MAME provider и host-инструменты: Windows provider существует, но Python/DAP launch/attach остаются неподдержанными до замены `fcntl`/Unix sockets, переноса launcher и живых тестов | +| R21 | Короткий sleep в `sdbg` достаточно для окна MAME | При stopped CPU обычный frame loop не обрабатывает события GUI, и macOS помечает приложение «не отвечает». В `wait_for_debugger` периодически вызывать event pump выбранного OSD (SDL3: `input_update` + `process_events`, native macOS: штатный poll), ограничив частоту; проверить Cmd-Tab/Dock при длительной остановке и сохранение DAP/клавиатурного ввода | +| R22 | CDB pointee type достаточен для выбора строки или скаляра | SDCC 4.5 кодирует проверенные `char *` и `uint8_t *` одинаково (`DG,SC:U`). В финальном debug-пакете сохранять исходный declared type/typedef chain, сверять его с CDB и размером; при неопределённости явно `unavailable` либо запросить типовую аннотацию. Отдельный fixture должен доказать, что `char *` читается как строка, а `int8_t *`/`uint8_t *` — как один 8-битный объект | + +## 4. Архитектура, сборка и пакет + +```text +sprinter-cc → .exe + отладочный пакет + manifest + │ + sdbg: карта и типы + │ +CLI ────────┐ │ +MCP ────────┼──→ общая сессия отладки ──→ мост MAME ──→ CPU/debugger +VS Code/DAP ┘ состояние, точки, + события, загрузка +``` + +### 4.1 Ответственность компонентов + +- `toolchain/sdbg.py` — публичный CLI; реализацию разделить на модули + пакета/парсера, адресов, типов, точек, сессии и транспорта по мере роста. +- Карта — чистые преобразования артефактов без команд MAME. +- Общая сессия — процесс для подключения CLI/MCP/DAP, хранит build ID, + generation, состояние, владельца управления и реестр точек. +- Мост — низкоуровневые действия, согласованные снимки, асинхронные + операции и события; второго парсера CDB в Lua нет. +- DAP/MCP — тонкие адаптеры; существующие input/screenshots сохраняются, + команды изменения исполнения проходят арбитраж. +- `.dbgs` — ограниченный автономный экспорт, не второй менеджер сессии. + Неподдержанную семантику экспорт отклоняет с объяснением. + +### 4.2 Флаги и manifest + +`--src-debug` включает debug для всех пользовательских TU; +повторяемый `--src-debug-file FILE` — для выбранных. Режимы взаимоисключающие, +неизвестный FILE — ошибка. Необязательный аргумент прежнего флага убран, +поскольку он неоднозначен рядом с позиционными `.c`. +`--debug` остаётся DEBUG_RT. В app.mk: `SRC_DEBUG := 1` либо +`SRC_DEBUG_FILES := a.c b.c`. Обычные сборки прежние; профиль IDE явно +включает карту, а не меняет defaults всего проекта. + +Пакет в `.sprinter-cc-/`: `.cdb/.map/.noi/.ihx/.asm`, нужные листинги, +manifest и индекс `.sdbg.json`. Конфигурация и содержимое входов входят +в ключ пересборки: смена debug/fast/safe/memory не оставляет stale artifacts. +Параллельные сборки одного output сериализуются или используют разные +каталоги. Пакет публикуется только после успешных проверок, атомарно. + +Manifest: schema version, build ID, хеш `.exe` и артефактов, версии +инструментов, команды/флаги, библиотеки, TU и исходники/заголовки с хешами, +соответствующие `.asm`, entry, секции, окна, банки и полнота debug-покрытия. +Адрес `>=0x10000` не объявляется банком без проверки секции. +Неподдержанное размещение диагностируется, не угадывается по имени режима. + +Пути относительно корня сборки, уникальный TU ID, поддержка одинаковых +basename и source path mapping при переносе проекта. IDE проверяет хеши; +при расхождении показывает stale source или сохранённый снимок через DAP +source. Сессия фиксирует пакет: новая сборка не меняет карту старого образа. + +### 4.3 Ошибки CDB + +Основное решение R01: проверить уникализацию отладочных имён по TU до +линковки в `.asm` с согласованным преобразованием всех связанных CDB-записей. +Если формат не позволяет надёжный внешний проход, подготовить патч SDCC. +Не изменять публичные C/asm-символы и инструкции. Выбор способа — результат +репро этапа 0, а не утверждение, что простое переименование уже достаточно. + +До исправления допустим пер-модульный режим только при успешной линковке; +manifest перечисляет отсутствующие TU. Неполный выбранный набор отличается +от повреждённой карты. Повреждённая карта не публикуется. +Автоматического превращения rc=1 в успех по наличию `.ihx` не будет. +Неуспешные артефакты можно сохранять для исследования отдельно, но обычный +attach их не принимает. Полные диагностики сохраняются, включая pipefail. + +### 4.4 Библиотеки + +Проверить извлечение `S/F/L` и путей из архивов libc/libbgi. После успеха +добавить явный режим debug-артефактов библиотек с отдельным ключом кэша, +сохранив fast/safe и «одна функция — один модуль». Проверить DCE, типы, +строки, отсутствие коллизий и идентичность кода. До этого библиотечный +код доступен как asm/символы без выдуманных C-строк. + +## 5. Адреса, исходники, переменные и выражения + +### 5.1 Адресная модель + +`CodeLocation/DataLocation`: build ID, секция, адрес линковщика, logical +CPU address, bank ID приложения при наличии, окно и offset. +Физическая страница — свойство сессии, не константа пакета. +`MappingSnapshot`: generation остановки, PC/SP, регистры, PG0..PG3, +остальные нужные биты отображения, готовность `_bank_pages`. + +```text +line_locations(source_id, line, function_id=None) → список CodeLocation +resolve_pc(pc, mapping_snapshot) → SourceLocation | Unknown +asm_at(code_location) → номер/текст asm и происхождение +function_at(code_location) → функция | Unknown +resolve_symbol(name, module_id=None) → Symbol | Ambiguous | Unknown +read_variable(symbol, snapshot) → TypedValue | Unavailable +``` + +Backend различает logical CPU memory, пространство MAME и physical RAM. +Преобразование `0x10000 | addr` инкапсулировано в драйверном backend. +Bootstrap/configuration имеет отдельную семантику и не допускает обычный +attach приложения. + +Банковая точка проверяет страницу окна против `_bank_pages[N]` после +подтверждения таблицы и резидентного контекста приложения. huge — W3, +big — W1. Проверить достаточность PGn с учётом CNF и других битов драйвера; +простое равенство — базовый случай, не доказанный полный контракт. +Manual-размещения допускаются по фактической карте и поддержке backend. + +### 5.2 Разрешение строк + +Хранить все позиции строки и диапазоны инструкции/функции/секции. +Breakpoint строки по умолчанию покрывает все доказанные позиции исполнения; +дополнительно можно выбрать функцию/экземпляр. Resolver возвращает реальные +адреса, фактическую строку и причины неоднозначности/переноса. +Для пустой или удалённой строки можно предложить ближайшую позицию в том +же контексте, но не подтверждать её молча как точное совпадение. +Без допустимого соответствия — unverified с объяснением. + +addr2line не распространяет предыдущую метку через конец функции, дыру, +секцию или банк. Inline-экземпляры и неоднозначности сохраняются, вместо +выбора первого TU. Привязка точного макроса рассматривается отдельно (§7.2). + +### 5.3 Типы, память и watchpoint + +MVP: доказанные целочисленные типы, указатели, глобалы/статики. Одинаковые +имена требуют квалификации модулем. Затем массивы, структуры, битовые поля +и другие типы с fixtures. Размеры, знак, byte order и ABI задаёт target +SDCC, не хост. Неподдержанный тип отображается raw bytes с диагностикой. + +Регистры/память/mapping читаются в одной остановке, пакетные запросы +привязаны к generation. После resume старые handles и snapshots недействительны. +Запись требует stopped, актуальную generation, проверку диапазона/типа +и известного отображения. Порты показывать по экспортированному состоянию; +отладочные чтения отключают side effects там, где backend это поддерживает. + +Неотображённый банк читается через проверенный доступ к physical RAM без +переключения страниц приложения; до реализации — Unavailable. Один +16-битный указатель не содержит достаточного bank ID: его нельзя выдумывать. +Чтение/запись через границу окна обрабатывается явно. + +Для watchpoint экспериментом установить пространство/alias реального пути +CPU и момент остановки до/после записи. PC-источник, старое и новое значение +показывать лишь при достоверном получении: текущий PC может отличаться от +адреса записавшей инструкции. Банковый watchpoint учитывает mapping; +до доказательства не объявлять его поддержанным. + +### 5.4 Выражения + +Один парсер для condition/logMessage/evaluate/watches: имена, integer +literals, `$`-регистры, ограниченные арифметические/битовые/сравнительные +операции. Документировать грамматику, приоритеты, знаковость и ошибки. +Поля/индексы/разыменование добавлять по поддержке типов. По умолчанию +evaluate без присваиваний, вызовов C и других побочных эффектов. + +Raw MAME expressions/commands — отдельный явно обозначенный режим; +это не C. Изменяющие команды также требуют управления и синхронизации. +Команды генерировать из проверенного AST с экранированием строк/путей, +не конкатенацией произвольного текста в action. + +## 6. Общая сессия и управление исполнением + +### 6.1 Жизненный цикл + +```text +disconnected → waiting_load → verifying_image → runtime_initializing + → stopped ↔ running + → exited +reset / state load / потеря связи → invalidated → повторная проверка +``` + +Совпадение PC с entry — только кандидат загрузки. Подтверждение сочетает +build ID выбранного файла, протокол запуска и сравнение неизменяемых +участков RAM по карте. Хеш `.exe` на хосте сам по себе RAM не проверяет. +Изменяемые crt0 данные/таблицы исключаются; сигнатуры и точки проверки +определяются для конкретного runtime. + +На entry активируются только доказанные резидентные точки. Банковые — +после загрузки и проверки `_bank_pages`. На main runtime должен быть готов. +Отладка crt0 — отдельный режим с asm и постепенным появлением областей. +Частичная ошибка загрузки не переводит сессию в ready. + +Выход через runtime/ESTEX и возврат в DSS деактивируют точки приложения. +Проверить обычный/аварийный выход и обход штатного exit. Если контекст +невозможно уверенно распознать — invalidated, а не продолжение со старой картой. +Reset/state load/restart сбрасывают mapping, temporary points, handles, +generation. Attach к работающей программе сначала останавливает CPU, +проверяет образ и runtime; неизвестную сборку не принимает молча. + +### 6.2 Управление и реестр точек + +Один клиент владеет run/step/write/input; остальные наблюдают согласованные +данные. Передача управления явная. Потеря клиента снимает lease и отменяет +его незавершённые операции согласно политике сессии. Screenshots доступны +наблюдателям; ввод влияет на приложение и арбитрируется. Изменения run/stop +из родного UI MAME отражаются событиями. + +Реестр: logical breakpoint ID, MAME IDs, owner, build ID/generation, +вид (user/log/temporary/service), адреса/условия. Clear-all ограничен owner. +Повторная загрузка файла точек заменяет его набор, не дублирует его. + +Совпавшие точки обслуживает диспетчер: вычислить условия, записать логи, +собрать причины остановки, продолжить только если их нет. User breakpoint, +pause и ошибка шага имеют приоритет над автоматическим `g`. +Изменения точек в родном UI требуют сверки; если backend не может +гарантировать совместное управление, сообщить конфликт. + +### 6.3 Протокол и транспорт + +Protocol version/capabilities, session ID, request ID, generation, +ответы и упорядоченные события stop/run/output/reset/exit/error. +Ответ на установку шага значит «принят», не «CPU уже шагнул». +Нужны timeout/cancel, snapshot/varbatch, EOF/disconnect и защита от повторного +исполнения: write/continue после timeout не повторяется вслепую. + +Сначала измерить файловый IPC: RTT, pause→snapshot, step→stopped, +periodic при running/stopped. Файловый backend: отдельный каталог сессии, +один писатель backend, атомарная публикация запросов/ответов, очистка stale +и журнал событий с курсором. Разделение диапазонов ID независимых MCP +заменяется одним владельцем backend: оно не решало гонки состояния. + +Сокет реализует тот же протокол при подтверждённом выигрыше. MAME имеет +пример `emu.file` socket в plugins/gdbstub, но неблокирующий ввод/вывод +при stop проверяется отдельно. Framing, частичные сообщения, лимит очереди, +reconnect, loopback по умолчанию обязательны. Callback не ждёт клиента +блокирующим чтением. Сокет не исправляет задержку исполнения инструкции. + +### 6.4 Шаги + +Сначала надёжный instruction step. Source-step — асинхронный автомат: +задать действие, вернуть управление MAME, дождаться фактической остановки, +получить snapshot, решить следующий шаг. Для скорости применить temporary +breakpoints на доказанных границах либо C++ hook, если Lua periodic +недостаточен по измерениям. + +StepIn идёт к следующей доступной позиции исходника с заходом в вызов. +Next обходит вызов только при распознанной семантике; stepOut требует +достоверного контекста возврата. Сравнивать source/TU/function/bank и +исполняемую позицию, не один номер строки. Повтор строки в цикле не должен +вечно ждать смены номера. + +Составной шаг имеет предел инструкций/wall time, отмену и итоговую причину. +Код без исходников, HALT, ISR, рекурсия, tail call, bank trampolines, +BIOS rst 08 и ESTEX rst 10 имеют явную политику. Для неизвестного случая +допустим отказ или переход к asm с объяснением; stepIn не выдаётся за next. +Системный код пропускается только при безопасном способе дождаться возврата. + +## 7. Логи, макросы и расширенная диагностика + +### 7.1 Внешние точки и журнал + +Основной путь — версионируемый внешний файл: ID, source/function/location, +condition, hit condition, сообщение и выражения. Тот же resolver и реестр, +что у VS Code; пересборка не требуется. Адреса пересчитываются для новой +сборки из сохранённой исходной привязки. DAP logMessage переводится в эту +модель, не образует отдельный движок. + +Журнал: sequence, build/session ID, host time, доступное emulated time, +PC, bank/page, location, tag и типизированные значения. JSONL — автотестам, +текст — CLI, DAP output — IDE, курсор — MCP. Ограничить объём/частоту, +предусмотреть ротацию или bounded buffer, счётчик потерь, flush на stop/exit. +clog остаётся для сообщений MAME, но хвост консоли не считается полным +надёжным журналом приложения. + +Форматирование — задача финального этапа §10, а не свойство текущего +`{name}`. Целевой ограниченный набор включает decimal/hex, ширину, +символ и строку; MAME `%d/%x/%X/%c/%s/%%` может быть форматом совместимого +экспорта, но не передаётся напрямую как C-макрос. Принято это разделение, +потому что formatter обязан проверять тип/знак, размер, pointer против +массива, границы и кодировку, а CDB не отличает `char *` от `uint8_t *` +(R22). Для строк нужны bounded read до NUL и проверка CP866→UTF-8; +raw bytes имеют отдельное представление. Значения собираются до resume. +Горячие логи могут исполняться в мосте по скомпилированному описанию; +стоимость измеряется отдельно. + +Console/logerror допустимы для совместимого экспорта. Trace/tracelog — +явный режим instruction tracing, не default для логов. Экспорт `.dbgs` +указывает ограничения и не ставит безусловный resume в общей сессии. + +### 7.2 SDBG_LOG и SDBG_LOGIF + +Практический синтаксис, типы, ограничения и примеры описаны в отдельном +[руководстве по SDBG_LOG](sdbg-log-macros.md). Этот документ — источник +истины для пользовательского контракта макросов: при изменении грамматики, +типов, регистров, чтения указателей или вывода в MAME/DAP обновлять его +вместе с кодом и проверками, затем синхронизировать краткие описания здесь. + +Первый поддержанный вариант реализован в ``: `SDBG_LOG(tag, +"total={total}")` и `SDBG_LOGIF(tag, flag, "total={total}")`. +Обычная сборка получает `((void)0)`; выбранный `--src-debug` TU — +символ `sym = .` без инструкции. `tag` обязан быть уникальным в TU, +условие пока только имя поддержанной global/static переменной, а +`{name}` соответствует ограниченной грамматике DAP logMessage. +Сборщик извлекает только активные вызовы из отдельного препроцессорного +metadata-прохода, проверяет единственный asm-якорь и linked address. +Автоматически установленный logpoint пишет в журнал debugger MAME и DAP +`output`, затем продолжает CPU; совпавшая обычная точка сохраняет остановку. +Событийный буфер DAP ограничен 1024 записями и сообщает разрыв курсора с +числом пропусков; overhead горячих macro-logpoints ещё требуется измерить. +Десятичный/hex formatter и чтение типизированного значения по указателю +отложены до финального этапа §10: сначала нужны доказанные тип, контекст +банка, границы и безопасное чтение памяти, иначе лог может показать неверный +объект. Текущий `{name}` выводит десятичное число и никогда не разыменовывает +указатель. +Если якорь не совпал с началом доказанной инструкции, он помечен unverified +и не активируется. В проверенном fixture EXE совпал побайтово с обычной +сборкой; это доказательство конкретного случая, не гарантия для всех +оптимизаций SDCC и inline asm. + +Макросы остаются расширением для авторских устойчивых точек. Без +anchors — `((void)0)`, с anchors — символ `sym = .` без инструкции. +Аргументы логирования не исполняются приложением и не имеют C-побочных +эффектов; это явно документируется, чтобы counter++ не считался кодом C. + +Точная точка требует найденного и проверенного якоря. Без него — missing/ +unverified; приблизительная привязка выбирается отдельно с показом +фактической позиции. Якорь задаёт машинную границу перед инструкцией, +но не гарантирует материализацию локальной или порядок всех вычислений C. + +ID включает TU и tag. Повторные/inline экземпляры имеют отдельные ID либо +отклоняются до линковки; уникальный tag в тексте программы не предотвращает +повторное разворачивание inline. + +Реализованный препроцессорный проход учитывает активные #if, include, +wrappers, многострочные вызовы, комментарии и склейку литералов; связывает +SDBG-описания с единственным реально собранным якорем. Текущая грамматика +намеренно отклоняет вычисляемый tag/condition, нестроковое сообщение и +повторные tag; поддержку сложных C-выражений и локальных добавлять только +после доказанного location range и ограниченного парсера. + +Приёмка сравнивает полные бинарники с/без anchors и без макроса на циклах, +ветках, inline и multi-TU. Если оптимизация меняется, внешние логпоинты +остаются путём без пересборки, anchors обозначаются инструментированным +режимом с измеренной дельтой. Без доказательства «ноль влияния» не обещать. +При добавлении sdbg.h обновить публичный справочник API. + +### 7.3 Комментарии, стек, локальные, покрытие + +Резидентные comadd ставятся после проверки образа и обновляются при смене +сборки. Банковые включаются после поддержки remap-обновления с удалением +старых либо патча ключа `(address, page/context, opcode CRC)`. +Оффлайновый cmt допустим для проверенного резидентного образа; CRC из ihx +не решает банковые коллизии. Учитывать владение пользовательскими комментариями. + +Backtrace сначала даёт текущий достоверный frame. Затем поддержать +распознанные прологи/эпилоги, SDCC __sdcccall(1), callee-pops, IX, +trampolines и ISR. Сканирование стека на похожие адреса — отдельная +маркированная эвристика, не основание для stepOut/локальных. Возможная +альтернатива — история call/return, но attach посреди исполнения не знает +прошлого, а нестандартные переходы требуют инвалидирования истории. + +Локальные доступны лишь при доказанном location range (регистр/стек/память). +Лексический scope не равен live-range. Неизвестные/оптимизированные +значения — unavailable/optimized out. CFI/location lists из asm — отдельное +исследование, а не автоматически доступная возможность CDB. + +Покрытие различает посещение адреса, число исполнений и время. Trackpc +сам по себе не даёт времени и требует проверки банковых коллизий. +Ключ покрытия включает build ID/bank/location, знаменатель — доказанные +исполняемые позиции. Профиль использует измеренный источник cycles/time +или sampling с указанной погрешностью и overhead. + +## 8. MCP, DAP и полный цикл VS Code + +### 8.1 CLI/MCP + +Интерфейсы: attach/status/detach, where, disassemble_src, break_at, +clear_owned_breakpoints, read_var/write_var, watch_var, registers/memory, +step_instruction/step_in/next/step_out/pause/continue, logs с курсором, +console_log и загрузка набора точек. Возвращать build ID/generation, +фактическое разрешение и ограничения там, где они нужны для интерпретации. +Неподдержанное — явная ошибка. Старые низкоуровневые команды интегрируются +в арбитраж, а не обходят его. + +### 8.2 DAP MVP и развитие + +MVP: initialize, attach, configurationDone, disconnect, setBreakpoints, +setFunctionBreakpoints, threads, stackTrace, scopes, variables, +ограниченный evaluate, continue/pause, проверенный instruction step, +disassemble и logMessage/output. Один Z80 — один thread; stackTrace +сначала содержит один текущий frame. Source-step, если ещё не готов, +отвечает отказом с объяснением; instruction granularity включается +только при реализации. + +Соблюдать initialize→initialized→configurationDone; stopped содержит +причину/ID точек, continued сообщает внешний resume, breakpoint — новое +разрешение. Terminated означает конец сессии; exited выдаётся только при +установленном завершении приложения и достоверном коде выхода. +SetBreakpoints заменяет набор данного source, включая очистку пустым +списком, а не добавляет точки бесконечно. + +Capabilities отражают реальную поддержку. Расширения: breakpointLocations, +instruction/conditional/hit breakpoints, readMemory/writeMemory, setVariable, +dataBreakpointInfo вместе с setDataBreakpoints, source-step/next/stepOut, +cancel и сложные типы. Frame/variables references привязаны к остановке, +memory references различают банк/пространство. Предусмотреть пагинацию. +Stdout адаптера содержит только DAP, диагностики — stderr/log. + +### 8.3 Разработка и запуск из редактора + +**Архитектурное решение:** Sprinter-специфичный цикл реализуется собственным +расширением `toolchain/vscode-sprinter-debug`. Готовые C/C++ или clangd можно +использовать для подсветки, completion и навигации, а VS Code Tasks и Debug UI +— как стандартные интерфейсы. Они не знают ABI SDCC/Z80, пакет +`.sprinter-cc-*`, DSS, банковую адресацию и протокол MAME, поэтому не могут +заменить project extension и не считаются источником истины для диагностики +компилятора или отладки. + +Мини-расширение уже содержит `contributes.debuggers`, точки для C, схему и +шаблоны конфигурации. Следующий уровень переносит в него build/run/debug +оркестрацию: `DebugConfigurationProvider` проверяет конфигурацию и при +необходимости запускает выбранную build task; команды расширения выбирают +target/profile/EXTRA_DATA; TaskProvider и problem matcher переводят ошибки +SDCC/линкера в Problems. Сборку всё равно выполняют `make`/`sprinter-cc`, а +запуск — общий Python launcher: расширение не дублирует их логику. + +Установка через VSIX, разработка через Extension Development Host. Наличие +VS Code, pyenv Python и необязательного языкового расширения проверяется при +настройке, а не считается постоянным свойством конкретной машины. + +Включить в поставку: + +- TaskProvider и/или tasks.json: make/sprinter-cc и problem matcher + SDCC/ассемблера/линкера; debug не стартует после неуспешной сборки. +- Языковые настройки: include/defines/gfx/safe/memory из той же сборочной + конфигурации. SDCC-расширения вроде __naked требуют совместимых редакторских + определений; generic clang/GCC-анализ не равен полному анализу SDCC. +- launch.json: приложение/пакет, MAME/ROM/media, EXTRA_DATA, timeout, + stopOnEntry/stopOnMain, source mappings; без личных абсолютных путей. +- Launch: успешная сборка → упаковка → MAME → загрузка DSS → проверка образа + → готовность runtime → main. Переиспользовать текущую упаковку/launcher, + не дублировать сценарии ввода клавиатуры. +- Attach к подготовленной сессии без второго MAME; restart с новым build ID + и пересчётом точек после новой сборки/загрузки. +- Disconnect не закрывает чужой MAME; terminate запущенного адаптером + процесса имеет явную политику. Выход приложения отличается от выхода + MAME. Ошибки сборки/ROM/media показываются в редакторе с причиной. + +## 9. Альтернативный маршрут GDB + +DAP выбран за прямую модель Sprinter, reuse сессии MCP и отсутствие +обязательного DWARF-писателя/target GDB. GDB полезен для его скриптов и +фронтендов; маршрут сохранён как самостоятельное расширение. + +Первоначальная разведка: OSD gdbstub знает z84c015, но объявляет mame.z80 +и другой порядок регистров; исследованный GDB ожидает org.gnu.gdb.z80.cpu +и набор с объединённым IR. Повторить проверку выбранной пары версий. +Lua gdbstub с i386-картой не является готовым backend Z80. Оценка патча +«20 строк» заменяется проверкой полного контракта. + +1. Собрать/закрепить target GDB; проверить handshake. Патч feature/регистров + и IR проходит round-trip всех регистров, включая альтернативные, + byte order и семантику R. +2. Проверить RSP step/continue/interrupt, logical memory read/write, + break/watch и отсутствие патчинга инструкций. Разведка сообщала, что + Z0/Z2..Z4 используют точки MAME; включить это в регрессию. +3. sdbg_elf.py на общей карте: ELF32 EM_Z80, секции по реальному размещению, + symtab, debug_line, затем CU/subprogram/variable и базовые типы DWARF. + Дыры/банки не склеивать в ложный text. Сначала binutils/GDB offline, + затем live. ELF с symtab остаётся полезным самостоятельным экспортом. +4. debug_frame/CFI и locations — только по доказанным данным; строки DWARF + сами по себе не исправляют unwinder SDCC. +5. VS Code cppdbg/target GDB/app.elf — отдельная MI-конфигурация. Банки + сначала вручную/ограниченно; затем исследовать overlays и синхронизацию + с runtime, а не обещать автоматическую поддержку. + +GDB имеет dprintf без пересборки и поддержку overlays. Редакторский +logMessage зависит от frontend; overlays требуют интеграции Sprinter. +Поэтому прежние «логпоинтов нет» и «банки невозможны» заменены конкретными +ограничениями. Области Registers/Ports также возможны поверх GDB, +но в DAP непосредственно используют нашу target-модель. + +RSP и DAP не управляют исполнением независимо одновременно. Режим GDB +получает отдельного владельца; наблюдающие функции MCP проверяются отдельно. +Общую карту можно переиспользовать без совместного run/stop. + +## 10. Этапы реализации и условия перехода + +Календарные оценки прежнего плана считаются оценками демонстрационного +прототипа: надёжность lifecycle/банков/DAP ими не покрыта. После этапа 0 +оценить каждый этап по репро и измерениям, раздельно прототип, поддержанный +выпуск и исследовательские функции. Проход этапа определяется приёмкой. + +### Этап 0 — проверка предположений + +Сохранить §2.3; воспроизвести общий inline в двух вызываемых TU и проверить +адреса обоих экземпляров/решение R01. Проверить anchors, huge/big/bank-data, +библиотечную CDB, memory aliases/watchpoint PC, lifecycle crt0, async step, +задержки моста и CRC банков. Закрепить версии и baseline производительности. + +**Выход:** доказанный способ R01 либо явный ограниченный пер-модульный +режим; таблица проверенных/непроверенных возможностей. Общую карту не +объявлять готовой при скрытом конфликте. + +### Этап 1 — сборка, manifest и карта + +Флаги/app.mk, публикация пакета, исправление CDB, типизированные адреса, +resolver и CLI map/addr2line/line2addr/vars/asm. Debug-библиотеки добавляются +после успешного эксперимента. Обновить документацию сборки/mame-autotest. + +**Выход:** fixtures, одинаковые basename, inline multi-TU, huge/big и +bank-data разрешаются достоверно; unknown явен. Полный cmp debug/non-debug; +make size-check без необъяснённой дельты. Baseline не обновляется ради +скрытия регрессии. + +### Этап 2 — сессия и мост + +Lifecycle/image verification, snapshot/generation, арбитраж, реестр точек, +событийный протокол. Сначала файловый backend/измерения, затем socket +по необходимости. Pause/continue/instruction step и ограниченный async +stepIn. Версионировать мост/патчи и установщик (§12). Новый самостоятельный плагин +можно загружать прямо из toolchain/mcp через pluginspath без копирования +в vendor. Для интерактивного режима без окон требуется решение R19. + +**Выход:** нет ложных точек DSS, банки активируются после готовности, +exit/reset/reload инвалидируют состояние; потеря клиента не блокирует MAME, +конкурентные операции не читают смешанный snapshot. + +### Этап 3 — полезная CLI/MCP-отладка и логи + +Внешние точки/условия/логи, диспетчер совпадений, базовые типы, чтение/запись +резидентных объектов, доказанные watchpoint, where/disassemble_src/clog. +Интегрировать пакет/журнал в автотестовый launcher; проверить headless debugger. + +**Выход:** значения совпадают с эталонными байтами, логи воспроизводимы +и ограничены, breakpoint не проглатывается логпоинтом, повторная загрузка +набора не дублирует точки. + +### Этап 4 — VS Code MVP + +DAP §8.2 и мини-расширение, сначала attach. Source/asm, break/logMessage, +globals/registers, pause/continue/instruction step. Source-step включить +после его приёмки, другие запросы не рекламировать заранее. + +**Выход:** редакторская точка показывает фактическое разрешение, +остановка — верные строку/банк/значения; lifecycle DAP, удаление точек, +disconnect и stale references проверены протокольными тестами. + +### Этап 5 — полный цикл разработки и расширенная отладка + +Собственное расширение получает команды Build/Run/Debug, TaskProvider, +problem matcher, выбор target/profile/EXTRA_DATA и проверку инструментов. +Языковой сервис C/C++ либо clangd остаётся необязательной внешней +зависимостью с генерируемыми include/defines; его диагностика не подменяет +SDCC. Добавить launch/restart/stopOnMain и упаковку VSIX. Довести next, +доступные stepOut, memory/setVariable, data breakpoints, physical bank-data, +массивы/структуры и резидентные комментарии. + +**Выход:** новый рабочий каталог проходит документированную настройку/F5 +на обычной и банковой программе; restart не использует старую карту. +У каждой расширенной функции свой тест/capability; недоступная функция +не мешает использовать поддержанные. + +### Этап 6 — якоря и исследовательские функции + +Первый `SDBG_LOG` и ограниченный `SDBG_LOGIF` уже реализованы; продолжить +§7.2 проверкой сложных inline/оптимизаций и условных выражений только после +появления соответствующего evaluator. Независимые подэтапы: банковые комментарии, +доказанные stack frames/локальные, coverage/profiling. GDB §9 — отдельный +подэтап по потребности. + +Финальные задачи для макросов, **без реализации в текущем этапе**: + +- Добавить явные десятичные и hex-форматы для 8/16/32-битных signed/unsigned + объектов и адресов, с проверкой типа, знака, ширины и ограниченной общей + грамматикой для SDBG_LOG, внешних logpoints и DAP. Проверить значения на + реальных SDCC-сборках; не передавать Python format specifier в MAME `printf`. +- Для указателя выводить его собственное значение (логический/банковый адрес) + в decimal/hex и отдельно значение по адресу только если указатель не `NULL`. + `char *` трактовать как строку с bounded read до NUL и проверенной кодировкой; + `int8_t *`/`uint8_t *` — как один 8-битный signed/unsigned объект (для + 16/32-битных typed pointers — соответствующий размер). Не выбирать режим + по одному CDB: `char *` и `uint8_t *` там неразличимы (R22); сохранить + объявленный тип/typedef chain из исходника и сверить с CDB. Чтение только + без side effects, после проверки размера, доступного банка, границ и image + identity; `NULL`, закрытая страница, отсутствие NUL в лимите и неизвестный + pointee дают явный `unavailable`/truncated, а не неверное значение. + +**Выход:** доказательства и ограничения каждой функции опубликованы. +Эвристика не выдаётся за стек, anchors — за гарантированную идентичность, +посещения адресов — за время CPU. + +## 11. Матрица приёмки и регрессий + +| Сценарий | Проверяемое свойство | Этап / риски | +|---|---|---| +| hello/probe debug и обычный | Полный cmp exe, размеры, карта main | 0–1, R02 | +| Два TU с общим вызываемым inline | Нет коллизий, адреса/строки обоих экземпляров | 0–1, R01/R04 | +| Ошибка линковки при старом ihx | Ошибка сохранена, новый пакет не опубликован | 1, R01/R12 | +| Два utils.c и перенесённый проект | TU identity и source mapping | 1, R12 | +| Цикл/ветка/удалённая строка/много адресов | Все позиции или unverified, без ложного переноса | 1–4, R04 | +| Два банка с одинаковыми PC и ret | Верная строка/точка, нет чужого комментария | 0–6, R03/R11 | +| huge W3, big W1, поддержанные manual | Адреса/guards из фактической карты | 1–5, R03 | +| bank-data, невключённый банк, граница окна | Physical чтение или Unavailable | 1–5, R03 | +| Watchpoint через CPU/alias | Адрес, момент и PC-источник корректны | 0–5, R03 | +| Entry до банков, ошибка loader | Нет ранней активации/ложного ready | 2, R05 | +| DSS/exit/restart/reset/state load | Нет чужих попаданий и stale handles | 2–5, R05/R12 | +| Изменённый source или чужой exe | Явное расхождение карты | 1–5, R12 | +| Log + user + temporary по одному PC | Лог есть, остановка сохранена | 2–4, R08 | +| Два клиента, ручной resume, reconnect | Арбитраж/события, нет повторных mutations | 2–4, R07/R15 | +| Одна строка в цикле, HALT/ISR/рекурсия/trampoline | Ограниченный отменяемый шаг | 2–5, R06/R13 | +| Signed 8/16, pointer/array, CP866, нет NUL | Знак/тип/кодировка, bounded read | 3–5, R10 | +| Горячий лог, переполнение, выход | Overhead, счётчик потерь, flush | 3, R09 | +| Debug libc/libbgi fast/safe, DCE | Строки/типы, нет роста кода | 0–1, R17 | +| Anchors с #if/include/wrappers/inline | Метаданные совпадают, unsupported отклонён | 6, R02/R04 | +| DAP replace/empty/events/references | Протокол и честные capabilities | 4, R13 | +| F5 с данными, ошибка ROM/build, restart | Полный цикл и диагностика | 5, R18 | +| MAME update с ручными изменениями | Установщик не затирает расхождение | 2, R16 | + +Тесты карты/протокола — небольшие fixtures без MAME; CPU/банки/lifecycle — +живые интеграционные сценарии. Golden-карта сверяется независимо с +листингом, байтами и фактическими остановками, не только выводом парсера. +Tests/hello и tests/banked — стартовые кандидаты, не вся приёмка. + +Замеры: без debugger, debugger без точек, resident/bank breakpoints, +горячий лог, instruction trace, transport RTT, end-to-end step. +Записывать хост/версию, emulation speed, объём журнала, latency p50/p95. +Пороги зафиксировать после baseline этапа 0 до выбора backend; +«21 МГц терпимо» не критерий приёмки. + +## 12. Размещение и воспроизводимость + +Исходники моста/MCP — `toolchain/mcp/`, патчи MAME — +`toolchain/mame-patches/`; патч SDCC при необходимости отдельно с версией. +`toolchain/install-mame-bridge.sh` проверяет upstream revision/хеши, +показывает diff при расхождении и не затирает неизвестные ручные изменения. +Повторная установка идемпотентна; protocol version проверяется handshake. + +Самостоятельный sdbgbridge загружается прямо из toolchain/mcp через +pluginspath: это устраняет необходимость копировать его в vendor и риск +потери изменений. Установщик остаётся нужен для патчей существующего +MAME/моста, если они потребуются. Игнорируемое mame/ не источник истины. Документировать сборку/применение +патчей, проверку установленного бинарника и откат. Не хранить личные пути, +ROM и большие образы в исходниках расширения. + +Defaults вместо прежних открытых вопросов: source debug явный/профиль IDE; +CDB errors не подавляются; отдельный журнал; общий ограниченный язык; +DAP — основной IDE-путь; одна сессия владеет backend; socket по измерениям; +debug-библиотеки после эксперимента. Технически открыты: способ R01, +наблюдение загрузки/выхода, полная mapping-формула, physical RAM и стоимость +hooks. У каждого — репро этапа 0 и условие допуска, а не молчаливое +предположение следующих этапов. + +## 13. Внешние спецификации + +Локальные версии исходников и репро первичны для конкретной сборки. +Online-документация задаёт общую семантику; при реализации фиксировать +использованную версию. + +- [DAP specification](https://microsoft.github.io/debug-adapter-protocol/specification): запросы, события, capabilities и references. +- [DAP specification source](https://github.com/microsoft/debug-adapter-protocol/blob/main/specification.md): полный контракт. +- [VS Code debugger extension](https://code.visualstudio.com/api/extension-guides/debugger-extension): регистрация/упаковка адаптера. +- [MAME general commands](https://docs.mamedev.org/debugger/general.html): printf/tracelog/source/trackpc. +- [MAME execution commands](https://docs.mamedev.org/debugger/execution.html): шаги и трассировка. +- [MAME Lua debugger classes](https://docs.mamedev.org/luascript/ref-debugger.html): низкоуровневый API. +- [GDB Dynamic Printf](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Dynamic-Printf.html): логирование без пересборки. +- [GDB Overlays](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Overlays.html): перекрывающиеся размещения и target-интеграция. diff --git a/docs/sdbg-log-macros.md b/docs/sdbg-log-macros.md new file mode 100644 index 0000000..3db4e94 --- /dev/null +++ b/docs/sdbg-log-macros.md @@ -0,0 +1,204 @@ +# Логи C-приложения через `SDBG_LOG` + +Этот документ описывает **работающий контракт** макросов из ``. +Они ставят авторские logpoints в MAME debugger без вызовов DSS/BIOS и без +кода печати в приложении. Сообщение попадает в Debug Console VS Code и в +журнал debugger MAME. Отдельное окно MAME debugger видно при launch с +`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME, +но не открывает штатное debugger-окно. + +## Быстрый пример + +```c +#include +#include + +volatile uint16_t frame_no; +volatile uint8_t ready; + +void draw_frame(void) +{ + ++frame_no; + SDBG_LOG(frame_counter, "frame={frame_no}"); + SDBG_LOGIF(frame_ready, ready, "ready={ready}, frame={frame_no}"); +} +``` + +Соберите приложение с `make SRC_DEBUG=1` либо запустите F5 в VS Code: +расширение выполняет debug-сборку перед запуском. Для прямого вызова +обёртки подходит `bin/sprinter-cc --src-debug ...`; в режиме +`--src-debug-file FILE` якоря создаются только в выбранных единицах +трансляции. После загрузки DSS launcher останавливается в `main`, проверяет +образ, и session server активирует макросные точки. При попадании он читает +поддержанные значения, выводит текст и продолжает CPU. Если на том же +адресе есть обычный breakpoint, сообщение печатается, а CPU остаётся +остановленным. + +В обычной сборке оба макроса раскрываются в `((void)0)`: без source-debug +сессии они сами ничего не печатают. Строка сообщения не хранится в EXE; +в проверенных обычном и банковом fixture бинарники с макросом и без него +побайтово совпали. Inline asm может влиять на оптимизацию в других случаях, +поэтому одинаковый размер/код каждой программы следует проверять отдельно. + +## Параметры и место вызова + +`SDBG_LOG(tag, message)` принимает два аргумента. + +| Параметр | Что передать | Ограничение | +|---|---|---| +| `tag` | Имя точки, например `frame_counter` | C-идентификатор `[A-Za-z_][A-Za-z_0-9]*`, **без кавычек**, уникальный внутри `.c`/TU | +| `message` | Строковый литерал C, например `"frame={frame_no}"` | От 1 до 1024 символов после декодирования, поддержана склейка соседних литералов | + +Одинаковый `tag` в разных `.c` допустим: сборщик добавляет к символу +уникальный ID TU. Повторный `tag` в одном TU, в том числе из повторно +развёрнутого inline-макроса, отклоняется при сборке. Вычисляемый tag, +строка вместо tag и автоматический `__LINE__` пока не поддержаны. +Используйте название, которое остаётся понятным после правки строк файла. + +`SDBG_LOGIF(tag, condition, message)` принимает третий смысловой компонент: +между tag и сообщением указывается **одно имя** поддержанной global/static +переменной. Если её значение при попадании равно нулю, запись пропускается, +CPU продолжается. Ненулевое значение включает запись. Например: + +```c +SDBG_LOGIF(after_load, ready, "ready={ready}"); +``` + +`ready != 0`, `!ready`, вызов функции, локальная переменная и регистр CPU +как `condition` сейчас не поддержаны. Условие не исполняется кодом C: +отладчик читает значение при остановке. Если переменная недоступна, запись +пропускается и отладчик один раз сообщает причину. Значения с побочными +эффектами (`counter++`, вызовы функций) нельзя использовать и в шаблоне: +аргументы макроса не вычисляются на Sprinter. + +Якорь привязан к текущему адресу ассемблера без инструкции. Он обозначает +машинную границу рядом с вызовом макроса, а не обещает точный порядок всех +выражений C после оптимизации. Ставьте вызов отдельным statement после +интересующего действия и проверяйте фактический адрес/значение на нужной +сборке. Если адрес не совпал с началом доказанной инструкции, точка +получает статус `unverified` и не активируется. Макрос в неактивном `#if` +не создаёт точку; wrappers, многострочные вызовы и склейка литералов +обрабатываются активным препроцессорным проходом. + +## Синтаксис сообщения сейчас + +После обработки C-escape-последовательностей шаблон состоит из литералов +и подстановок `{name}`. `name` — имя **одной** доступной переменной; +значение выводится десятичным числом. Например: + +```c +SDBG_LOG(progress, "step={step}, total={total}"); +SDBG_LOG(braces, "literal {{value}}; actual={total}"); +SDBG_LOG(multiline, "step={step}, " "total={total}"); +``` + +`{{` и `}}` дают буквальные `{` и `}`. `%` сейчас обычный символ: `%d`, +`%x` и `%s` **не являются** форматами макроса. Синтаксис `{name:04X}`, +`{name!r}`, `{name+1}`, индексы массивов и разыменование указателя +отклоняются во время debug-сборки, до запуска MAME. Не добавляйте префикс +`0x` перед `{name}`: значение пока десятичное, и результат будет неверно +выглядеть как шестнадцатеричный. + +Подстановки разрешаются в контексте TU, где расположен макрос. Можно +прочитать единственную поддержанную global или file-static переменную этого +TU. Если имя не найдено/неоднозначно, тип неподдержан или физический банк +сейчас не отображён, вместо значения выводится ``. +Локальные/параметры функции не имеют доказанных location ranges и пока +не читаются. Resident-страница, закрытая банком, тоже недоступна. + +Текст DAP сохраняет символы UTF-8. Перед отправкой в MAME debugger `printf` +кавычки заменяются апострофами, а управляющие символы — пробелами; +проценты и обратные слэши экранируются. Очень длинный сформированный текст +может не пройти ограничение MAME console 2048 байт, но DAP output остаётся. +При частом попадании CPU останавливается каждый раз, поэтому эмуляция может +замедлиться. DAP хранит до 1024 событий и показывает число пропусков, +если клиент отстал; скорость горячих logpoints ещё не измерена. + +## Какие значения можно подставить + +Источник истины — тип и размер глобального/file-static объекта в debug-карте +SDCC. Доступны целые скаляры размером 1, 2 или 4 байта, а также проверенный +обычный 16-битный указатель. Значение читается из памяти без side effects. +Ниже приведены результаты реальной debug-сборки SDCC 4.5 для этих типов. + +| C-тип объекта | Сейчас в `{name}` | Что остаётся недоступным | +|---|---|---| +| `uint8_t` / `unsigned char` | Десятичное 0…255 | Hex/битовая маска | +| `int8_t` / `signed char` | Десятичное −128…127 | Принудительный unsigned/hex | +| `uint16_t` / `unsigned int` | Десятичное 0…65535 | Hex с 4 цифрами | +| `int16_t` / `int` | Десятичное со знаком | Иной числовой формат | +| `uint32_t` / `unsigned long` | Десятичное 0…4294967295 | Hex с 8 цифрами | +| `int32_t` / `long` | Десятичное со знаком | Иной числовой формат | +| `char` | **Числовой код** байта согласно signedness SDCC; в проверенной сборке `char` был unsigned | Вывод как символ, CP866→UTF-8 | +| `char *` | **Числовой 16-битный адрес** указателя | По принятому для финального API правилу это строка, а не один `char`: bounded read до NUL, границы, кодировка | +| `int8_t *` / `uint8_t *` | **Числовой 16-битный адрес** указателя | По финальному API — один знаковый/беззнаковый 8-битный объект по ненулевому адресу, decimal/hex | +| `char[]`, другие массивы, struct/union, float/double | Не поддержаны | Элементы/поля/значение объекта | +| Локальные и параметры функции | Не поддержаны | Нужны доказанные регистр/стек и live-range | +| Регистры CPU (`PC`, `HL`, `DE`, `AF`, `PG0`…) | Не являются подстановками макроса | Они видны в scope Registers VS Code и MAME debugger, но `{PC}` сейчас не читает регистр | + +Для `char *` вывод адреса **не доказывает**, что память по нему доступна. +Сам массив `char[]` сейчас не выводится даже если его размер 1/2/4 байта. +Числовые коды `char` не декодируются как символы Sprinter. Поддержка +конкретного объекта зависит и от того, есть ли он в карте: оптимизированное +или неразрешённое объявление может быть недоступно. + +Тип указателя нельзя выбирать только по текущему CDB: SDCC 4.5 записал +проверенные `char *` и `uint8_t *` одинаково как `DG,SC:U` (2 байта), +а `int8_t *` как `DG,SC:S`. Для финального вывода строки или скаляра нужны +метаданные **исходного объявленного типа**, сохранённые в debug-пакете и +сверенные с CDB/размером. Неоднозначные typedef/объявления должны давать +`unavailable` либо требовать явную аннотацию формата, а не угадывать по CDB. + +## Форматирование, которое рассматривается + +Эта таблица описывает **предложение для следующей версии**, а не действующий +синтаксис. В текущей версии все записи справа вызовут ошибку сборки. + +| Предлагаемый шаблон | Назначение | Что нужно реализовать и проверить | +|---|---|---| +| `{u8:02X}`, `{u16:04X}`, `{u32:08X}` | Hex с шириной по типу | Ограниченный formatter, signed/unsigned и ширина 8/16/32 бит | +| `{value:d}`, `{value:x}`, `{value:X}` | Одну переменную можно вывести в десятичном и hex виде в одном сообщении | Проверка типа/знака, 8/16/32-битной ширины и недопущение произвольных Python format specifier | +| `{letter:c}` | Символ из `char` | CP866→UTF-8, различие числового кода и отображения символа | +| `{ptr:p}` | Значение самого указателя: логический/банковый адрес | Типизированный адрес, явный `NULL`, различие числового decimal/hex отображения | +| `{p8:*d}`, `{p8:*x}` **условно** | Один 8-битный scalar по `int8_t *`/`uint8_t *` при ненулевом адресе; decimal/hex, окончательный синтаксис ещё не утверждён | Исходный тип и signedness, side-effect-free чтение одного байта, границы/банк; `NULL`/закрытая страница → `unavailable` | +| `{text:s}` | Строка по `char *` при ненулевом адресе, а не один `char`; прямой `char[]` — отдельный случай | Сохранить declared type, bounded read до NUL, доступный банк, отсутствие NUL/кодировка/лимит вывода | +| `{reg:PC}` | Значение регистра CPU | Отдельное пространство имён, чтобы не спутать регистр с C-global `PC` | + +Форматирование должно использовать один и тот же ограниченный движок для +C-макросов, внешних logpoints и DAP output. До реализации предпочтительнее +выводить десятичное значение и читать hex/регистры в штатных views debugger, +чем имитировать `printf` в тексте сообщения. + +Обе задачи — decimal/hex вывод и чтение значения по указателю — записаны +в финальный [этап 6 плана](mame-source-debug.md). +Сейчас `{ptr}` показывает только десятичное значение адреса; разыменование +не выполняется даже при ненулевом указателе. В финальном API `char *` +обозначает строку, а указатель на один 8-битный объект записывается как +`int8_t *` или `uint8_t *`. Адрес любого указателя должен выводиться +отдельно от значения по адресу. Для строки нужна отдельная проверка NUL, +лимита и кодировки (CP866→UTF-8 либо явное представление raw bytes). +Целевой результат можно представить как `text=<адрес>, value=<строка>` для +`char *` и `p8=<адрес>, value=<одно 8-битное число>` для `uint8_t *`; +показанные выше `{text:s}`/`{p8:*d}` ещё нельзя вставлять в рабочий C-код. + +## Если лог не появился + +| Симптом | Причина и действие | +|---|---| +| Нет лога после обычной сборки | Макрос пуст; запустите `SRC_DEBUG=1`/F5 и проверьте, что TU выбран для карты | +| Сборка сообщает о повторном `tag` | Дайте каждой точке отдельный идентификатор; повторные inline-развёртки в одном TU требуют отдельного решения | +| Сборка сообщает «только подстановки» | Уберите `:04X`, `%d` не подставляет значение; используйте `{name}` | +| `unverified`/«не активирован» в Debug Console | Якорь не совпал с исполняемой инструкцией; переместите макрос к доказанной границе и пересоберите | +| `` | Проверьте global/static тип и отображение страницы банка; локальные пока не поддерживаются | +| Нет сообщения в окне MAME при `sdbg` | Штатное debugger-окно в этом режиме скрыто; Debug Console VS Code работает, для окна выберите `osx` | +| При частом логе программа заметно медленнее | Каждое попадание останавливает CPU; уменьшите частоту или используйте условный макрос | + +## Актуализация контракта + +При изменении ``, парсера/форматтера шаблона, набора читаемых типов, +источников значений, условий, поведения breakpoint или маршрутов MAME/DAP +сначала обновляйте этот документ и проверяемые примеры. Затем синхронизируйте +краткое описание в `docs/mame-source-debug.md`, `docs/vscode-sprinter-debug.md`, +`docs/mame-source-debug-status.md` и `docs/libc-reference.md`. Реальные +проверки: `tests/sdbg/test_sdbg.py`, `test_server.py`, `test_dap.py` и живой +`tests/sdbg/run_macro_log_probe.py` для `sdbg`/`osx`. diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md new file mode 100644 index 0000000..701bf8b --- /dev/null +++ b/docs/vscode-sprinter-debug.md @@ -0,0 +1,272 @@ +# Source-debug Sprinter в VS Code + +Статус: MVP для разработки самого отладчика. Поддержаны автоматический launch, +ручной attach, точки по строкам и функциям, logpoints, один C-frame, +регистры, простые global/static, continue/pause, instruction step и шаги +по исходнику F10/F11/Shift+F11. + +> [!WARNING] +> **Windows пока не поддерживает полный цикл отладки.** Значение +> `debugger: "windows"` включает только штатное окно debugger MAME. +> Текущие launcher, owner lock и DAP/session transport используют Unix +> shell, `fcntl` и Unix domain sockets. Поэтому native Windows launch/attach +> из VS Code пока не считается рабочим. Поддержка потребует отдельного +> Windows transport и end-to-end проверки. + +## Какие расширения нужны + +Для build/run/debug используется собственное расширение этого проекта — +`toolchain/vscode-sprinter-debug`. Только оно знает формат source-debug +пакета, загрузку приложения через DSS, банки Sprinter и DAP-сессию MAME. + +Microsoft C/C++ или clangd можно поставить дополнительно ради completion, +переходов по исходникам и подсветки. Оба анализируют код как близкий к +обычному C и не являются точной моделью SDCC: параметры `__naked`, ABI и +часть target-заголовков потребуют отдельных defines/configuration. Ошибки +реальной сборки всегда определяет `sprinter-cc`. + +## Подготовка программы + +Соберите приложение с полной картой: + +```sh +pyenv exec make -C tests/hello SRC_DEBUG=1 +``` + +Рядом с EXE появится `.sprinter-cc-hello/manifest.json`. При изменении C, +заголовка или опций `make` пересоберёт пакет по хэшу содержимого. + +## Загрузка development-расширения + +Из корня репозитория откройте VS Code с распакованным расширением: + +```sh +code --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" "$PWD" +``` + +Если команда `code` не добавлена в `PATH`, на macOS этого проекта доступен +полный путь: + +```sh +"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" \ + --new-window \ + --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" \ + "$PWD" +``` + +Расширение в режиме `auto` использует `~/.pyenv/shims/python` при наличии +local `.python-version`; для внешнего workspace ищет установленный +`~/.pyenv/versions/3.12*/bin/python`, затем `.venv/bin/python` и известные +абсолютные пути Python 3.12. Local-версия передаётся задаче сборки через +`PYENV_VERSION`, даже если приложение лежит вне workspace. Это не зависит от `PATH` процесса VS Code, +который мог быть открыт из Dock. Выбор можно переопределить абсолютным путём +в `sprinterDebugger.pythonCommand`; дополнительные аргументы задаются через +`sprinterDebugger.pythonArguments`. + +## Автоматический launch + +Добавьте в локальный `.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "sprinter-mame", + "request": "launch", + "name": "Sprinter MAME: hello", + "build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello" + } + ] +} +``` + +Launcher создаёт временные floppy/HDD/state-каталоги и видимое окно MAME. +В отдельный `cfg/sprinter.cfg` он записывает только включение обеих клавиатур +Sprinter: `:` и `:kbd:ms_naturl`. MAME по умолчанию активирует лишь первую, +поэтому без этой настройки физические клавиши не доходят до DSS `getchar()`. +Действующая пользовательская конфигурация MAME при этом не копируется. +Он читает символы текстового экрана из VRAM, находит пустой prompt `X:…>` и +требует, чтобы тот оставался готовым 0,25 секунды. Только затем ставится +service-точка `main` и вводится `a:\\HELLO.EXE`. После совпадения PC и +сигнатуры кода запускается session server, DAP принимает редакторские точки, +а VS Code показывает остановку на entry. Завершение debug session останавливает +только созданные ей MAME/server. + +`dssTimeout` задаёт предельное время ожидания prompt (30 эмулируемых секунд). +`launchAt` можно задать как необязательную нижнюю границу времени запуска; +наличие prompt всё равно обязательно. Перед вводом сохраняется диагностический +снимок DSS. + +Дополнительные файлы на floppy задаются массивом `data`, путь к другому MAME — +полем `mame`. + +## Ручная проверка VS Code + +В репозитории локально подготовлены два профиля `.vscode/launch.json`: +`Sprinter: hello (VS Code)` и `Sprinter: hello (VS Code + MAME debugger)`. +Файл `.vscode` намеренно игнорируется Git и не содержит личных путей. + +1. Соберите `hello` командой из раздела «Подготовка программы» и запустите + Extension Development Host одной из команд выше. +2. Откройте `tests/hello/hello.c` и поставьте breakpoint на строке 31, + вызове `puts`. +3. В Run and Debug выберите `Sprinter: hello (VS Code)` и нажмите F5. + Расширение сначала выполнит `make SRC_DEBUG=1` в `tests/hello` как задачу + Sprinter Build. После успешной сборки должно появиться окно Sprinter; + launcher дождётся prompt DSS, введёт + `A:\\HELLO.EXE` и VS Code остановится в `main`. +4. Проверьте Call Stack, scope Registers и Debug Console. После Continue + должна сработать подтверждённая точка строки 31 по адресу `0x824b`. +5. На остановке проверьте F11 и F10. Курсор должен переходить только после + фактической остановки CPU, а не сразу после отправки команды. На строке 62 + (`getchar`) нажмите F10, щёлкните окно Sprinter MAME и нажмите латинскую `x`: + выполнение должно перейти на строку 63. Пока программа ждёт клавишу, + кнопка Pause в VS Code должна останавливать CPU. +6. Завершите сессию кнопкой Stop. Затем повторите профиль с `osx`: вместе с + тем же VS Code-сеансом должно открыться штатное Cocoa-окно debugger MAME. + +Команда палитры `Sprinter: Build Active Project` собирает приложение по +Makefile открытого C-файла. Задачи `Sprinter: Build ...` доступны и в +`Tasks: Run Task`. Для launch автоматическая сборка включена по умолчанию, +если из `build` можно найти Makefile с `app.mk`; нестандартный каталог задаётся +полем `project`, а уже собранный пакет запускается с `"autoBuild": false`. +Ошибка SDCC с файлом и строкой видна в Problems; ненулевой код задачи +останавливает F5 до запуска MAME. Явный `preLaunchTask` использует стандартное +поведение VS Code и отключает автоматическую сборку расширения. + +Если F5 сообщает, что тип `sprinter-mame` неизвестен, окно запущено без +`--extensionDevelopmentPath`. Сообщение `Couldn't find a debug adapter +descriptor` означает, что extension manifest загружен, но расширение не +активировалось; после изменения `package.json` выполните `Developer: Reload +Window`. Канал Output → `Sprinter MAME Debug` показывает выбранные Python и +DAP. При раннем завершении MAME ответ launch включает последние строки +MAME log. Если сборочный пакет устарел, адаптер должен отказать до запуска +программы и показать причину, а не использовать старую карту. + +Логи из исходника без вывода в DSS задаются в C через ``; +полный синтаксис и таблица поддержанных типов — в +[руководстве по SDBG_LOG](sdbg-log-macros.md): + +```c +#include + +volatile int total; + +/* tag уникален в этом .c; total читает debugger, не приложение. */ +SDBG_LOG(after_increment, "total={total}"); +``` + +При F5 debug-сборка привяжет нулевой asm-якорь к адресу и session server +поставит logpoint после загрузки DSS. Попадание напишет `total=...` в +Debug Console VS Code и в debugger console MAME, затем продолжит выполнение. +В обычной сборке макрос пуст. Поддерживаются только global/static переменные +доказанного типа, литералы и `{name}`; локальные, выражения и `%d` пока нет. +`SDBG_LOGIF(tag, flag, "...")` выводит сообщение, если поддержанная +global/static `flag` ненулевая. Аргументы не исполняются на Sprinter, поэтому +выражения с побочными эффектами не имеют здесь смысла. В режиме `sdbg` +журнал MAME существует, но его штатное окно не открывается; для видимого +окна выберите `osx` ниже. Горячий logpoint может заметно замедлить эмуляцию: +CPU останавливается на каждом попадании. Если DAP не успевает забрать +события из ограниченного журнала, Debug Console сообщает число пропусков. +Текст в VS Code сохраняется точно; MAME console заменяет двойные кавычки +апострофами и управляющие символы пробелами из-за синтаксиса `printf`. + +## Родной debugger MAME вместе с VS Code + +По умолчанию используется project backend `sdbg`: видимо окно Sprinter, +а отдельное Cocoa-окно debugger не создаётся. Для регистров, дизассемблера, +памяти и console штатного MAME добавьте в launch-конфигурацию: + +```json +"debugger": "osx" +``` + +Bridge и DAP продолжают работать параллельно. Ручные `go`, изменение точек и +reset в native console обходят модель состояния VS Code; reset пока нельзя +использовать из-за lifecycle-crash MAME 0.287. + +Для MAME без project patch выберите штатный provider: + +| Платформа/сборка | `debugger` | +|---|---| +| macOS | `osx` | +| Windows native | `windows` | +| Linux с Qt debugger | `qt` | +| Linux/SDL без Qt | `imgui` | +| Автоматический выбор доступного provider | `auto` | + +`imgui` использует основное графическое окно MAME; launcher уже запускает его +с `-video soft -window`. `none` немедленно продолжает остановленный CPU, а +`gdbstub` ждёт протокол GDB, поэтому они не подходят для sdbgbridge. + +Текущая host-часть sdbg использует `fcntl` и Unix domain sockets. Она работает +на macOS/Linux. Для native Windows нужны отдельные реализации owner lock, +локального RPC и launcher; выбор `windows` решает только сторону MAME и не +обеспечивает работу VS Code-интеграции. + +Backend `sdbg` входит как воспроизводимый patch к MAME 0.287. Для локального +checkout достаточно: + +```sh +make mame-sdbg +``` + +Команда идемпотентно применяет +`toolchain/mame-patches/0001-sdbg-debugger-backend.patch`, инкрементально +собирает MAME и устанавливает `mame/v306/mame.arm`. + +## Logpoints + +Обычная команда VS Code **Add Logpoint…** работает без пересборки. В сообщении +разрешены литералы и простые подстановки: + +```text +frame={frame_counter} state={state} +``` + +Подстановка принимает только имя поддержанной переменной. Выражения, +format specifier и conversion отклоняются. Если logpoint и обычная точка +разрешились в один адрес, сообщение печатается, а CPU остаётся остановленным. + +## Шаги по исходнику + +F11 выполняет Z80-инструкции до следующей отличающейся C-позиции. F10 на +каждом машинном шаге использует MAME `over`, поэтому банковский вызов проходит +целиком. Shift+F11 сначала делает MAME `out`, затем проходит служебный bank +trampoline до первой C-позиции вызывающей функции. Instruction granularity +остаётся доступна для точного одиночного шага. + +Автомат ограничен 512 машинными операциями. Команда шага отвечает VS Code +сразу; пока `getchar()` или другой вызов ждёт внешнего ввода, MAME продолжает +работать, а пользователь может нажать клавишу в его окне либо выполнить Pause +в VS Code. Время ожидания ввода не ограничивается таймаутом исходного шага. +При достижении предела CPU остаётся остановленным, а Debug Console получает +сообщение. +Пользовательская точка внутри шага имеет приоритет; logpoint печатается и шаг +продолжается с прежней семантикой. + +## Ручной attach + +Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py`, затем: + +```json +{ + "type": "sprinter-mame", + "request": "attach", + "name": "Sprinter MAME: Attach", + "socket": "/tmp/sprinter-sdbg.sock" +} +``` + +Команды server и диагностического CLI приведены в +[mame-source-debug-status.md](mame-source-debug-status.md). + +## Ограничения MVP + +- Reset/restart отключён после найденного crash MAME 0.287 на старом Lua + callback. Session завершается штатным terminate. +- Локальные, backtrace, setVariable, watchpoints и чтение неотображённого + bank-data ещё не реализованы. +- Если одна инструкция имеет несколько C-маркеров, frame помечается + `[ambiguous]`; адаптер не выбирает строку молча. diff --git a/libc/include/sdbg.h b/libc/include/sdbg.h new file mode 100644 index 0000000..0714b98 --- /dev/null +++ b/libc/include/sdbg.h @@ -0,0 +1,26 @@ +/* sdbg.h — авторские точки журналирования source debugger. + * + * Текст и подстановки обрабатываются на хосте: код Sprinter не вызывает + * ESTEX/BIOS и не хранит строку в EXE. В обычной сборке макросы пустые. + * В --src-debug выбранный TU получает нулевой по размеру asm-якорь. + * tag — уникальный C-идентификатор внутри единицы трансляции. */ +#ifndef SPRINTER_SDBG_H +#define SPRINTER_SDBG_H + +#if defined(SPRINTER_SDBG_METADATA) +/* Только проход препроцессора сборщика: сохраняем описание для пакета. */ +#define SDBG_LOG(tag, message) SDBG_METADATA_LOG(tag, message) +#define SDBG_LOGIF(tag, condition, message) SDBG_METADATA_LOGIF(tag, condition, message) +#elif defined(SPRINTER_SDBG_ANCHORS) +/* Символ привязан к текущему счётчику адреса, но не добавляет инструкцию. */ +#define SDBG_LOG(tag, message) __asm \ + .globl _spr_sdbg_log_##tag \ + _spr_sdbg_log_##tag = . \ + __endasm +#define SDBG_LOGIF(tag, condition, message) SDBG_LOG(tag, message) +#else +#define SDBG_LOG(tag, message) ((void)0) +#define SDBG_LOGIF(tag, condition, message) ((void)0) +#endif + +#endif diff --git a/tests/hello/hello.c b/tests/hello/hello.c index 445ef17..0ea0486 100644 --- a/tests/hello/hello.c +++ b/tests/hello/hello.c @@ -16,7 +16,7 @@ int main(void) { - errno = -1; + errno = -2; int16_t a0 = 0, a1 = 0; a0 = get_text_attr(); diff --git a/tests/sdbg/fixtures/common.h b/tests/sdbg/fixtures/common.h new file mode 100644 index 0000000..043eb39 --- /dev/null +++ b/tests/sdbg/fixtures/common.h @@ -0,0 +1,5 @@ +/* Общий inline выявляет коллизии CDB между единицами трансляции. */ +static inline unsigned char transform(unsigned char value) +{ + return value ^ 0x5a; +} diff --git a/tests/sdbg/fixtures/helper.c b/tests/sdbg/fixtures/helper.c new file mode 100644 index 0000000..606418f --- /dev/null +++ b/tests/sdbg/fixtures/helper.c @@ -0,0 +1,5 @@ +#include "common.h" +unsigned char helper(unsigned char value) +{ + return transform(value); +} diff --git a/tests/sdbg/fixtures/left/utils.c b/tests/sdbg/fixtures/left/utils.c new file mode 100644 index 0000000..4c3ac1b --- /dev/null +++ b/tests/sdbg/fixtures/left/utils.c @@ -0,0 +1,2 @@ +static int counter; +int left(void) { counter = 1; return counter; } diff --git a/tests/sdbg/fixtures/logmacro.c b/tests/sdbg/fixtures/logmacro.c new file mode 100644 index 0000000..918eb69 --- /dev/null +++ b/tests/sdbg/fixtures/logmacro.c @@ -0,0 +1,17 @@ +#include + +volatile int total; + +#define AUTHOR_LOG(tag, text) SDBG_LOG(tag, text) + +int main(void) +{ + total = 1; + AUTHOR_LOG(after_one, + "total=" "{total}"); +#if 0 + SDBG_LOG(inactive, "should not exist"); +#endif + total = 2; + return total; +} diff --git a/tests/sdbg/fixtures/main.c b/tests/sdbg/fixtures/main.c new file mode 100644 index 0000000..3ea1988 --- /dev/null +++ b/tests/sdbg/fixtures/main.c @@ -0,0 +1,12 @@ +/* Репро карты строк и типов; global/static должны иметь разные адреса. */ +#include "common.h" +int total; +const char *debug_looking_text = "Fmain$text$0_0$0 Lmain.fake"; +static signed char counter; +unsigned char helper(unsigned char value); +int main(void) +{ + counter = -7; + total = helper(transform(3)); + return total + counter; +} diff --git a/tests/sdbg/fixtures/right/utils.c b/tests/sdbg/fixtures/right/utils.c new file mode 100644 index 0000000..29591b6 --- /dev/null +++ b/tests/sdbg/fixtures/right/utils.c @@ -0,0 +1,2 @@ +static int counter; +int right(void) { counter = 2; return counter; } diff --git a/tests/sdbg/run_macro_log_probe.py b/tests/sdbg/run_macro_log_probe.py new file mode 100644 index 0000000..fef8fc8 --- /dev/null +++ b/tests/sdbg/run_macro_log_probe.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Живой DAP-проход: C-макрос → breakpoint → обе console без DSS-вывода.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(ROOT/'toolchain')) +from run_vscode_dap_probe import receive, send, wait_response +from sdbg.server import rpc_call + + +def main(): + provider = 'osx' if '--osx' in sys.argv else 'sdbg' + source = ROOT/'tests/sdbg/fixtures/logmacro.c' + with tempfile.TemporaryDirectory(prefix='sprinter-macro-live-') as temp: + exe = Path(temp)/'logmacro.exe' + build = subprocess.run([str(ROOT/'bin/sprinter-cc'), '-o', str(exe), + '--src-debug', str(source)], cwd=ROOT, + env=dict(os.environ, SPRINTER_PYTHON=sys.executable), + capture_output=True, text=True) + if build.returncode: + raise RuntimeError('Не собрано приложение с SDBG_LOG: '+build.stdout+build.stderr) + process = 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(process, 1, 'initialize') + _, buffer, _ = wait_response(process, buffer, 'initialize', 10) + send(process, 2, 'launch', {'build': str(exe.with_name('.sprinter-cc-logmacro')), + 'debugger': provider, 'dssTimeout': 30}) + launched, buffer, events = wait_response(process, buffer, 'launch', 80) + if not any(event.get('event') == 'initialized' for event in events): + event, buffer = receive(process, buffer, 5) + if event.get('event') != 'initialized': + raise RuntimeError('Нет DAP initialized: '+str(event)) + send(process, 3, 'configurationDone') + _, buffer, _ = wait_response(process, buffer, 'configurationDone', 10) + send(process, 4, 'stackTrace') + frames, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + if not frames['body']['stackFrames'][0]['name'].startswith('main'): + raise RuntimeError('Перед стартом нет main: '+str(frames['body']['stackFrames'])) + send(process, 5, 'continue') + _, buffer, events = wait_response(process, buffer, 'continue', 10) + outputs = [event for event in events if event.get('event') == 'output'] + deadline = time.monotonic()+25 + while not any('total=1\n' == event.get('body', {}).get('output') for event in outputs): + if time.monotonic() >= deadline: + raise RuntimeError('SDBG_LOG не пришёл в DAP Debug Console: '+str(outputs[-4:])) + event, buffer = receive(process, buffer, deadline-time.monotonic()) + if event.get('event') == 'terminated': + raise RuntimeError('DAP завершился до авторского лога') + if event.get('event') == 'output': outputs.append(event) + socket = launched['body']['socket'] + tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6) + if not any('total=1' in line for line in tail['lines']): + raise RuntimeError('В debugger console MAME нет total=1: '+str(tail)) + print(json.dumps({'event': 'macro_dual_console_verified', + 'provider': provider, 'dap_output': 'total=1', + 'mame_console': [line for line in tail['lines'] if 'total=1' in line], + 'entry': launched['body']['entry']}, ensure_ascii=False), flush=True) + send(process, 6, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + finally: + if process.poll() is None: process.terminate() + try: _, stderr = process.communicate(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + _, stderr = process.communicate() + if stderr: + print(stderr.decode(errors='replace')[-3000:], file=sys.stderr) + + +if __name__ == '__main__': + try: main() + except (RuntimeError, TimeoutError, OSError, KeyError, ValueError) as error: + print('macro-log-probe: '+str(error), file=sys.stderr) + sys.exit(1) diff --git a/tests/sdbg/run_mame_probe.py b/tests/sdbg/run_mame_probe.py new file mode 100644 index 0000000..8307f8a --- /dev/null +++ b/tests/sdbg/run_mame_probe.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Изолированный MAME-репро: загрузка EXE, проверка main и async step. + +Использует копию системного HDD и собственную дискету/каталоги состояния. +Ничего не меняет в общих media или уже запущенном MAME. +""" +import importlib.util +import json +import os +from pathlib import Path +import select +import shutil +import subprocess +import sys +import time +import uuid + +# Запуск через системный python3 тоже подхватывает local pyenv проекта. +if sys.version_info < (3, 12): + os.execvp('pyenv', ['pyenv', 'exec', 'python', __file__, *sys.argv[1:]]) + +ROOT=Path(__file__).resolve().parents[2] +os.environ['SPRINTER_PYTHON'] = sys.executable +sys.path.insert(0,str(ROOT/'toolchain')) +from sdbg.model import DebugMap +from sdbg.image import read_ihx +from sdbg.session import DebugSession +from mame_interactive import build_events + + +def wait_dap_stop(engine, timeout=15): + deadline=time.monotonic()+timeout + seen=[] + while time.monotonic()=events[1][1]-0.5 then + bp=cpu.debug:bpset(MAIN,"", "") + phase="loading" + machine.video:snapshot() + emit('{"event":"dss_wait_complete","time":' .. now() .. '}') + emit('{"event":"entry_armed","time":' .. now() .. '}') + end + if not launch_reported and now()>=events[1][1] then + launch_reported=true + emit('{"event":"launch_input_started","time":' .. now() .. '}') + end + while index<=#events and now()>=events[index][1] do + local e=events[index] + for _,field in pairs(machine.ioport.ports[e[2]].fields) do + if field.mask==e[3] then field:set_value(e[4]);break end + end + index=index+1 + end + if debug.execution_state=="stop" then + local pc=cpu.state.PC.value + if phase=="loading" then + local match=pc==MAIN + for i,b in ipairs(signature) do + if cpu.spaces.program:read_u8(0x10000+MAIN+i-1)~=b then match=false end + end + if not match then false_hits=false_hits+1;debug.execution_state="run";return end + emit('{"event":"main","pc":' .. pc .. ',"ticks":' .. ticks .. + ',"false_hits":' .. false_hits .. '}') + cpu.debug:bpclear(bp) + if os.getenv("SDBG_SESSION_ID") then phase="bridge_wait";return end + phase="stepping" + last_time=now() + cpu.debug:step(1) + emit('{"event":"step_requested","pc":' .. cpu.state.PC.value .. '}') + elseif phase=="stepping" and now()>last_time then + emit('{"event":"step_completed","pc":' .. pc .. ',"ticks":' .. ticks .. '}') + report:close() + machine:exit() + end + end + if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end +end) +'''.replace('REPORT',json.dumps(str(report))).replace('EVENTS',ev) + .replace('SIGNATURE',','.join(map(str,signature))).replace('MAIN',str(main_address))) + command=[str(ROOT/'mame/v306/mame.arm'),'sprinter','-noreadconfig', + '-rompath',str(ROOT/'mame/v306/roms'),'-bios','v3.06','-kbd','ms_naturl,bios=sp2k', + '-video','none','-sound','none','-nothrottle','-skip_gameinfo', + '-beta:wd179x:0','35hd','-flop1',str(work/'probe.img'), + '-hard1',str(system),'-debug','-debugger','none', + '-autoboot_delay','0','-autoboot_script',str(lua)] + for key in ['nvram','cfg','diff','snapshot']: + (work/key).mkdir(exist_ok=True) + command.extend(['-'+key+'_directory',str(work/key)]) + bridge_mode = '--bridge' in sys.argv + server_mode = '--server' in sys.argv + env = dict(os.environ) + if bridge_mode: + command[command.index('-debugger')+1] = ( + 'osx' if '--native-debugger' in sys.argv else 'sdbg') + command[command.index('-video')+1]='soft' + command.append('-window') + ipc=work/('ipc-'+uuid.uuid4().hex) + ipc.mkdir() + env.update(SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=uuid.uuid4().hex) + command.extend(['-verbose','-plugin','sdbgbridge','-pluginspath', + str(ROOT/'toolchain/mcp')+';'+str(ROOT/'mame/sources/MAME/plugins')]) + with (work/'mame.log').open('w') as log: + if not bridge_mode: + result=subprocess.run(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT,timeout=55) + else: + from sdbg.transport import FileBridge, BridgeError + process=subprocess.Popen(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT) + bridge=None + server_process=None + try: + deadline=time.monotonic()+50 + while '"event":"main"' not in report.read_text(): + if process.poll() is not None or time.monotonic()>deadline: + raise RuntimeError('Нет остановки main; см. '+str(work/'mame.log')) + time.sleep(.05) + if server_mode: + assert banked_mode,'--server live probe пока требует --banked' + from sdbg.dap import DapEngine + socket_path=Path('/tmp')/('sprinter-sdbg-'+uuid.uuid4().hex+'.sock') + server_process=subprocess.Popen([ + sys.executable,str(ROOT/'toolchain/sdbg_server.py'), + '--build',str(model.directory),'--ipc',str(ipc), + '--session',env['SDBG_SESSION_ID'],'--socket',str(socket_path)], + stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + ready=server_process.stdout.readline() + assert ready and json.loads(ready)['ready'],server_process.stderr.read() + try: + FileBridge(ipc,env['SDBG_SESSION_ID']) + raise AssertionError('Session server не удержал owner lock') + except BridgeError: pass + dap=DapEngine() + attached,_=dap.handle('attach',{'socket':str(socket_path)}) + assert attached['buildId']==model.manifest['build_id'] + started=time.monotonic() + points,_=dap.handle('setFunctionBreakpoints', + {'breakpoints':[{'name':'worker'}]}) + assert points['breakpoints'][0]['verified'] + dap.handle('continue',{}) + deadline=time.monotonic()+5 + dap_events=[] + while time.monotonic()=1 + bridge.request('continue') + stopped=bridge.wait_stopped() + location=session.where(stopped) + assert location['status'] in ('mapped','ambiguous'), (location,stopped,attached) + assert location['function']['name']=='worker' + assert location['function']['bank']==1 + session.clear_breakpoint(breakpoint['id']) + after={'pc':stopped['registers']['PC']} + else: + bridge.request('step') + after=session.where(bridge.wait_stopped()) + assert after['pc']==main_address+3 + bridge.request('step') + session.where(bridge.wait_stopped()) + assert session.read_variable('total')['value']==42 + breakpoint=session.break_line(str(source),5) + bridge.request('continue') + stopped=bridge.wait_stopped() + location=session.where(stopped) + assert location['status']=='mapped' + assert location['sources']==[{'file':str(source.resolve()),'line':5}] + session.clear_breakpoint(breakpoint['id']) + events=bridge.request('events',after=0) + assert any(event['kind']=='stopped' for event in events['events']) + final_total=session.read_variable('total')['value'] if not banked_mode else None + debug_wall_ms=round((time.monotonic()-started)*1000,2) + with report.open('a') as file: + file.write(json.dumps({'event':'bridge_verified','pc_before':main_address, + 'pc_after_step':after['pc'], + 'line_break_pc':stopped['registers']['PC'], + 'total':final_total, + 'bank':location['function'].get('bank'), + 'step_wall_ms':debug_wall_ms, + 'generation':bridge.generation})+'\n') + finally: + if bridge: bridge.close() + if server_process and server_process.poll() is None: + server_process.terminate() + try: server_process.wait(timeout=3) + except subprocess.TimeoutExpired: server_process.kill();server_process.wait() + if process.poll() is None: + process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: process.kill();process.wait() + print(report.read_text()) + if not bridge_mode and (result.returncode or 'step_completed' not in report.read_text()): + print((work/'mame.log').read_text());return 1 + return 0 + +if __name__=='__main__':sys.exit(main()) diff --git a/tests/sdbg/run_vscode_dap_probe.py b/tests/sdbg/run_vscode_dap_probe.py new file mode 100644 index 0000000..85ddf9f --- /dev/null +++ b/tests/sdbg/run_vscode_dap_probe.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Живой stdio DAP-проход для проверки VS Code → launcher → MAME. + +Запускает изолированный MAME и завершает только процессы этого репро. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import select +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +PYTHON = Path.home()/'.pyenv/shims/python' +sys.path.insert(0, str(ROOT/'toolchain')) +from mame_interactive import resolve +from sdbg.server import rpc_call + + +def send(process, sequence, command, arguments=None): + body = json.dumps({'seq': sequence, 'type': 'request', 'command': command, + 'arguments': arguments or {}}).encode() + frame = f'Content-Length: {len(body)}\r\n\r\n'.encode()+body + process.stdin.write(frame) + process.stdin.flush() + + +def receive(process, buffer, timeout): + deadline = time.monotonic()+timeout + while True: + separator = buffer.find(b'\r\n\r\n') + if separator >= 0: + header = buffer[:separator] + length = None + for line in header.split(b'\r\n'): + if line.lower().startswith(b'content-length:'): + length = int(line.split(b':', 1)[1].strip()) + if length is None: + raise RuntimeError('DAP frame без Content-Length') + end = separator+4+length + if len(buffer) >= end: + return json.loads(buffer[separator+4:end]), buffer[end:] + remaining = deadline-time.monotonic() + if remaining <= 0: + raise TimeoutError('DAP response timeout') + readable, _, _ = select.select([process.stdout], [], [], remaining) + if not readable: + raise TimeoutError('DAP response timeout') + chunk = os.read(process.stdout.fileno(), 65536) + if not chunk: + raise RuntimeError(f'DAP завершился, rc={process.poll()}') + buffer += chunk + + +def wait_response(process, buffer, command, timeout): + deadline = time.monotonic()+timeout + events = [] + while time.monotonic() < deadline: + message, buffer = receive(process, buffer, deadline-time.monotonic()) + if message.get('type') == 'event': + events.append(message) + continue + if message.get('command') == command: + if not message.get('success'): + raise RuntimeError(f'{command}: {message.get("message")}') + return message, buffer, events + raise TimeoutError(command) + + +def wait_event(process, buffer, event, timeout): + deadline = time.monotonic()+timeout + while time.monotonic() < deadline: + try: + message, buffer = receive(process, buffer, deadline-time.monotonic()) + except TimeoutError: + return None, buffer + if message.get('event') == 'terminated': + raise RuntimeError('DAP session завершилась во время '+event) + if message.get('event') == event: + return message, buffer + return None, buffer + + +def main(): + if not PYTHON.is_file(): + raise RuntimeError('Нет local pyenv shim: '+str(PYTHON)) + build = ROOT/'tests/hello/.sprinter-cc-hello' + command = [str(PYTHON), str(ROOT/'toolchain/sdbg_dap.py')] + process = subprocess.Popen(command, cwd=ROOT, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + buffer = b'' + try: + send(process, 1, 'initialize') + _, buffer, _ = wait_response(process, buffer, 'initialize', 10) + send(process, 2, 'launch', {'build': str(build), 'debugger': 'sdbg', + 'dssTimeout': 30}) + launched, buffer, events = wait_response(process, buffer, 'launch', 80) + if not any(item.get('event') == 'initialized' for item in events): + initialized, buffer = receive(process, buffer, 5) + if initialized.get('event') != 'initialized': + raise RuntimeError('Нет initialized: '+str(initialized)) + send(process, 3, 'configurationDone') + _, buffer, _ = wait_response(process, buffer, 'configurationDone', 10) + send(process, 4, 'stackTrace') + frame_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + frame = frame_response['body']['stackFrames'][0] + if frame['name'] != 'main': + raise RuntimeError('Не main: '+str(frame)) + if '--waitkey' in sys.argv: + send(process, 5, 'setBreakpoints', { + 'source': {'path': str(ROOT/'tests/hello/hello.c')}, + 'breakpoints': [{'line': 62}], + }) + points, buffer, _ = wait_response(process, buffer, 'setBreakpoints', 10) + if not points['body']['breakpoints'][0]['verified']: + raise RuntimeError('Не подтверждена точка getchar') + send(process, 6, 'continue') + _, buffer, _ = wait_response(process, buffer, 'continue', 10) + stopped, buffer = wait_event(process, buffer, 'stopped', 30) + if not stopped: + raise RuntimeError('Нет остановки перед getchar') + send(process, 7, 'stackTrace') + at_getchar, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + before = at_getchar['body']['stackFrames'][0] + if before['line'] != 62: + raise RuntimeError('Остановка не на getchar: '+str(before)) + started = time.monotonic() + send(process, 8, 'next') + _, buffer, _ = wait_response(process, buffer, 'next', 3) + response_ms = round((time.monotonic()-started)*1000, 2) + if '--emulated-key' in sys.argv: + socket_path = launched['body']['socket'] + first = rpc_call(socket_path, 'snapshot', timeout=5) + time.sleep(1) + second = rpc_call(socket_path, 'snapshot', timeout=5) + if second['paused'] or second['time'] <= first['time']: + raise RuntimeError('MAME не обновляет input frames: '+ + str((first, second))) + tag, mask, _ = resolve('x') + rpc_call(socket_path, 'input_key', + {'tag': tag, 'mask': mask, 'down': True}, timeout=5) + time.sleep(.15) + rpc_call(socket_path, 'input_key', + {'tag': tag, 'mask': mask, 'down': False}, timeout=5) + key_stop, buffer = wait_event(process, buffer, 'stopped', 10) + if not key_stop: + raise RuntimeError('PC keyboard ioport не завершил WAITKEY') + send(process, 9, 'stackTrace') + after_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + after = after_response['body']['stackFrames'][0] + if after['line'] != 63: + raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) + ascii_code = rpc_call(socket_path, 'registers', timeout=5) + ascii_code = ascii_code['registers']['DE'] & 0xff + if ascii_code != ord('x'): + raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') + print(json.dumps({'event': 'mame_keyboard_port_verified', + 'at': before, 'after': after, + 'ascii': ascii_code, + 'emulated_time_advanced': second['time']-first['time'], + 'next_response_ms': response_ms}, + ensure_ascii=False), flush=True) + send(process, 10, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + return 0 + if '--manual-key' in sys.argv: + socket_path = launched['body']['socket'] + keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards'] + if keyboard_state.get(':kbd:ms_naturl') is not True: + raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+ + str(keyboard_state)) + print(json.dumps({'event': 'manual_key_ready', + 'mame_pid': launched['body']['mamePid'], + 'keyboards': keyboard_state, + 'instruction': 'Click this MAME Sprinter window and press x'}, + ensure_ascii=False), flush=True) + key_stop, buffer = wait_event(process, buffer, 'stopped', 45) + if not key_stop: + raise RuntimeError('Ручное нажатие x не завершило WAITKEY за 45 секунд') + send(process, 9, 'stackTrace') + after_response, buffer, _ = wait_response(process, buffer, 'stackTrace', 10) + after = after_response['body']['stackFrames'][0] + if after['line'] != 63: + raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) + ascii_code = rpc_call(socket_path, 'registers', timeout=5) + ascii_code = ascii_code['registers']['DE'] & 0xff + if ascii_code != ord('x'): + raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') + print(json.dumps({'event': 'physical_mame_key_verified', + 'at': before, 'after': after, + 'ascii': ascii_code, + 'next_response_ms': response_ms}, + ensure_ascii=False), flush=True) + send(process, 10, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + return 0 + early, buffer = wait_event(process, buffer, 'stopped', 12) + if early: + raise RuntimeError('getchar завершился без нового ввода: '+str(early)) + send(process, 9, 'pause') + _, buffer, pause_events = wait_response(process, buffer, 'pause', 6) + if not any(item.get('event') == 'stopped' for item in pause_events): + paused, buffer = wait_event(process, buffer, 'stopped', 6) + if not paused: + raise RuntimeError('Pause недоступен во время WAITKEY') + print(json.dumps({'event': 'waitkey_step_remains_responsive', + 'at': before, 'next_response_ms': response_ms, + 'wait_without_key_s': 12, 'pause': 'verified'}, + ensure_ascii=False), flush=True) + send(process, 10, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + return 0 + print(json.dumps({'event': 'vscode_dap_launch_verified', + 'entry': launched['body']['entry'], + 'frame': frame}, ensure_ascii=False), flush=True) + send(process, 5, 'disconnect') + wait_response(process, buffer, 'disconnect', 10) + return 0 + finally: + if process.poll() is None: + process.terminate() + try: + _, stderr = process.communicate(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + _, stderr = process.communicate() + if stderr: + print(stderr.decode(errors='replace')[-4000:], file=sys.stderr) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (RuntimeError, TimeoutError, OSError, KeyError) as error: + print('vscode-dap-probe: '+str(error), file=sys.stderr) + sys.exit(1) diff --git a/tests/sdbg/test_dap.py b/tests/sdbg/test_dap.py new file mode 100644 index 0000000..1d3edbc --- /dev/null +++ b/tests/sdbg/test_dap.py @@ -0,0 +1,146 @@ +"""DAP MVP сообщает только реализованные возможности и точные адреса.""" +from pathlib import Path +import json +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) +from sdbg.dap import DapEngine +from sdbg.session import SessionError + + +class Rpc: + def __init__(self): + self.calls = [] + self.location = { + 'status': 'mapped', 'pc': 0x8100, 'link_address': 0x8100, + 'function': {'name': 'main', 'bank': None}, + 'sources': [{'file': '/src/main.c', 'line': 3}], + } + + def __call__(self, method, arguments): + self.calls.append((method, arguments)) + if method == 'status': + return {'build_id': 'abc', 'event_sequence': 4} + if method in ('where', 'pause', 'step', 'source_step'): + return self.location + if method == 'registers': + return {'registers': {'PC': 0x8100, 'SP': 0xbffe}} + if method == 'variables': + return [{'name': 'total', 'module': None, 'type': 'SI:S', + 'link_address': 0x8200}] + if method == 'read_variable': + return {'value': 42, 'type': 'SI:S', 'link_address': 0x8200} + if method == 'set_source_breakpoints': + return {'breakpoints': [{'id': 7, 'verified': True, 'line': 3, + 'locations': [{'line': 3, 'link_address': 0x8100}]}]} + if method == 'set_function_breakpoints': + return {'breakpoints': [{'id': 8, 'verified': True, + 'locations': [{'link_address': 0x8100}]}]} + if method == 'continue': + return {'accepted': True} + if method == 'events': + return {'events': [], 'last': 4, 'closed': False} + raise AssertionError(method) + + +class DapTests(unittest.TestCase): + def setUp(self): + self.rpc = Rpc() + self.dap = DapEngine(self.rpc) + + def call(self, command, **arguments): + return self.dap.handle(command, arguments) + + def test_initialize_attach_and_frame(self): + capabilities, events = self.call('initialize') + self.assertTrue(capabilities['supportsFunctionBreakpoints']) + self.assertFalse(capabilities['supportsDisassembleRequest']) + self.assertEqual(events, []) + attached, events = self.call('attach') + self.assertEqual(attached['buildId'], 'abc') + self.assertEqual(events, [('initialized', {})]) + stack, _ = self.call('stackTrace') + frame = stack['stackFrames'][0] + self.assertEqual(frame['source']['path'], '/src/main.c') + self.assertEqual(frame['instructionPointerReference'], '0x8100') + + def test_breakpoints_variables_and_restricted_evaluate(self): + self.call('attach') + body, _ = self.call('setBreakpoints', source={'path': '/src/main.c'}, + breakpoints=[{'line': 3, 'logMessage': 'total={total}'}]) + self.assertEqual(body['breakpoints'][0]['id'], 7) + self.assertIn(('set_source_breakpoints', {'file': '/src/main.c', + 'breakpoints': [{'line': 3, 'logMessage': 'total={total}'}]}), self.rpc.calls) + variables, _ = self.call('variables', variablesReference=2) + self.assertEqual(variables['variables'][0]['value'], '42') + evaluated, _ = self.call('evaluate', expression='total') + self.assertEqual(evaluated['result'], '42') + with self.assertRaisesRegex(SessionError, 'только имя'): + self.call('evaluate', expression='total+1') + + def test_source_and_instruction_steps_use_distinct_rpc(self): + self.call('attach') + self.call('stepIn') + self.assertIn(('source_step', {'kind': 'into'}), self.rpc.calls) + self.call('next') + self.assertIn(('source_step', {'kind': 'over'}), self.rpc.calls) + self.call('stepOut') + self.assertIn(('source_step', {'kind': 'out'}), self.rpc.calls) + body, events = self.call('stepIn', granularity='instruction') + self.assertEqual((body, events), ({}, [])) + self.assertIn(('step', {}), self.rpc.calls) + + def test_event_ring_gap_warns_debug_console(self): + self.dap.event_sequence = 4 + self.dap._rpc_override = lambda method, args: { + 'events': [{'seq': 7, 'event': 'output', + 'body': {'category': 'console', 'output': 'total=42\n'}}], + 'first': 7, 'last': 7, 'closed': False, + } + events, closed = self.dap.poll_events(timeout=0) + self.assertFalse(closed) + self.assertEqual(events[0][0], 'output') + self.assertIn('пропущено 2', events[0][1]['output']) + self.assertEqual(events[1][1]['output'], 'total=42\n') + + def test_stdio_framing_without_attach(self): + requests = [ + {'seq': 1, 'type': 'request', 'command': 'initialize', 'arguments': {}}, + {'seq': 2, 'type': 'request', 'command': 'disconnect', 'arguments': {}}, + ] + data = b'' + for request in requests: + body = json.dumps(request).encode() + data += f'Content-Length: {len(body)}\r\n\r\n'.encode() + body + process = subprocess.run([sys.executable, str(ROOT/'toolchain/sdbg_dap.py')], + input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.assertEqual(process.returncode, 0, process.stderr.decode()) + messages = [] + output = process.stdout + while output: + header, output = output.split(b'\r\n\r\n', 1) + length = int(header.split(b':', 1)[1]) + body, output = output[:length], output[length:] + messages.append(json.loads(body)) + self.assertEqual([item['type'] for item in messages], + ['response', 'response']) + + def test_vscode_extension_activates_before_debug_adapter_factory(self): + extension_dir = ROOT / 'toolchain' / 'vscode-sprinter-debug' + manifest = json.loads((extension_dir / 'package.json').read_text()) + self.assertIn('onDebug', manifest['activationEvents']) + self.assertNotIn('onDebug:sprinter-mame', manifest['activationEvents']) + self.assertEqual('auto', manifest['contributes']['configuration'] + ['properties']['sprinterDebugger.pythonCommand']['default']) + self.assertTrue((extension_dir / manifest['main']).is_file()) + debugger_types = { + item['type'] for item in manifest['contributes']['debuggers'] + } + self.assertIn('sprinter-mame', debugger_types) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sdbg/test_launcher.py b/tests/sdbg/test_launcher.py new file mode 100644 index 0000000..b273540 --- /dev/null +++ b/tests/sdbg/test_launcher.py @@ -0,0 +1,48 @@ +"""Генератор launcher Lua не должен менять вложенность событий/сигнатуры.""" +from pathlib import Path +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) +from sdbg_launcher import disk_entries, lua_script, write_keyboard_config + + +class LauncherTests(unittest.TestCase): + def test_lua_tables_have_expected_shape(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'launch.lua' + lua_script(path, Path(directory)/'ready.json', 0x8100, b'\x21\x42', + [(0.0, ':kbd:test', 4, 1)], 0) + text = path.read_text() + self.assertIn('local events={{0.0,":kbd:test",4,1}}', text) + self.assertIn('local signature={33,66}', text) + self.assertIn('local function dss_prompt_row()', text) + self.assertNotIn('local events={{{', text) + self.assertNotIn('local signature={{', text) + + def test_disk_entries_reject_missing_and_83_collision(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory) + exe=root/'program-long.exe';exe.write_bytes(b'x') + duplicate=root/'program-last.exe';duplicate.write_bytes(b'y') + with self.assertRaisesRegex(ValueError,'8.3'): + disk_entries(exe,[duplicate]) + with self.assertRaisesRegex(ValueError,'Не найден'): + disk_entries(exe,[root/'missing.dat']) + + def test_isolated_config_enables_both_sprinter_keyboards(self): + with tempfile.TemporaryDirectory() as directory: + write_keyboard_config(Path(directory)) + root = ET.parse(Path(directory)/'sprinter.cfg').getroot() + self.assertEqual(root.attrib['version'], '10') + self.assertEqual(root.find('system').attrib['name'], 'sprinter') + keyboards = {item.attrib['tag']: item.attrib['enabled'] + for item in root.findall('./system/input/keyboard')} + self.assertEqual(keyboards, {':': '1', ':kbd:ms_naturl': '1'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sdbg/test_sdbg.py b/tests/sdbg/test_sdbg.py new file mode 100644 index 0000000..acde7ae --- /dev/null +++ b/tests/sdbg/test_sdbg.py @@ -0,0 +1,231 @@ +"""Регрессии реального SDCC/linker: код, коллизии, банк, stale и ошибки.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +os.environ['SPRINTER_PYTHON'] = sys.executable +sys.path.insert(0, str(ROOT/'toolchain')) +from sdbg.build import normalize +from sdbg.model import DebugMap + + +class Integration(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix='sdbg-tests-') + cls.work = Path(cls.temp.name) + cls.fixtures = cls.work/'src' + shutil.copytree(ROOT/'tests/sdbg/fixtures', cls.fixtures) + + @classmethod + def tearDownClass(cls): cls.temp.cleanup() + + def build(self, name, sources, *flags, success=True): + output = self.work/(name+'.exe') + result = subprocess.run([str(ROOT/'bin/sprinter-cc'), '-o', str(output), + *flags, *map(str,sources)], cwd=ROOT, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if success: self.assertEqual(result.returncode,0,result.stdout) + else: self.assertNotEqual(result.returncode,0,result.stdout) + return output, result + + def model(self, name): return DebugMap(self.work/('.sprinter-cc-'+name)) + + def test_01_raw_collision_and_fixed_byte_identity(self): + sources = [self.fixtures/'main.c', self.fixtures/'helper.c'] + sdcc = str(ROOT/'third_party/sdcc/bin/sdcc') + objects=[] + for source in sources: + obj=self.work/(source.stem+'.rel') + subprocess.run([sdcc,'-mz80','--debug','-c','-o',str(obj),str(source)],check=True) + objects.append(str(obj)) + raw=subprocess.run([sdcc,'-mz80','--debug','--no-std-crt0','-o',str(self.work/'raw.ihx'),*objects], + text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) + self.assertNotEqual(raw.returncode,0) + self.assertIn('Multiple definition of C$',raw.stdout) + plain,_=self.build('plain',sources) + debug,_=self.build('debug',sources,'--src-debug') + self.assertEqual(plain.read_bytes(),debug.read_bytes()) + self.assertIn(b'Fmain$text$0_0$0 Lmain.fake', debug.read_bytes()) + model=self.model('debug') + matches=[f for f in model.functions if f['name']=='transform'] + self.assertEqual(len(matches),2) + self.assertNotEqual(matches[0]['start'],matches[1]['start']) + for f in matches: + self.assertTrue(model.addr2line(f['start'])['sources']) + self.assertEqual(model.addr2line(0xffff)['status'],'unknown') + total=next(v for v in model.variables if v['name']=='total') + counter=next(v for v in model.variables if v['name']=='counter') + self.assertEqual(total['size'],2) + self.assertTrue(counter['signed']) + self.assertEqual(counter['size'],1) + model.verify_executable() + + def test_02_banks_and_windows(self): + source=ROOT/'tests/banked' + for mode,base in [('huge',0xc000),('big',0x4000)]: + flags=['--memory',mode,'--bank','1='+str(source/'bank1.c'), + '--bank','2='+str(source/'bank2.c')] + plain,_=self.build(mode+'plain',[source/'banked.c'],*flags) + debug,_=self.build(mode,[source/'banked.c'],'--src-debug',*flags) + self.assertEqual(plain.read_bytes(),debug.read_bytes()) + model=self.model(mode) + for bank in (1,2): + function=next(f for f in model.functions if f['name']==f'bank{bank}_func') + self.assertEqual(function['bank'],bank) + self.assertEqual(function['logical_address'],base+12) + # У sprinter_page_w1 в каждом TU существуют начало и epilogue. + locations=model.line_locations('sprinter.h',111)['locations'] + self.assertEqual(len(locations),6) + self.assertEqual({l['bank'] for l in locations},{None,1,2}) + + def test_03_failure_preserves_published_package(self): + source=self.fixtures/'main.c' + sources=[source,self.fixtures/'helper.c'] + exe,_=self.build('transaction',sources,'--src-debug') + before=exe.read_bytes() + manifest=(self.work/'.sprinter-cc-transaction/manifest.json').read_bytes() + broken=self.work/'broken.c' + broken.write_text('extern void missing(void); int main(void) { missing(); return 0; }') + _,result=self.build('transaction',[broken],'--src-debug',success=False) + self.assertIn('missing',result.stdout) + self.assertEqual(exe.read_bytes(),before) + self.assertEqual((self.work/'.sprinter-cc-transaction/manifest.json').read_bytes(),manifest) + self.model('transaction').verify_executable() + + def test_04_same_basename_and_bank_data(self): + main=self.work/'names.c' + main.write_text('int left(void); int right(void); int main(void) {return left()+right();}') + self.build('names',[main,self.fixtures/'left/utils.c',self.fixtures/'right/utils.c'],'--src-debug') + model=self.model('names') + self.assertEqual(len([f for f in model.functions if f['name'] in ('left','right')]),2) + self.assertEqual(len([v for v in model.variables if v['name']=='counter']),2) + bank=self.work/'data.c' + bank.write_text('volatile int value; void fun(void) __banked {value=42;}') + main.write_text('void fun(void) __banked; int main(void) {fun();return 0;}') + self.build('data',[main],'--src-debug','--memory','big','--bank','1='+str(bank),'--bank-data') + value=next(v for v in self.model('data').variables if v['name']=='value') + self.assertEqual(value['bank'],1) + self.assertEqual(value['window'],1) + + def test_05_stale_sources_and_corrupt_artifacts(self): + source=self.work/'stale.c' + source.write_text('int main(void) {return 42;}') + self.build('stale',[source],'--src-debug') + source.write_text('int main(void) {return 0;}') + model=self.model('stale') + self.assertIn(str(source.resolve()),model.stale_sources) + self.assertIn('42',model.source_text(str(source.resolve()),1)) + asm=next((self.work/'.sprinter-cc-stale').glob('*.asm')) + asm.write_text('corrupted') + with self.assertRaisesRegex(ValueError,'артефакт'): self.model('stale') + + def test_06_selected_tu_and_argument_errors(self): + sources=[self.fixtures/'main.c',self.fixtures/'helper.c'] + self.build('selected',sources,'--src-debug-file',str(sources[0])) + self.assertEqual(len(self.model('selected').units),1) + self.build('invalid',sources,'--src-debug-file','nonexistent.c',success=False) + self.build('invalid',sources,'--src-debug','--src-debug-file',str(sources[0]),success=False) + + + def test_07_make_configuration_and_header_dependency(self): + directory=self.work/'make-project' + shutil.copytree(self.fixtures,directory) + (directory/'Makefile').write_text( + f'PROJ_ROOT := {ROOT}\nEXAMPLE := main\nEXTRA_SRCS := helper.c\n' + f'include {ROOT}/app.mk\n') + def make(*options): + result=subprocess.run(['make','SRC_DEBUG=1','PYTHON='+sys.executable,*options], + cwd=directory,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) + self.assertEqual(result.returncode,0,result.stdout) + return result.stdout + self.assertIn('sprinter-cc: wrote',make()) + self.assertNotIn('sprinter-cc: wrote',make()) + before=json.loads((directory/'.sprinter-cc-main/manifest.json').read_text())['build_id'] + header=directory/'common.h' + header.write_text(header.read_text()+'\n/* Правка включённого заголовка. */\n') + self.assertIn('sprinter-cc: wrote',make()) + after=json.loads((directory/'.sprinter-cc-main/manifest.json').read_text())['build_id'] + self.assertNotEqual(before,after) + self.assertNotIn('sprinter-cc: wrote',make()) + self.assertIn('sprinter-cc: wrote',make('SRC_DEBUG=0')) + self.assertNotIn('sprinter-cc: wrote',make('SRC_DEBUG=0')) + + def test_09_log_macro_has_linked_anchor_without_exe_growth(self): + source = self.fixtures/'logmacro.c' + plain, _ = self.build('logmacro_plain', [source]) + debug, _ = self.build('logmacro_debug', [source], '--src-debug') + self.assertEqual(plain.read_bytes(), debug.read_bytes()) + self.assertNotIn(b'total=', debug.read_bytes()) + model = self.model('logmacro_debug') + self.assertEqual([item['tag'] for item in model.logpoints], ['after_one']) + anchor = model.logpoints[0] + self.assertTrue(anchor['verified'], anchor) + self.assertEqual(anchor['message'], 'total={total}') + self.assertEqual(anchor['line'], 10) + self.assertIn(anchor['link_address'], model.instructions) + self.assertEqual(model.symbols[anchor['symbol']], anchor['link_address']) + + def test_10_duplicate_macro_tag_preserves_published_package(self): + source = self.fixtures/'logmacro.c' + exe, _ = self.build('logmacro_transaction', [source], '--src-debug') + before = exe.read_bytes() + manifest = (self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes() + duplicate = self.work/'duplicate_log.c' + duplicate.write_text('#include \nint main(void) {\n' + 'SDBG_LOG(same, "one");\nSDBG_LOG(same, "two");\nreturn 0;\n}\n') + _, result = self.build('logmacro_transaction', [duplicate], '--src-debug', success=False) + self.assertIn('tag должен быть уникальным', result.stdout) + self.assertEqual(exe.read_bytes(), before) + self.assertEqual((self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes(), manifest) + invalid_format = self.work/'invalid_log_format.c' + invalid_format.write_text('#include \nint value; int main(void) {' + 'SDBG_LOG(value_log,"value={value:04X}");return 0;}') + _, result = self.build('logmacro_transaction', [invalid_format], '--src-debug', success=False) + self.assertIn('только подстановки', result.stdout) + self.assertEqual(exe.read_bytes(), before) + self.assertEqual((self.work/'.sprinter-cc-logmacro_transaction/manifest.json').read_bytes(), manifest) + + def test_11_banked_macro_uses_typed_location_without_exe_growth(self): + main = self.work/'bank_log_main.c' + bank = self.work/'bank_log_worker.c' + main.write_text('void worker(void) __banked; int main(void) {worker();return 0;}') + bank.write_text('#include \nvolatile int bank_value;\n' + 'void worker(void) __banked {bank_value=1;' + 'SDBG_LOG(bank_hit,"bank_value={bank_value}");bank_value=2;}') + flags = ('--memory', 'big', '--bank', '1='+str(bank)) + plain, _ = self.build('bank_log_plain', [main], *flags) + debug, _ = self.build('bank_log_debug', [main], '--src-debug', *flags) + self.assertEqual(plain.read_bytes(), debug.read_bytes()) + anchor = self.model('bank_log_debug').logpoints[0] + self.assertTrue(anchor['verified'], anchor) + self.assertEqual((anchor['bank'], anchor['window']), (1, 1)) + self.assertEqual(anchor['logical_address'], anchor['link_address'] & 0xffff) + + def test_08_archive_debug_descriptions_are_not_imported(self): + directory=self.work/'archive' + directory.mkdir() + sdcc=ROOT/'third_party/sdcc/bin/sdcc' + from sdbg.build import compile_unit + compile_unit(sdcc,ROOT/'third_party/sdcc/bin/sdasz80',ROOT/'libc/string/strlwr.c', + directory/'strlwr.rel',['-mz80','--std-c99','-I',str(ROOT/'libc/include')]) + subprocess.run([str(ROOT/'third_party/sdcc/bin/sdar'),'rcs',str(directory/'probe.lib'), + str(directory/'strlwr.rel')],check=True) + source=directory/'app.c' + source.write_text('extern char *strlwr(char *); int main(void) {return *strlwr((char*)0x9000);}') + subprocess.run([str(sdcc),'-mz80','--debug','-c','-o',str(directory/'app.rel'),str(source)],check=True) + subprocess.run([str(sdcc),'-mz80','--debug','--no-std-crt0','-o',str(directory/'app.ihx'), + str(directory/'app.rel'),'-L'+str(directory),'-lprobe'],check=True) + records=(directory/'app.cdb').read_text() + self.assertIn('L:G$strlwr$',records) + self.assertNotIn('F:G$strlwr$',records) + self.assertIn('F:G$strlwr$',(directory/'strlwr.adb').read_text()) + + +if __name__ == '__main__': unittest.main() diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py new file mode 100644 index 0000000..e88983c --- /dev/null +++ b/tests/sdbg/test_server.py @@ -0,0 +1,317 @@ +"""Постоянная сессия: локальный RPC и replace source breakpoints.""" +from pathlib import Path +import sys +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) +from sdbg.server import SessionController, SessionRpcServer, rpc_call +from sdbg.session import SessionError + + +class DummyModel: + variables = [] + logpoints = [] + stale_sources = [] + + +class DummyBridge: + def __init__(self): self.calls = [] + def close(self): pass + def request(self, command, **arguments): + self.calls.append((command, arguments)) + if command == 'console_print': return {'printed': True} + raise AssertionError(command) + + +class DummySession: + def __init__(self): + self.bridge = DummyBridge() + self.model = DummyModel() + self.next_id = 1 + self.cleared = [] + self.activated = 0 + + def attach(self): + return {'build_id': 'test', 'location': {'status': 'mapped'}} + + def break_line(self, filename, line, enabled=True): + if line == 99: + raise SessionError('нет адреса') + identifier = self.next_id + self.next_id += 1 + return {'id': identifier, 'backend_ids': [identifier], + 'conditions': [], 'locations': [{'line': line}], 'enabled': enabled} + + def clear_breakpoint(self, identifier): + self.cleared.append(identifier) + return {'cleared': identifier} + + def activate_breakpoints(self): + self.activated += 1 + return {'enabled': self.next_id - 1} + + def read_variable(self, name, module=None): + if name == 'total': return {'value': 42} + if name == 'flag': return {'value': 0} + raise SessionError('нет переменной') + + +class MacroSession(DummySession): + def __init__(self): + super().__init__() + self.model = type('MacroModel', (), { + 'variables': [], 'stale_sources': [], + 'logpoints': [ + {'tag': 'authored', 'source': '/src/main.c', 'line': 3, + 'verified': True, 'message': 'total={total}', + 'condition': None, 'module': None, 'link_address': 0x8100}, + {'tag': 'conditional', 'source': '/src/main.c', 'line': 4, + 'verified': True, 'message': 'never', + 'condition': 'flag', 'module': None, 'link_address': 0x8102}, + ]})() + + def attach(self): + return {'build_id': 'macro', 'location': {'status': 'mapped'}, + 'capabilities': {'console_print': True}} + + def break_anchor(self, location, enabled=True): + identifier = self.next_id + self.next_id += 1 + return {'id': identifier, 'backend_ids': [identifier], + 'locations': [{'link_address': location['link_address']}], + 'conditions': [], 'enabled': enabled} + + +class StepBridge: + def __init__(self, owner): + self.owner = owner + self.calls = [] + + 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, + len(self.owner.locations) - 1) + return {'accepted': True} + if command == 'snapshot': + return {'state': 'stopped'} + raise AssertionError(command) + + def wait_stopped(self, timeout=5): + return {'state': 'stopped'} + + +class StepSession: + def __init__(self): + self.model = DummyModel() + self.locations = [ + {'status': 'mapped', 'link_address': 0x8100, + 'sources': [{'file': '/src/main.c', 'line': 3}]}, + {'status': 'mapped', 'link_address': 0x8102, + 'sources': [{'file': '/src/main.c', 'line': 3}]}, + {'status': 'mapped', 'link_address': 0x8104, + 'sources': [{'file': '/src/main.c', 'line': 4}]}, + ] + self.index = 0 + self.bridge = StepBridge(self) + + def attach(self): + return {'build_id': 'step', 'location': self.locations[0]} + + def where(self, snapshot=None): + return self.locations[self.index] + + +class WaitingBridge: + """Машинный over ожидает внешний ввод, но pause должен остаться доступен.""" + def __init__(self): + self.paused = False + self.calls = [] + + def close(self): pass + + def request(self, command, **arguments): + self.calls.append(command) + if command == 'snapshot': + return {'state': 'stopped' if self.paused else 'running'} + if command == 'pause': + self.paused = True + return {'accepted': True} + if command == 'step_over': + return {'accepted': True} + raise AssertionError(command) + + def wait_stopped(self, timeout=5): + return {'state': 'stopped'} + + +class ServerTests(unittest.TestCase): + @staticmethod + def wait_source_step(controller): + with controller.changed: + if not controller.changed.wait_for(lambda: not controller.running, timeout=2): + raise AssertionError('source step не сообщил остановку') + + def test_rpc_roundtrip_and_events(self): + with tempfile.TemporaryDirectory() as directory: + controller = SessionController(DummySession()) + try: + server = SessionRpcServer(Path(directory) / 's.sock', controller) + except PermissionError: + controller.close() + self.skipTest('sandbox запрещает bind Unix socket') + thread = threading.Thread(target=server.server.serve_forever, daemon=True) + thread.start() + try: + status = rpc_call(server.path, 'status') + self.assertEqual(status['build_id'], 'test') + events = rpc_call(server.path, 'events', {'after': 0}) + self.assertEqual(events['events'][0]['event'], 'stopped') + with self.assertRaisesRegex(SessionError, 'Неизвестный'): + rpc_call(server.path, 'unknown') + finally: + server.server.shutdown() + thread.join() + server.close() + controller.close() + + def test_source_breakpoints_replace_and_rollback(self): + session = DummySession() + controller = SessionController(session) + try: + first = controller.call('set_source_breakpoints', + {'file': 'main.c', 'lines': [3, 5]}) + self.assertEqual([item['line'] for item in first['breakpoints']], [3, 5]) + self.assertEqual(controller.source_breakpoints[str(Path('main.c').resolve())], [1, 2]) + controller.call('set_source_breakpoints', {'file': 'main.c', 'lines': [7]}) + self.assertEqual(session.cleared, [1, 2]) + with self.assertRaisesRegex(SessionError, 'нет адреса'): + controller.call('set_source_breakpoints', {'file': 'main.c', 'lines': [8, 99]}) + self.assertIn(4, session.cleared) + self.assertEqual(controller.source_breakpoints[str(Path('main.c').resolve())], [3]) + finally: + controller.close() + + def test_logpoint_is_bounded_and_does_not_hide_stop(self): + controller = SessionController(DummySession()) + try: + result = controller.call('set_source_breakpoints', { + 'file': 'main.c', + 'breakpoints': [{'line': 3, 'logMessage': 'total={total} {{ok}}'}]}) + identifier = result['breakpoints'][0]['id'] + controller.breakpoint_info[identifier]['locations'] = [{'link_address': 0x8100}] + self.assertTrue(controller._handle_logpoints({'link_address': 0x8100})) + self.assertEqual(controller.events[-1]['body']['output'], 'total=42 {ok}\n') + controller.breakpoint_info[99] = { + 'kind': 'stop', 'message': None, + 'locations': [{'link_address': 0x8100}], 'hits': 0} + self.assertFalse(controller._handle_logpoints({'link_address': 0x8100})) + with self.assertRaisesRegex(SessionError, 'только подстановки'): + controller._validate_log_message('{total+1}') + finally: + controller.close() + + def test_authored_macro_logs_auto_arm_mirror_and_preserve_user_stop(self): + session = MacroSession() + controller = SessionController(session) + try: + self.assertEqual(session.activated, 1) + self.assertEqual([info['tag'] for info in controller.breakpoint_info.values()], + ['authored', 'conditional']) + self.assertTrue(controller._handle_logpoints({'link_address': 0x8100})) + self.assertEqual(controller.events[-1]['body']['output'], 'total=42\n') + self.assertEqual(session.bridge.calls[-1], + ('console_print', {'text': 'total=42'})) + before = len(controller.events) + self.assertTrue(controller._handle_logpoints({'link_address': 0x8102})) + self.assertEqual(len(controller.events), before) + controller.breakpoint_info[99] = { + 'kind': 'stop', 'message': None, + 'locations': [{'link_address': 0x8100}], 'hits': 0} + self.assertFalse(controller._handle_logpoints({'link_address': 0x8100})) + self.assertEqual(controller.events[-1]['body']['output'], 'total=42\n') + controller.call('set_source_breakpoints', {'file': '/src/main.c', 'lines': [5]}) + controller.call('set_source_breakpoints', {'file': '/src/main.c', 'lines': []}) + self.assertEqual(session.cleared, [3]) + self.assertIn(1, controller.breakpoint_info) + finally: + controller.close() + + def test_bounded_event_ring_reports_loss(self): + controller = SessionController(DummySession()) + try: + for number in range(1025): + controller._emit('output', {'output': str(number)}) + result = controller.call('events', {'after': 0, 'timeout': 0}) + self.assertEqual(result['lost'], 2) # entry + первый output + self.assertEqual(result['first'], 3) + self.assertEqual(len(result['events']), 1024) + finally: + controller.close() + + def test_source_step_skips_same_line_and_uses_over_primitive(self): + session = StepSession() + controller = SessionController(session) + try: + result = controller.call('source_step', {'kind': 'into'}) + self.assertEqual(result, {'accepted': True}) + self.wait_source_step(controller) + self.assertEqual(controller.events[-1]['body']['location']['sources'][0]['line'], 4) + self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'], + ['step', 'step']) + session.index = 0 + session.bridge.calls.clear() + result = controller.call('source_step', {'kind': 'over'}) + self.assertEqual(result, {'accepted': True}) + self.wait_source_step(controller) + self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'], + ['step_over', 'step_over']) + session.index = 0 + session.bridge.calls.clear() + result = controller.call('source_step', {'kind': 'out'}) + self.assertEqual(result, {'accepted': True}) + self.wait_source_step(controller) + self.assertEqual([name for name in session.bridge.calls if name != 'snapshot'], + ['step_out', 'step_over']) + finally: + controller.close() + + def test_source_step_preserves_user_breakpoint_on_same_line(self): + session = StepSession() + controller = SessionController(session) + try: + controller.breakpoint_info[1] = { + 'kind': 'stop', 'message': None, + 'locations': [{'link_address': 0x8102}], 'hits': 0} + result = controller.call('source_step', {'kind': 'into'}) + self.assertEqual(result, {'accepted': True}) + self.wait_source_step(controller) + self.assertEqual(controller.events[-1]['body']['reason'], 'breakpoint') + finally: + controller.close() + + def test_waiting_step_over_releases_session_lock_for_pause(self): + session = StepSession() + session.bridge = WaitingBridge() + controller = SessionController(session) + try: + started = controller.call('source_step', {'kind': 'over'}) + self.assertEqual(started, {'accepted': True}) + self.assertTrue(controller.running) + stopped = controller.call('pause', {}) + self.assertEqual(stopped['sources'][0]['line'], 3) + self.assertFalse(controller.running) + self.assertIsNone(controller.source_step) + self.assertIn('pause', session.bridge.calls) + self.assertEqual(controller.events[-1]['body']['reason'], 'pause') + finally: + controller.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sdbg/test_session.py b/tests/sdbg/test_session.py new file mode 100644 index 0000000..1d0d7fb --- /dev/null +++ b/tests/sdbg/test_session.py @@ -0,0 +1,143 @@ +"""Source-session: identity, банковские условия и безопасные переменные.""" +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) +from sdbg.image import read_ihx +from sdbg.session import DebugSession, SessionError + + +def record(kind, address, data=b''): + body = bytes((len(data), address >> 8, address & 0xff, kind)) + data + return ':' + (body + bytes((-sum(body) & 0xff,))).hex().upper() + + +class FakeModel: + def __init__(self, directory): + self.directory = Path(directory) + self.manifest = {'executable': 'test.exe', 'build_id': 'build'} + self.stale_sources = [] + self.symbols = {'_bank_pages': 0x8200} + self.instructions = { + 0x8100: {'link_address': 0x8100, 'logical_address': 0x8100, + 'size': 2, 'bank': None, 'window': 2}, + 0x1c000: {'link_address': 0x1c000, 'logical_address': 0xc000, + 'size': 2, 'bank': 1, 'window': 3}, + } + self.functions = [ + {'name': 'main', 'link_address': 0x8100, 'logical_address': 0x8100, + 'start': 0x8100, 'end': 0x8102, 'bank': None, 'window': 2}, + {'name': 'worker', 'link_address': 0x1c000, 'logical_address': 0xc000, + 'start': 0x1c000, 'end': 0x1c002, 'bank': 1, 'window': 3}, + ] + self.variables = [ + {'name': 'total', 'module': None, 'size': 2, 'type': 'SI:S', + 'signed': True, 'supported': True, 'link_address': 0x8210, + 'logical_address': 0x8210, 'bank': None, 'window': 2}, + ] + + def verify_executable(self): + return None + + def addr2line(self, address): + return {'status': 'mapped', 'address': address} + + def line_locations(self, filename, line): + return {'stale_source': bool(self.stale_sources), + 'locations': [self.functions[1]] if line == 7 else []} + + +class FakeBridge: + def __init__(self, memory, pc=0x8100, pg3=5): + self.memory = memory + self.generation = 1 + self.registers = {'PC': pc, 'PG0': 0, 'PG1': 1, 'PG2': 2, 'PG3': pg3} + self.calls = [] + self.next_id = 10 + + def handshake(self): + return {'protocol': 1, 'capabilities': {'bank_guard': True}} + + def wait_stopped(self, timeout=5): + return {'state': 'stopped', 'registers': dict(self.registers)} + + def request(self, command, **args): + self.calls.append((command, args)) + if command == 'memory': + data = bytes(self.memory.get(args['address'] + i, 0) + for i in range(args['length'])) + return {'hex': data.hex()} + if command == 'breakpoint': + value = {'id': self.next_id} + self.next_id += 1 + return value + if command == 'clear': + return {'cleared': args['id']} + raise AssertionError(command) + + +class SessionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + directory = Path(self.temp.name) + ihx = '\n'.join(( + record(0, 0x8100, b'\x21\x2a'), + record(4, 0, b'\x00\x01'), + record(0, 0xc000, b'\x3e\x07'), + record(1, 0), + )) + '\n' + (directory / 'test.ihx').write_text(ihx) + self.model = FakeModel(directory) + self.memory = {0x8100: 0x21, 0x8101: 0x2a, + 0xc000: 0x3e, 0xc001: 0x07, + 0x8200: 0, 0x8201: 7, + 0x8210: 0xfe, 0x8211: 0xff} + + def tearDown(self): + self.temp.cleanup() + + def test_ihx_rejects_checksum(self): + path = Path(self.temp.name) / 'bad.ihx' + path.write_text(':00000001FE\n') + with self.assertRaisesRegex(ValueError, 'сумм'): + read_ihx(path) + + def test_attach_and_read_signed_variable(self): + session = DebugSession(self.model, FakeBridge(self.memory)) + attached = session.attach() + self.assertEqual(attached['pc'], 0x8100) + self.assertEqual(attached['bank_pages'], {1: 7}) + self.assertEqual(session.read_variable('total')['value'], -2) + + def test_loaded_image_mismatch_is_rejected(self): + self.memory[0x8100] = 0 + with self.assertRaisesRegex(SessionError, 'не соответствует'): + DebugSession(self.model, FakeBridge(self.memory)).attach() + + def test_banked_breakpoint_has_page_condition(self): + bridge = FakeBridge(self.memory) + session = DebugSession(self.model, bridge) + session.attach() + result = session.break_line('worker.c', 7) + self.assertEqual(result['backend_ids'], [10]) + self.assertIn(('breakpoint', {'address': 0xc000, 'window': 3, 'page': 7}), + bridge.calls) + session.clear_breakpoint(result['id']) + + def test_stale_source_and_unmapped_bank_are_rejected(self): + session = DebugSession(self.model, FakeBridge(self.memory)) + session.attach() + self.model.stale_sources = ['worker.c'] + with self.assertRaisesRegex(SessionError, 'пересоберите'): + session.break_line('worker.c', 7) + bridge = FakeBridge(self.memory, pc=0xc000, pg3=7) + session = DebugSession(self.model, bridge) + attached = session.attach() + self.assertEqual(attached['location']['link_address'], 0x1c000) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/sdbg/test_transport.py b/tests/sdbg/test_transport.py new file mode 100644 index 0000000..cefb911 --- /dev/null +++ b/tests/sdbg/test_transport.py @@ -0,0 +1,71 @@ +"""Контракт файлового канала: владение, атомарный ответ и неизвестный итог.""" +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'toolchain')) +from sdbg.build import write_json +from sdbg.transport import BridgeError, FileBridge + + +class TransportTests(unittest.TestCase): + def test_owner_lock(self): + with tempfile.TemporaryDirectory() as directory: + first = FileBridge(directory, 'one') + try: + with self.assertRaisesRegex(BridgeError, 'управляющая'): + FileBridge(directory, 'one') + finally: + first.close() + with self.assertRaisesRegex(BridgeError, 'закрыт'): + first.request('continue') + second = FileBridge(directory, 'one') + second.close() + + def test_response_generation_and_session(self): + with tempfile.TemporaryDirectory() as directory: + bridge = FileBridge(directory, 'session', timeout=1) + def backend(): + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + requests = list(Path(directory).glob('req_*.json')) + if requests: + request = requests[0] + data = json.loads(request.read_text()) + response = request.with_name(request.name.replace('req_', 'resp_')) + write_json(response, {'session': data['session'], 'generation': 7, + 'ok': True, 'result': {'state': 'stopped'}}) + request.unlink() + return + time.sleep(.001) + thread = threading.Thread(target=backend) + thread.start() + try: + self.assertEqual(bridge.request('snapshot')['state'], 'stopped') + self.assertEqual(bridge.generation, 7) + finally: + thread.join() + bridge.close() + + def test_timeout_never_retries_mutation(self): + with tempfile.TemporaryDirectory() as directory: + bridge = FileBridge(directory, 'session', timeout=.025) + try: + with self.assertRaisesRegex(BridgeError, 'Timeout'): + bridge.request('continue') + self.assertTrue(bridge.invalid) + with self.assertRaisesRegex(BridgeError, 'инвалидирован'): + bridge.request('continue') + self.assertFalse(list(Path(directory).glob('req_*.json'))) + finally: + bridge.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/toolchain/apply-mame-sdbg-patch.sh b/toolchain/apply-mame-sdbg-patch.sh new file mode 100755 index 0000000..d366d26 --- /dev/null +++ b/toolchain/apply-mame-sdbg-patch.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Применяет воспроизводимый backend sdbg к checkout MAME 0.287. +set -eu + +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +mame_source=${1:-"$project_dir/mame/sources/MAME"} +patch_file="$project_dir/toolchain/mame-patches/0001-sdbg-debugger-backend.patch" + +if git -C "$mame_source" apply --reverse --check "$patch_file" 2>/dev/null; then + echo "MAME sdbg backend уже применён: $mame_source" +elif git -C "$mame_source" apply --check "$patch_file"; then + git -C "$mame_source" apply "$patch_file" + echo "MAME sdbg backend применён: $mame_source" +else + echo "Не удалось применить sdbg patch к $mame_source" >&2 + echo "Ожидается MAME 0.287, commit b0c4527c." >&2 + exit 1 +fi diff --git a/toolchain/mame-patches/0001-sdbg-debugger-backend.patch b/toolchain/mame-patches/0001-sdbg-debugger-backend.patch new file mode 100644 index 0000000..67e46b9 --- /dev/null +++ b/toolchain/mame-patches/0001-sdbg-debugger-backend.patch @@ -0,0 +1,129 @@ +diff --git a/scripts/src/osd/modules.lua b/scripts/src/osd/modules.lua +--- a/scripts/src/osd/modules.lua ++++ b/scripts/src/osd/modules.lua +@@ -69,6 +69,7 @@ + MAME_DIR .. "src/osd/modules/debugger/debugkeyconfig.h", + MAME_DIR .. "src/osd/modules/debugger/debuggdbstub.cpp", + MAME_DIR .. "src/osd/modules/debugger/debugimgui.cpp", ++ MAME_DIR .. "src/osd/modules/debugger/sdbg.cpp", + MAME_DIR .. "src/osd/modules/debugger/debugwin.cpp", + MAME_DIR .. "src/osd/modules/debugger/none.cpp", + MAME_DIR .. "src/osd/modules/debugger/xmlconfig.cpp", +diff --git a/scripts/src/osd/mac.lua b/scripts/src/osd/mac.lua +--- a/scripts/src/osd/mac.lua ++++ b/scripts/src/osd/mac.lua +@@ -78,6 +78,7 @@ + + files { + MAME_DIR .. "src/osd/modules/debugger/debugosx.mm", ++ MAME_DIR .. "src/osd/modules/debugger/sdbgmac.mm", + MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.mm", + MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.h", + MAME_DIR .. "src/osd/modules/debugger/osx/consoleview.mm", +diff --git a/src/osd/modules/debugger/sdbg.cpp b/src/osd/modules/debugger/sdbg.cpp +new file mode 100644 +--- /dev/null ++++ b/src/osd/modules/debugger/sdbg.cpp +@@ -0,0 +1,72 @@ ++// license:BSD-3-Clause ++// copyright-holders:Sprinter C-Compiler contributors ++//============================================================ ++// ++// sdbg.cpp - headless debugger wait loop for an external DAP bridge ++// ++//============================================================ ++ ++#include "emu.h" ++#include "debug_module.h" ++#include "modules/lib/osdobj_common.h" ++ ++#if defined(OSD_MAC) ++void sdbg_poll_mac_events(); ++#endif ++ ++ ++namespace osd { ++ ++namespace { ++ ++class debug_sdbg : public osd_module, public debug_module ++{ ++public: ++ debug_sdbg() : osd_module(OSD_DEBUG_PROVIDER, "sdbg"), debug_module(), ++ m_osd(nullptr), m_next_host_poll(0) ++ { } ++ ++ virtual int init(osd_interface &osd, const osd_options &options) override ++ { ++ m_osd = &dynamic_cast(osd); ++ return 0; ++ } ++ virtual void exit() override { } ++ ++ virtual void init_debugger(running_machine &machine) override { } ++ virtual void wait_for_debugger(device_t &device, bool firststop) override; ++ virtual void debugger_update() override { } ++ ++private: ++ osd_common_t *m_osd; ++ osd_ticks_t m_next_host_poll; ++}; ++ ++void debug_sdbg::wait_for_debugger(device_t &device, bool firststop) ++{ ++ // debugger_cpu::wait_for_debugger вызывает Lua periodic перед каждым ++ // обращением сюда. Короткий sleep исключает busy-loop, после возврата ++ // внешний bridge получает следующую возможность обработать RPC. ++ // Без обработки событий окон macOS считает MAME зависшим, пока CPU ++ // удерживается на точке VS Code. 100 Hz достаточно для Dock/Cmd-Tab. ++ osd_ticks_t const current = osd_ticks(); ++ if (current >= m_next_host_poll) ++ { ++#if defined(OSD_MAC) ++ sdbg_poll_mac_events(); ++#else ++ // Текущий mame.arm собран на SDL3: pump + poll необходимы, ++ // поскольку обычный frame_update не выполняется при остановке CPU. ++ m_osd->input_update(false); ++ m_osd->process_events(); ++#endif ++ m_next_host_poll = current + osd_ticks_per_second() / 100; ++ } ++ osd_sleep(osd_ticks_per_second() / 1000); ++} ++ ++} // anonymous namespace ++ ++} // namespace osd ++ ++MODULE_DEFINITION(DEBUG_SDBG, osd::debug_sdbg) +diff --git a/src/osd/modules/debugger/sdbgmac.mm b/src/osd/modules/debugger/sdbgmac.mm +new file mode 100644 +--- /dev/null ++++ b/src/osd/modules/debugger/sdbgmac.mm +@@ -0,0 +1,14 @@ ++// license:BSD-3-Clause ++// copyright-holders:Sprinter C-Compiler contributors ++// Прокачка событий Cocoa в headless debugger без окна штатного debugger. ++ ++#import ++ ++extern void MacPollInputs(); ++ ++void sdbg_poll_mac_events() ++{ ++ NSAutoreleasePool *const pool = [[NSAutoreleasePool alloc] init]; ++ MacPollInputs(); ++ [pool release]; ++} +diff --git a/src/osd/modules/lib/osdobj_common.cpp b/src/osd/modules/lib/osdobj_common.cpp +--- a/src/osd/modules/lib/osdobj_common.cpp ++++ b/src/osd/modules/lib/osdobj_common.cpp +@@ -282,6 +282,7 @@ + REGISTER_MODULE(m_mod_man, DEBUG_QT); + REGISTER_MODULE(m_mod_man, DEBUG_IMGUI); + REGISTER_MODULE(m_mod_man, DEBUG_GDBSTUB); ++ REGISTER_MODULE(m_mod_man, DEBUG_SDBG); + REGISTER_MODULE(m_mod_man, DEBUG_NONE); + #endif + diff --git a/toolchain/mcp/sdbgbridge/init.lua b/toolchain/mcp/sdbgbridge/init.lua new file mode 100644 index 0000000..df12228 --- /dev/null +++ b/toolchain/mcp/sdbgbridge/init.lua @@ -0,0 +1,242 @@ +-- license:BSD-3-Clause +-- Изолированный backend sdbg. Один каталог/процесс-владелец на сессию. +-- Не загружать одновременно с другим мостом управления тем же CPU. +local exports = { name="sdbgbridge", version="0.1.0", description="Sprinter source debugger", + license="BSD-3-Clause", author={name="Sprinter C-Compiler contributors"} } +function exports.startplugin() + local json = require("json") + local lfs = require("lfs") + local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR") + local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID") + local generation, sequence = 0, 0 + local events, owned = {}, {} + local state, pending = "initializing", nil + local invalidated=false + local session_started=false + local registers = {"PC","SP","AF","BC","DE","HL","IX","IY","AF2","BC2","DE2","HL2", + "I","R","IM","IFF1","IFF2","PG0","PG1","PG2","PG3","CNF","7FFD","1FFD"} + local function machine() return manager.machine end + local function cpu() return machine().devices[":maincpu"] end + local function now() + local t=machine().time + return t.seconds+t.attoseconds/1e18 + end + local function event(kind, body) + sequence=sequence+1 + events[#events+1]={seq=sequence,kind=kind,generation=generation,body=body or {}} + if #events>512 then table.remove(events,1) end + end + local function atomic(path, value) + local file=assert(io.open(path..".tmp","wb")) + file:write(json.stringify(value));file:close() + assert(os.rename(path..".tmp",path)) + end + local function update() + if invalidated then return end + local observed=machine().debugger.execution_state=="stop" and "stopped" or "running" + -- step() исполняется лишь после возврата periodic callback. + if pending and pending.kind:match("^step") and now()<=pending.time then return end + if observed~=state or (pending and observed=="stopped") then + state=observed + generation=generation+1 + local body={pc=cpu().state.PC.value} + if pending then body.reason=pending.kind end + event(state,body) + if state=="stopped" then pending=nil end + end + end + local function stopped(request) + assert(state=="stopped" and not pending,"CPU не остановлен") + assert(request.generation==generation,"Устаревшая generation") + end + local function number(value, limit) + assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число") + return value + end + local invalidate + local function dispatch(request) + assert(request.session==session,"Чужая сессия") + local args=request.args or {} + local command=request.command + if invalidated and command~="hello" and command~="snapshot" and command~="events" then + error("Сессия инвалидирована reset/load") + end + if command=="hello" then + session_started=true + return {protocol=1,session=session,capabilities={snapshot=true,memory=true, + instruction_step=true,step_over=true,step_out=true, + bank_guard=true,deferred_breakpoints=true,console_print=true},state=state} + elseif command=="snapshot" then + local result={state=state,time=now(),paused=machine().paused} + result.keyboards=setmetatable({}, {__jsontype="object"}) + for tag,kbd in pairs(machine().natkeyboard.keyboards) do + result.keyboards[tag]=kbd.enabled + end + if state=="stopped" and not pending then + result.registers=setmetatable({}, {__jsontype="object"}) + for _,name in ipairs(registers) do + if cpu().state[name] then result.registers[name]=cpu().state[name].value end + end + end + return result + elseif command=="key" then + -- Этот путь нужен для воспроизводимых UI-тестов; обычная клавиатура + -- хоста остаётся у MAME и не проходит через bridge. + assert(state=="running" or (state=="stopped" and args.down==false), + "Нажатие принимается при running CPU, отпускание также при stopped") + local tag=args.tag + assert(type(tag)=="string" and tag:match("^:kbd:ms_naturl:P%d+%.%d+$"), + "Разрешены только порты PC-клавиатуры Sprinter") + local mask=number(args.mask,0xffff) + assert(type(args.down)=="boolean","Нужен флаг down") + local port=machine().ioport.ports[tag] + assert(port,"Порт клавиатуры не найден") + for _,field in pairs(port.fields) do + if field.mask==mask then + field:set_value(args.down and 1 or 0) + return {accepted=true,tag=tag,mask=mask,down=args.down} + end + end + error("Поле клавиатуры не найдено") + elseif command=="events" then + local result={events={},last=sequence,first=events[1] and events[1].seq or sequence+1} + for _,item in ipairs(events) do + if item.seq>(args.after or 0) then result.events[#result.events+1]=item end + end + return result + elseif command=="console_tail" then + local log=machine().debugger.consolelog + local total=#log + local count=number(args.count or 40,200) + local lines={} + for index=math.max(1,total-count+1),total do + lines[#lines+1]=log[index] + end + return {total=total,lines=lines} + elseif command=="pause" then + if state~="stopped" then + pending={kind="pause",time=now()} + machine().debugger.execution_state="stop" + end + return {accepted=true} + elseif command=="continue" or command=="step" or command=="step_over" or + command=="step_out" then + stopped(request) + generation=generation+1 + state="running" + event("running",{reason=command}) + if command=="step" then + pending={kind="step",time=now()} + cpu().debug:step(1) + elseif command=="step_over" then + pending={kind="step_over",time=now()} + machine().debugger:command("over 1") + elseif command=="step_out" then + pending={kind="step_out",time=now()} + machine().debugger:command("out") + else machine().debugger.execution_state="run" end + return {accepted=true} + elseif command=="memory" then + stopped(request) + local address=number(args.address,0xffff) + assert(args.enabled==nil or type(args.enabled)=="boolean","Неверный enabled") + local length=number(args.length,4096) + assert(address+length<=0x10000,"Чтение за пределами logical memory") + local symbols=emu.symbol_table(cpu()) + local bytes={} + for offset=0,length-1 do + -- Этот интерфейс отключает side effects в отличие от space:read_u8. + bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true)) + end + return {hex=table.concat(bytes)} + elseif command=="console_print" then + stopped(request) + local text=args.text + assert(type(text)=="string" and #text>=1 and #text<=2048, + "Нужна строка журнала до 2048 байт") + -- В debugger printf уже отформатированный текст — только данные. + -- Убираем управляющие символы и экранируем синтаксис команды/формата. + local safe=text:gsub("[%c]"," "):gsub("\\","\\\\") + :gsub("%%","%%%%"):gsub('"',"'") + machine().debugger:command('printf "'..safe..'"') + return {printed=true} + elseif command=="breakpoint" then + stopped(request) + local address=number(args.address,0xffff) + local condition="" + if args.window~=nil or args.page~=nil then + local window=number(args.window,3) + local page=number(args.page,255) + local ports={[0]=0x82,[1]=0xa2,[2]=0xc2,[3]=0xe2} + -- Дополнительные PG state entries драйвера Sprinter не входят в + -- expression table CPU. Читаем штатный page-port без side effects; + -- числа debugger expression по умолчанию шестнадцатеричные. + condition=string.format("ib@%x==%x",ports[window],page) + end + local id=cpu().debug:bpset(address,condition,"") + owned[id]={address=address,condition=condition} + if args.enabled==false then cpu().debug:bpdisable(id) end + return {id=id,condition=condition,enabled=args.enabled~=false} + elseif command=="activate_breakpoints" then + stopped(request) + local count=0 + for id in pairs(owned) do + if cpu().debug:bpenable(id) then count=count+1 end + end + return {enabled=count} + elseif command=="deactivate_breakpoints" then + stopped(request) + local count=0 + for id in pairs(owned) do + if cpu().debug:bpdisable(id) then count=count+1 end + end + return {disabled=count} + elseif command=="clear" then + stopped(request) + local id=number(args.id,0x7fffffff) + assert(owned[id],"Точка не принадлежит sdbg") + cpu().debug:bpclear(id);owned[id]=nil + return {cleared=id} + else error("Неподдержанная команда: "..tostring(command)) end + end + invalidate=function(reason) + if invalidated then return end + if not session_started then return end + if reason=="state_load" then + for id in pairs(owned) do cpu().debug:bpclear(id) end + end + owned={} + invalidated=true + generation=generation+1 + pending=nil + state="invalidated" + event("invalidated",{reason=reason}) + end + -- Subscription-объекты надо удерживать: иначе Lua GC снимет callback. + exports._subscriptions={ + emu.add_machine_reset_notifier(function() invalidate("reset") end), + emu.add_machine_post_load_notifier(function() invalidate("state_load") end) + } + emu.register_periodic(function() + if not machine() or not machine().debugger then return end + update() + for name in lfs.dir(directory) do + local id=name:match("^req_(%d+)%.json$") + if id then + local path=directory.."/"..name + local file=io.open(path,"rb") + if file then + local data=file:read("*a");file:close() + local ok,request=pcall(json.parse,data) + local result + if ok and type(request)=="table" then ok,result=pcall(dispatch,request) + else result="Неверный JSON";ok=false end + atomic(directory.."/resp_"..id..".json",{ok=ok,generation=generation, + result=ok and result or nil,error=not ok and tostring(result) or nil,session=session}) + os.remove(path) + end + end + end + end) +end +return exports diff --git a/toolchain/mcp/sdbgbridge/plugin.json b/toolchain/mcp/sdbgbridge/plugin.json new file mode 100644 index 0000000..93dfee2 --- /dev/null +++ b/toolchain/mcp/sdbgbridge/plugin.json @@ -0,0 +1 @@ +{"plugin":{"name":"sdbgbridge","description":"Sprinter: протокол сессии отладки","version":"0.1.0","author":"Sprinter C-Compiler contributors","type":"plugin","start":"false"}} diff --git a/toolchain/run-mame-mcp.sh b/toolchain/run-mame-mcp.sh new file mode 100644 index 0000000..3db93ac --- /dev/null +++ b/toolchain/run-mame-mcp.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Запуск MCP из любой папки проекта; локальные пути не попадают в Git. +set -eu +mame_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +if [ -f "$mame_root/.codex/mame.local.env" ]; then + . "$mame_root/.codex/mame.local.env" +fi +: "${MAME_UV:=uv}" +: "${MAME_MCP_SCRIPT:=mame/sources/MAME/src/mame_mcp.py}" +cd "$mame_root" +if ! command -v "$MAME_UV" >/dev/null 2>&1; then + echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2 + exit 1 +fi +if [ ! -f "$MAME_MCP_SCRIPT" ]; then + echo "mame-z80: сервер не найден: $MAME_MCP_SCRIPT; задайте MAME_MCP_SCRIPT в .codex/mame.local.env" >&2 + exit 1 +fi +exec "$MAME_UV" run --python 3.12 --no-project --with 'mcp<2' "$MAME_MCP_SCRIPT" diff --git a/toolchain/sdbg.py b/toolchain/sdbg.py new file mode 100644 index 0000000..3a13a17 --- /dev/null +++ b/toolchain/sdbg.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Оффлайновая карта исходников Sprinter; live attach появится отдельным этапом.""" +import argparse +import json +import sys +from sdbg.model import DebugMap + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', required=True) + commands = parser.add_subparsers(dest='command', required=True) + for name in ('map', 'vars', 'symbols', 'verify'): commands.add_parser(name) + address = commands.add_parser('addr2line') + address.add_argument('address', type=lambda s: int(s, 0)) + line = commands.add_parser('line2addr') + line.add_argument('file') + line.add_argument('line', type=int) + args = parser.parse_args() + try: + model = DebugMap(args.build) + if args.command == 'map': result = model.functions + elif args.command == 'vars': result = model.variables + elif args.command == 'symbols': result = model.symbols + elif args.command == 'addr2line': result = model.addr2line(args.address) + elif args.command == 'line2addr': result = model.line_locations(args.file,args.line) + else: + model.verify_executable() + result = {'build_id': model.manifest['build_id'], 'artifacts': 'verified', + 'stale_sources': model.stale_sources, 'diagnostics': model.diagnostics, + 'limitations': model.manifest['limitations']} + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (ValueError, OSError, KeyError) as error: + print('sdbg: ' + str(error), file=sys.stderr) + return 1 + + +if __name__ == '__main__': sys.exit(main()) diff --git a/toolchain/sdbg/__init__.py b/toolchain/sdbg/__init__.py new file mode 100644 index 0000000..0071ad0 --- /dev/null +++ b/toolchain/sdbg/__init__.py @@ -0,0 +1 @@ +"""Карта оптимизированного кода SDCC и пакет отладки Sprinter.""" diff --git a/toolchain/sdbg/build.py b/toolchain/sdbg/build.py new file mode 100644 index 0000000..c128b61 --- /dev/null +++ b/toolchain/sdbg/build.py @@ -0,0 +1,113 @@ +"""Сборочные операции sdbg: уникальные debug-символы без изменения инструкций.""" +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +from pathlib import Path +from .macros import anchor_symbols, extract + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write_json(path: Path, data) -> None: + temporary = path.with_suffix(path.suffix + '.tmp') + temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n') + temporary.replace(path) + + +def normalize(assembly: str, adb: str, module: str): + """Меняет только имена debug-записей; сохраняет число строк .asm.""" + match = re.search(r'^\s*\.module\s+(\S+)', assembly, re.M) + if not match: + raise ValueError('В asm отсутствует .module') + old = match.group(1) + # F/XF/L связывают статики, функции и локальные с единицей трансляции. + def rename(text): + text = re.sub(r'\b(X?F)' + re.escape(old) + r'(?=\$)', + lambda m: m[1] + module, text) + return re.sub(r'\bL' + re.escape(old) + r'(?=[.$])', 'L' + module, text) + # Не трогаем .ascii и инструкции: строковый литерал может содержать + # текст, похожий на debug-символ. Переименовываем только определения + # и объявления символов, сгенерированные SDCC. + symbol_line = re.compile(r'^\s*(?:\.globl\s+|(?:X?F|L)' + re.escape(old) + r'[$.])') + assembly = ''.join(rename(line) if symbol_line.match(line) else line + for line in assembly.splitlines(keepends=True)) + adb = rename(adb) + assembly = re.sub(r'(?m)^(\s*\.module\s+)\S+', lambda m: m[1] + module, assembly) + adb = re.sub(r'(?m)^M:.*$', 'M:' + module, adb) + markers, active, output = {}, {}, [] + for number, line in enumerate(assembly.splitlines(keepends=True), 1): + definition = re.match(r'\s*(C\$[^\s=]+)\s*=\s*\.', line) + if definition: + original = definition[1] + parts = original.split('$') + if len(parts) != 5: + raise ValueError('Неизвестная форма CDB: ' + original) + unique = f'C${module}_{len(markers)}${parts[2]}${parts[3]}${parts[4]}' + active[original] = unique + markers[unique] = {'original': original, 'file': parts[1], + 'line': int(parts[2]), 'asm_line': number} + line = line.replace(original, unique) + elif re.match(r'\s*\.globl\s+C\$', line): + original = line.split()[1] + if original not in active: + raise ValueError('CDB .globl без определения: ' + original) + line = line.replace(original, active[original]) + output.append(line) + return ''.join(output), adb, markers + + +def compile_unit(sdcc: Path, assembler: Path, source: Path, output: Path, flags, debug=True): + """-S и те же опции ассемблера, которые SDCC 4.5 использует с --debug.""" + source = source.resolve() + asm, adb = output.with_suffix('.asm'), output.with_suffix('.adb') + compile_flags = [*flags, '-DSPRINTER_SDBG_ANCHORS=1'] if debug else flags + # SDCC -E кодирует внутренний разделитель строк inline asm байтом 0x87; + # для списка #line нужны только ASCII-пути. Само описание логов извлекаем + # отдельным metadata-проходом без asm-развёртки. + preprocessed = subprocess.check_output([str(sdcc), *compile_flags, '-E', str(source)], + text=True, errors='replace') + dependencies = {str(source)} + for filename in re.findall(r'^#(?:line)?\s*\d+\s+"([^"]+)"', preprocessed, re.M): + path = Path(filename).resolve() + if path.is_file(): dependencies.add(str(path)) + hashes = {name: digest(Path(name)) for name in dependencies} + if not debug: + subprocess.run([str(sdcc), *flags, '-c', '-o', str(output), str(source)], check=True) + if any(digest(Path(name)) != sha for name, sha in hashes.items()): + raise ValueError('Исходники изменились во время компиляции') + write_json(output.with_suffix('.sdbg-input.json'), {'dependency_hashes': hashes}) + return + metadata = subprocess.check_output([str(sdcc), *compile_flags, + '-DSPRINTER_SDBG_METADATA=1', '-E', str(source)], text=True) + macros = extract(metadata, dependencies) + subprocess.run([str(sdcc), *compile_flags, '--debug', '-S', '-o', str(asm), str(source)], check=True) + if any(digest(Path(name)) != sha for name, sha in hashes.items()): + raise ValueError('Исходники изменились во время компиляции') + original = asm.read_text() + # Включаем output stem: один исходник может собираться в разные банки. + module = 'sdbg_' + hashlib.sha256((str(source) + ':' + output.stem).encode()).hexdigest()[:16] + assembly, descriptions, markers = normalize(original, adb.read_text(), module) + assembly, log_macros = anchor_symbols(assembly, module, macros) + asm.write_text(assembly) + adb.write_text(descriptions) + subprocess.run([str(assembler), '-plosgffwy', str(output), str(asm)], check=True) + # Комментарии SDCC сохраняют фактические пути строк, включая заголовки. + sources = {} + for name in dependencies: + sources.setdefault(Path(name).name, set()).add(name) + for filename in re.findall(r'^;(.+?):\d+:', original, re.M): + path = Path(filename).resolve() + if path.is_file(): + sources.setdefault(path.name, set()).add(str(path)) + write_json(output.with_suffix('.sdbg-unit.json'), { + 'module': module, 'source': str(source), 'source_sha256': digest(source), + 'asm': asm.name, 'adb': adb.name, 'object': output.name, + 'flags': compile_flags, 'markers': markers, 'log_macros': log_macros, + 'dependency_hashes': hashes, + 'sources': {k: sorted(v) for k, v in sources.items()}, + }) diff --git a/toolchain/sdbg/dap.py b/toolchain/sdbg/dap.py new file mode 100644 index 0000000..5f93429 --- /dev/null +++ b/toolchain/sdbg/dap.py @@ -0,0 +1,198 @@ +"""Честный минимальный Debug Adapter Protocol поверх постоянной sdbg-сессии.""" +from __future__ import annotations + +from pathlib import Path + +from .server import rpc_call +from .session import SessionError + + +class DapEngine: + def __init__(self, rpc=None): + self.rpc_path = None + self._rpc_override = rpc + self.attached = None + self.event_sequence = 0 + + def rpc(self, method, arguments=None, timeout=10): + if self._rpc_override is not None: + return self._rpc_override(method, arguments or {}) + if not self.rpc_path: + raise SessionError('DAP ещё не подключён к session server') + return rpc_call(self.rpc_path, method, arguments, timeout) + + def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]: + events = [] + if command == 'initialize': + body = { + 'supportsConfigurationDoneRequest': True, + 'supportsFunctionBreakpoints': True, + 'supportsInstructionBreakpoints': False, + 'supportsEvaluateForHovers': True, + 'supportsSetVariable': False, + 'supportsStepBack': False, + 'supportsRestartRequest': False, + 'supportsTerminateRequest': False, + 'supportsDisassembleRequest': False, + 'supportsSteppingGranularity': True, + } + return body, events + if command == 'attach': + if self._rpc_override is None: + path = arguments.get('socket') + if not isinstance(path, str) or not path: + raise SessionError('В attach требуется socket session server') + self.rpc_path = path + self.attached = self.rpc('status') + self.event_sequence = self.attached['event_sequence'] + events.append(('initialized', {})) + return {'buildId': self.attached['build_id']}, events + if command == 'configurationDone': + location = self.rpc('where') + events.append(('stopped', {'reason': 'entry', 'threadId': 1, + 'allThreadsStopped': True, + 'description': self._description(location)})) + return {}, events + if command == 'disconnect': + return {}, events + if command == 'threads': + return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events + if command == 'stackTrace': + location = self.rpc('where') + return {'stackFrames': [self._frame(location)], 'totalFrames': 1}, events + if command == 'scopes': + return {'scopes': [ + {'name': 'Registers', 'variablesReference': 1, 'expensive': False}, + {'name': 'Globals/statics', 'variablesReference': 2, 'expensive': False}, + ]}, events + if command == 'variables': + reference = int(arguments.get('variablesReference', 0)) + if reference == 1: + values = self.rpc('registers')['registers'] + variables = [{'name': name, 'value': f'{value:#x}', + 'variablesReference': 0} for name, value in sorted(values.items())] + elif reference == 2: + variables = self._global_variables() + else: + variables = [] + return {'variables': variables}, events + if command == 'evaluate': + expression = arguments.get('expression', '') + if not expression.isidentifier(): + raise SessionError('MVP evaluate принимает только имя переменной') + value = self.rpc('read_variable', {'name': expression}) + return {'result': str(value['value']), 'type': value['type'], + 'variablesReference': 0, 'memoryReference': hex(value['link_address'])}, events + if command == 'setBreakpoints': + source = arguments.get('source', {}) + path = source.get('path') + if not path: + raise SessionError('Для source breakpoint нужен полный path') + specifications = [{name: item[name] for name in + ('line','condition','hitCondition','logMessage') if name in item} + for item in arguments.get('breakpoints', [])] + result = self.rpc('set_source_breakpoints', + {'file': path, 'breakpoints': specifications}) + return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events + if command == 'setFunctionBreakpoints': + names = [item['name'] for item in arguments.get('breakpoints', [])] + result = self.rpc('set_function_breakpoints', {'names': names}) + return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events + if command == 'continue': + self.rpc('continue') + return {'allThreadsContinued': True}, events + if command == 'pause': + self.rpc('pause') + return {}, events + if command in ('next', 'stepIn'): + if arguments.get('granularity') == 'instruction': + self.rpc('step') + else: + self.rpc('source_step', { + 'kind': 'over' if command == 'next' else 'into'}) + return {}, events + if command == 'stepOut': + self.rpc('source_step', {'kind': 'out'}) + return {}, events + raise SessionError('DAP-команда пока не поддерживается: ' + command) + + def poll_events(self, timeout=1): + previous = self.event_sequence + result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout}, + timeout=timeout + 2) + self.event_sequence = result['last'] + translated = [] + gap = max(0, result.get('first', previous + 1) - previous - 1) + if gap: + translated.append(('output', {'category': 'stderr', + 'output': f'sdbg: пропущено {gap} событий журнала; ' + 'уменьшите частоту logpoint\n'})) + for event in result['events']: + if event['event'] == 'stopped': + translated.append(('stopped', {'reason': event['body'].get('reason', 'breakpoint'), + 'threadId': 1, 'allThreadsStopped': True, + 'description': self._description( + event['body'].get('location', {}))})) + elif event['event'] == 'continued': + translated.append(('continued', {'threadId': 1, 'allThreadsContinued': True})) + elif event['event'] == 'invalidated': + translated.append(('terminated', {'restart': False})) + elif event['event'] == 'output': + translated.append(('output', {'category': event['body'].get('category','console'), + 'output': event['body']['output']})) + return translated, result['closed'] + + @staticmethod + def _description(location): + status = location.get('status', 'unknown') + bank = location.get('function', {}).get('bank') if location.get('function') else None + suffix = '' if bank is None else f', bank {bank}' + return f'{status}{suffix}, PC={location.get("pc", 0):#x}' + + @staticmethod + def _frame(location): + function = location.get('function') or {} + sources = location.get('sources') or [] + source = sources[0] if sources else None + frame = { + 'id': 1, + 'name': function.get('name', '') + + (' [ambiguous]' if location.get('status') == 'ambiguous' else ''), + 'line': source['line'] if source else 1, + 'column': 1, + 'instructionPointerReference': hex(location.get('link_address', location.get('pc', 0))), + } + if source: + frame['source'] = {'name': Path(source['file']).name, 'path': source['file']} + return frame + + @staticmethod + def _breakpoint(item): + locations = item.get('locations', []) + first = locations[0] if locations else {} + result = {'id': item['id'], 'verified': bool(item.get('verified', locations))} + line = item.get('line', first.get('line')) + if line is not None: + result['line'] = line + if first.get('link_address') is not None: + result['instructionReference'] = hex(first['link_address']) + if len(locations) > 1: + result['message'] = f'Разрешено в {len(locations)} адресов/банков' + return result + + def _global_variables(self): + result = [] + for variable in self.rpc('variables'): + label = variable['name'] + if variable.get('module'): + label += '@' + variable['module'] + try: + value = self.rpc('read_variable', {'name': variable['name'], + 'module': variable.get('module')}) + text = str(value['value']) + except SessionError as error: + text = '' + result.append({'name': label, 'value': text, 'type': variable['type'], + 'variablesReference': 0, + 'memoryReference': hex(variable['link_address'])}) + return result diff --git a/toolchain/sdbg/image.py b/toolchain/sdbg/image.py new file mode 100644 index 0000000..9a1f600 --- /dev/null +++ b/toolchain/sdbg/image.py @@ -0,0 +1,51 @@ +"""Строгий разбор Intel HEX для проверки образа, загруженного в MAME.""" +from __future__ import annotations + +from pathlib import Path + + +def read_ihx(path: Path) -> dict[int, int]: + """Возвращает разреженный образ и отвергает битые/конфликтующие записи.""" + memory: dict[int, int] = {} + base = 0 + eof = False + for number, raw in enumerate(Path(path).read_text().splitlines(), 1): + if not raw.startswith(':'): + raise ValueError(f'Intel HEX, строка {number}: отсутствует двоеточие') + try: + record = bytes.fromhex(raw[1:]) + except ValueError as error: + raise ValueError(f'Intel HEX, строка {number}: неверные hex-цифры') from error + if len(record) < 5 or len(record) != record[0] + 5: + raise ValueError(f'Intel HEX, строка {number}: неверная длина') + if sum(record) & 0xff: + raise ValueError(f'Intel HEX, строка {number}: неверная контрольная сумма') + length = record[0] + address = int.from_bytes(record[1:3], 'big') + kind = record[3] + data = record[4:4 + length] + if eof: + raise ValueError(f'Intel HEX, строка {number}: данные после EOF') + if kind == 0: + for offset, value in enumerate(data): + absolute = base + address + offset + if absolute in memory and memory[absolute] != value: + raise ValueError(f'Intel HEX: конфликт по адресу {absolute:#x}') + memory[absolute] = value + elif kind == 1: + if length or address: + raise ValueError(f'Intel HEX, строка {number}: неверная EOF-запись') + eof = True + elif kind == 2: + if length != 2 or address: + raise ValueError(f'Intel HEX, строка {number}: неверная segment-запись') + base = int.from_bytes(data, 'big') << 4 + elif kind == 4: + if length != 2 or address: + raise ValueError(f'Intel HEX, строка {number}: неверная linear-запись') + base = int.from_bytes(data, 'big') << 16 + elif kind not in (3, 5): + raise ValueError(f'Intel HEX, строка {number}: неизвестный тип {kind}') + if not eof: + raise ValueError('Intel HEX: отсутствует EOF') + return memory diff --git a/toolchain/sdbg/macros.py b/toolchain/sdbg/macros.py new file mode 100644 index 0000000..5d49a2a --- /dev/null +++ b/toolchain/sdbg/macros.py @@ -0,0 +1,152 @@ +"""Извлекает авторские логи из активного препроцессорного потока SDCC.""" +from __future__ import annotations + +import ast +import bisect +import re +import string +from pathlib import Path + +_DIRECTIVE = re.compile(r'^#(?:line)?\s+(\d+)\s+"([^"]+)"') +_CALL = re.compile(r'\bSDBG_METADATA_(LOGIF|LOG)\s*\(') +_IDENT = re.compile(r'[A-Za-z_][A-Za-z_0-9]*\Z') +_LITERAL = re.compile(r'"(?:\\.|[^"\\])*"') + + +def _calls(text: str): + """Находит metadata-вызовы вне строк/символьных литералов C.""" + position = 0 + while position < len(text): + char = text[position] + if char in ('"', "'"): + quote = char + position += 1 + while position < len(text): + if text[position] == '\\': position += 2 + elif text[position] == quote: + position += 1 + break + else: position += 1 + continue + match = _CALL.match(text, position) + if match: + yield match + position = match.end() + else: + position += 1 + + +def _arguments(text: str, start: int) -> list[str]: + """Делит аргументы вызова после `(`, не путая запятые в строках.""" + args, begin, depth, quote, escape = [], start, 1, False, False + for position in range(start, len(text)): + char = text[position] + if quote: + if escape: escape = False + elif char == '\\': escape = True + elif char == '"': quote = False + continue + if char == '"': quote = True + elif char == '(': depth += 1 + elif char == ')': + depth -= 1 + if depth == 0: + args.append(text[begin:position].strip()) + return args + elif char == ',' and depth == 1: + args.append(text[begin:position].strip()) + begin = position + 1 + raise ValueError('Незакрытый вызов SDBG_LOG в препроцессорном потоке') + + +def _message(value: str) -> str: + literals = [] + position = 0 + while position < len(value): + while position < len(value) and value[position].isspace(): position += 1 + if position == len(value): break + match = _LITERAL.match(value, position) + if not match: + raise ValueError('SDBG_LOG: сообщение должно быть строковым литералом') + try: + decoded = ast.literal_eval(match.group()) + except (ValueError, SyntaxError) as error: + raise ValueError('SDBG_LOG: неверный строковый литерал') from error + if not isinstance(decoded, str): + raise ValueError('SDBG_LOG: нужен обычный строковый литерал') + literals.append(decoded) + position = match.end() + message = ''.join(literals) + if not message or len(message) > 1024: + raise ValueError('SDBG_LOG: сообщение должно содержать 1..1024 символа') + validate_log_message(message) + return message + + +def validate_log_message(message: str) -> None: + """Одна грамматика для C-макроса и DAP logMessage.""" + if not isinstance(message, str) or not message or len(message) > 1024: + raise ValueError('logMessage должен содержать 1..1024 символа') + try: + fields = list(string.Formatter().parse(message)) + except ValueError as error: + raise ValueError('Неверные фигурные скобки logMessage') from error + for _, name, spec, conversion in fields: + if name is not None and (not _IDENT.fullmatch(name) or spec or conversion): + raise ValueError('В logMessage разрешены только подстановки {variable}') + + +def extract(preprocessed: str, dependencies: set[str]) -> list[dict]: + """Возвращает активные вызовы и проверенные исходные пути/строки.""" + lines = preprocessed.splitlines(keepends=True) + offsets, indexed = [], [] + offset, filename, number = 0, None, 0 + for line in lines: + offsets.append(offset) + directive = _DIRECTIVE.match(line) + if directive: + filename, number = directive[2], int(directive[1]) + indexed.append((None, 0)) + else: + indexed.append((filename, number)) + number += 1 + offset += len(line) + found, tags = [], set() + for match in _calls(preprocessed): + index = bisect.bisect_right(offsets, match.start()) - 1 + filename, number = indexed[index] + if not filename or number < 1: + raise ValueError('SDBG_LOG: препроцессор не сохранил позицию исходника') + source = str(Path(filename).resolve()) + if source not in dependencies: + raise ValueError('SDBG_LOG: вызов вне проверенных исходников: ' + source) + args = _arguments(preprocessed, match.end()) + expected = 3 if match[1] == 'LOGIF' else 2 + if len(args) != expected: + raise ValueError('SDBG_LOG: неверное число аргументов') + tag = args[0] + if not _IDENT.fullmatch(tag) or tag in tags: + raise ValueError('SDBG_LOG: tag должен быть уникальным идентификатором TU: ' + tag) + tags.add(tag) + condition = args[1] if expected == 3 else None + if condition is not None and not _IDENT.fullmatch(condition): + raise ValueError('SDBG_LOGIF: пока поддержано только имя переменной') + found.append({'tag': tag, 'message': _message(args[-1]), + 'condition': condition, 'source': source, 'line': number}) + return found + + +def anchor_symbols(assembly: str, module: str, macros: list[dict]) -> tuple[str, list[dict]]: + """Уникализирует asm-символы TU и требует ровно один якорь на macro tag.""" + descriptions = [] + for macro in macros: + old = '_spr_sdbg_log_' + macro['tag'] + new = '_spr_sdbg_log_' + module + '_' + macro['tag'] + definition = re.compile(r'(?m)^(\s*)' + re.escape(old) + r'(\s*=\s*\.\s*)$') + declaration = re.compile(r'(?m)^(\s*\.globl\s+)' + re.escape(old) + r'(\s*)$') + if len(definition.findall(assembly)) != 1 or len(declaration.findall(assembly)) != 1: + raise ValueError('SDBG_LOG: якорь отсутствует или развёрнут повторно: ' + macro['tag']) + assembly = definition.sub(lambda m: m[1] + new + m[2], assembly) + assembly = declaration.sub(lambda m: m[1] + new + m[2], assembly) + descriptions.append({**macro, 'symbol': new}) + return assembly, descriptions diff --git a/toolchain/sdbg/model.py b/toolchain/sdbg/model.py new file mode 100644 index 0000000..4b874f8 --- /dev/null +++ b/toolchain/sdbg/model.py @@ -0,0 +1,193 @@ +"""Проверенная оффлайновая карта SDCC; неизвестные диапазоны не угадываются.""" +from __future__ import annotations +from dataclasses import asdict, dataclass +import json +from pathlib import Path +import re +from .build import digest + + +@dataclass(frozen=True) +class Location: + link_address: int + logical_address: int + section: str + bank: int | None + window: int | None + + +class DebugMap: + def __init__(self, directory): + self.directory = Path(directory).resolve() + self.manifest = json.loads((self.directory/'manifest.json').read_text()) + if self.manifest['schema_version'] != 1: + raise ValueError('Неподдержанная версия пакета') + for name, expected in self.manifest['artifacts'].items(): + path = (self.directory/name).resolve() + if not path.is_relative_to(self.directory) or digest(path) != expected: + raise ValueError('Повреждённый артефакт: ' + name) + self.stale_sources = [name for name, value in self.manifest['sources'].items() + if not Path(name).is_file() or digest(Path(name)) != value['sha256']] + stem = Path(self.manifest['executable']).stem + self.symbols = {} + for row in (self.directory/(stem+'.noi')).read_text().splitlines(): + match = re.fullmatch(r'DEF (\S+) (0x[0-9A-Fa-f]+)', row) + if match: + self.symbols[match[1]] = int(match[2], 16) + self.sections = [] + for row in (self.directory/(stem+'.map')).read_text().splitlines(): + match = re.match(r'^(\S+)\s+([0-9A-F]{8})\s+([0-9A-F]{8})\s+=', row) + if match and int(match[3],16): + item = (match[1], int(match[2],16), int(match[3],16)) + if item not in self.sections: self.sections.append(item) + self.units = {u['module']: u for u in self.manifest['units']} + self.instructions = {} + self.markers = [] + self.functions = [] + self.variables = [] + self.logpoints = [] + self.diagnostics = [] + self._load((self.directory/(stem+'.cdb')).read_text().splitlines()) + + def verify_executable(self): + path = Path(self.manifest['executable_path']) + if not path.is_file() or digest(path) != self.manifest.get('executable_sha256', self.manifest['build_id']): + raise ValueError('EXE не соответствует пакету') + + def location(self, address): + sections = [s for s in self.sections if s[1] <= address < s[1]+s[2]] + if len(sections) != 1: + raise ValueError(f'Неоднозначная/неизвестная секция адреса {address:#x}') + section = sections[0][0] + match = re.fullmatch(r'_?BANK(\d+)', section) + bank = int(match[1]) if match else None + if address > 0xffff and (bank is None or address >> 16 != bank): + raise ValueError(f'Неподдержанное размещение {section}: {address:#x}') + logical = address & 0xffff + return asdict(Location(address, logical, section, bank, logical >> 14)) + + def _load(self, records): + addresses, declarations = {}, [] + module = None + for record in records: + if record.startswith('M:'): module = record[2:] + elif record.startswith('L:'): + name, value = record[2:].rsplit(':', 1) + address = int(value, 16) + if name in addresses and addresses[name] != address: + raise ValueError('Конфликт отладочного символа: ' + name) + addresses[name] = address + elif record.startswith(('F:', 'S:')): + declarations.append((module, record)) + elif record and not record.startswith('T:'): + self.diagnostics.append('Неизвестная запись: '+record) + # A$ у ассемблера использует basename файла, а не .module. + for unit in self.units.values(): + asm = (self.directory/unit['asm']).read_text().splitlines() + sizes = {} + listing = (self.directory/Path(unit['asm']).with_suffix('.lst')).read_text() + for row in listing.splitlines(): + match = re.match(r'^\s+[0-9A-F]{6,8}\s+(.+?)\s+\[\s*\d+\]\s+(\d+)\s', row) + if match: + sizes[int(match[2])] = len(re.findall(r'[0-9A-F]{2}', match[1])) + prefix = 'A$'+Path(unit['asm']).stem+'$' + for symbol, address in addresses.items(): + if symbol.startswith(prefix): + line = int(symbol[len(prefix):]) + if line in sizes and sizes[line] > 0: + self.instructions[address] = { + **self.location(address), 'size': sizes[line], + 'asm': unit['asm'], 'asm_line': line, 'text': asm[line-1].strip(), + 'module': unit['module'], + } + for symbol, marker in unit['markers'].items(): + if symbol not in addresses: + raise ValueError('Отсутствует linked CDB-маркер: '+symbol) + paths = unit['sources'].get(Path(marker['file']).name, []) + if len(paths) > 1: + raise ValueError('Неоднозначный путь debug-записи: ' + marker['file']) + self.markers.append({**self.location(addresses[symbol]), + 'line': marker['line'], 'sources': paths, + 'module': unit['module']}) + for module, record in declarations: + match = re.match(r'([FS]):([^($]+\$[^($]+\$[^($]+\$[^($]+)\(\{(\d+)\}(.+)\),([A-Z]),', record) + if not match: continue + kind, key, size, ctype, space = match.groups() + parts = key.split('$') + if kind == 'F': + startkey = '$'.join(parts[:2])+'$0$0' + start, last = addresses.get(startkey), addresses.get('X'+startkey) + if start is None or last not in self.instructions: continue + end = last + self.instructions[last]['size'] + if end <= start: continue + function = {'name': parts[1], 'module': module, 'start': start, + 'end': end, **self.location(start)} + if function not in self.functions: self.functions.append(function) + elif space == 'E' and parts[0].startswith(('G','F')) and not ctype.startswith('DF,'): + address = addresses.get(key) + if address is None and parts[0] == 'G': address = self.symbols.get('_'+parts[1]) + if address is None: continue + supported = bool(re.fullmatch(r'S[ICL]:[SU]', ctype) or ctype.startswith('DG,')) + variable = {'name': parts[1], 'module': module if parts[0] != 'G' else None, + 'size': int(size), 'type': ctype, + 'signed': ctype.endswith(':S') and not ctype.startswith('D'), + 'supported': supported, **self.location(address)} + if variable not in self.variables: self.variables.append(variable) + self.functions.sort(key=lambda f: f['start']) + self.markers.sort(key=lambda m: m['link_address']) + for unit in self.units.values(): + for macro in unit.get('log_macros', []): + address = self.symbols.get(macro['symbol']) + if address is None: + raise ValueError('Связанный SDBG_LOG-якорь отсутствует: ' + macro['tag']) + try: + location = self.location(address) + verified = address in self.instructions and self.function_at(address) is not None + reason = None if verified else 'Якорь не совпал с началом исполняемой инструкции' + except ValueError as error: + location, verified, reason = {}, False, str(error) + self.logpoints.append({**macro, **location, 'module': unit['module'], + 'verified': verified, 'reason': reason}) + + def function_at(self, address): + found = [f for f in self.functions if f['start'] <= address < f['end']] + return found[0] if len(found) == 1 else None + + def addr2line(self, address): + function = self.function_at(address) + instruction = next((v for k,v in self.instructions.items() + if k <= address < k+v['size']), None) + if not function or not instruction: + return {'address': address, 'status': 'unknown', 'function': function} + markers = [m for m in self.markers if m['module'] == function['module'] + and function['start'] <= m['link_address'] <= instruction['link_address']] + nearest = max((m['link_address'] for m in markers), default=None) + sources = [] + for marker in markers: + if marker['link_address'] == nearest: + for source in marker['sources']: + item = {'file': source, 'line': marker['line']} + if item not in sources: sources.append(item) + return {'status': 'mapped' if len(sources) == 1 else 'ambiguous' if sources else 'unknown', + 'instruction': instruction, 'function': function, 'sources': sources, 'stale_source': any(s['file'] in self.stale_sources for s in sources)} + + def line_locations(self, filename, line): + exact = str(Path(filename).resolve()) + known = self.manifest['sources'] + candidates = [exact] if exact in known else [p for p in known if Path(p).name == filename] + if len(candidates) > 1: raise ValueError('Неоднозначный source; укажите полный путь') + result = [] + for marker in self.markers: + address = marker['link_address'] + if marker['line'] == line and any(p in candidates for p in marker['sources']): + if address in self.instructions and self.function_at(address): + item = {**marker, 'function': self.function_at(address)['name']} + if item not in result: result.append(item) + stale = any(p in self.stale_sources for p in candidates) + return {'status': 'stale' if stale else 'verified' if result else 'unverified', + 'locations': result, 'stale_source': stale} + + def source_text(self, filename, line): + source = self.manifest['sources'][filename] + text = (self.directory/source['snapshot']).read_text(errors='replace').splitlines() + return text[line-1] if 0 < line <= len(text) else None diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py new file mode 100644 index 0000000..499ea88 --- /dev/null +++ b/toolchain/sdbg/server.py @@ -0,0 +1,464 @@ +"""Постоянный владелец DebugSession и локальный JSON-RPC для адаптеров.""" +from __future__ import annotations + +from collections import deque +import json +from pathlib import Path +import socket +import socketserver +import string +import threading +import time + +from .session import DebugSession, SessionError +from .transport import BridgeError +from .macros import validate_log_message + + +class SessionController: + def __init__(self, session: DebugSession): + self.session = session + self.lock = threading.RLock() + self.changed = threading.Condition() + self.events = deque(maxlen=1024) + self.dropped_events = 0 + self.sequence = 0 + self.running = False + self.source_step = None + self.closed = False + self.source_breakpoints: dict[str, list[int]] = {} + self.function_breakpoints: list[int] = [] + self.breakpoint_info: dict[int, dict] = {} + self.attached = session.attach() + self.mame_console = bool(self.attached.get('capabilities', {}).get('console_print')) + self._emit('stopped', {'reason': 'entry', 'location': self.attached['location']}) + self._install_macro_logs() + self.poller = threading.Thread(target=self._poll, name='sdbg-poller', daemon=True) + self.poller.start() + + def _install_macro_logs(self) -> None: + """Авторские точки принадлежат пакету сборки, не DAP source-набору.""" + created = [] + try: + for macro in getattr(self.session.model, 'logpoints', []): + if not macro['verified'] or macro['source'] in self.session.model.stale_sources: + self._emit('output', {'category': 'stderr', + 'output': 'sdbg: SDBG_LOG ' + macro['tag'] + + ' не активирован: ' + str(macro.get('reason') or 'устаревший исходник') + '\n'}) + continue + self._validate_log_message(macro['message']) + item = self.session.break_anchor(macro, enabled=False) + created.append(item['id']) + self.breakpoint_info[item['id']] = { + 'kind': 'log', 'message': macro['message'], + 'condition': macro['condition'], 'module': macro['module'], + 'tag': macro['tag'], 'locations': item['locations'], 'hits': 0, + } + if created: + self.session.activate_breakpoints() + except BaseException: + for identifier in created: + self.breakpoint_info.pop(identifier, None) + try: self.session.clear_breakpoint(identifier) + except BaseException: pass + raise + + def _emit(self, name: str, body: dict) -> None: + with self.changed: + if len(self.events) == self.events.maxlen: + self.dropped_events += 1 + self.sequence += 1 + self.events.append({'seq': self.sequence, 'event': name, 'body': body}) + self.changed.notify_all() + + def _poll(self) -> None: + while not self.closed: + if not self.running: + time.sleep(.02) + continue + try: + with self.lock: + snapshot = self.session.bridge.request('snapshot') + if snapshot['state'] == 'stopped': + location = self.session.where(snapshot) + if self.source_step is not None: + self._source_step_stopped(location) + elif self._handle_logpoints(location): + self.session.bridge.request('continue') + continue + else: + self.running = False + self._emit('stopped', {'reason': 'breakpoint', 'location': location}) + elif snapshot['state'] == 'invalidated': + self.running = False + self.source_step = None + self.closed = True + self._emit('invalidated', {'reason': 'reset_or_load'}) + except (BridgeError, SessionError, ValueError, OSError) as error: + self.running = False + self.source_step = None + self.closed = True + self._emit('invalidated', {'reason': str(error)}) + time.sleep(.01) + + def close(self) -> None: + self.closed = True + self.poller.join(timeout=1) + self.session.bridge.close() + + def call(self, method: str, arguments: dict) -> object: + if method == 'events': + after = int(arguments.get('after', 0)) + timeout = min(max(float(arguments.get('timeout', 0)), 0), 30) + deadline = time.monotonic() + timeout + with self.changed: + while self.sequence <= after and not self.closed and time.monotonic() < deadline: + self.changed.wait(deadline - time.monotonic()) + return {'events': [event for event in self.events if event['seq'] > after], + 'first': self.events[0]['seq'] if self.events else self.sequence + 1, + 'last': self.sequence, 'lost': self.dropped_events, + 'closed': self.closed} + with self.lock: + if self.closed: + raise SessionError('Сессия закрыта или инвалидирована') + if method == 'status': + return {**self.attached, 'running': self.running, + 'event_sequence': self.sequence} + if method == 'snapshot': + return self.session.bridge.request('snapshot') + if method == 'mame_console_tail': + return self.session.bridge.request( + 'console_tail', count=int(arguments.get('count', 40))) + if method == 'input_key': + if not self.running and arguments['down'] is not False: + raise SessionError('Нажатие возможно только при running CPU') + return self.session.bridge.request( + 'key', tag=arguments['tag'], mask=int(arguments['mask']), + down=arguments['down']) + if method == 'where': + return self.session.where() + if method == 'registers': + mapping = self.session.refresh() + return {'generation': mapping.generation, 'registers': mapping.registers, + 'bank_pages': mapping.bank_pages} + if method == 'variables': + return self.session.model.variables + if method == 'read_variable': + return self.session.read_variable(arguments['name'], arguments.get('module')) + if method == 'break_line': + return self.session.break_line(arguments['file'], int(arguments['line'])) + if method == 'break_function': + return self.session.break_function(arguments['name']) + if method == 'clear_breakpoint': + return self.session.clear_breakpoint(int(arguments['id'])) + if method == 'set_source_breakpoints': + specifications = arguments.get('breakpoints') + if specifications is None: + specifications = [{'line': line} for line in arguments.get('lines', [])] + return self._set_source_breakpoints(arguments['file'], specifications) + if method == 'set_function_breakpoints': + return self._set_function_breakpoints(arguments.get('names', [])) + if method == 'continue': + if self.running: + raise SessionError('CPU уже выполняется; сначала Pause') + result = self.session.bridge.request('continue') + self.running = True + self._emit('continued', {}) + return result + if method == 'pause': + self.source_step = None + self.session.bridge.request('pause') + location = self.session.where(self.session.bridge.wait_stopped()) + self.running = False + self._emit('stopped', {'reason': 'pause', 'location': location}) + return location + if method == 'step': + if self.running: + raise SessionError('CPU уже выполняется; сначала Pause') + self.session.bridge.request('step') + self.running = True + self._emit('continued', {'reason': 'step'}) + location = self.session.where(self.session.bridge.wait_stopped()) + self.running = False + self._emit('stopped', {'reason': 'step', 'location': location}) + return location + if method == 'source_step': + return self._source_step(arguments.get('kind', 'into')) + raise SessionError('Неизвестный RPC-метод: ' + method) + + @staticmethod + def _source_identity(location: dict) -> frozenset[tuple[str, int]]: + return frozenset((item['file'], int(item['line'])) + for item in location.get('sources', [])) + + def _has_stop_breakpoint(self, location: dict) -> bool: + address = location.get('link_address') + return any(info['kind'] == 'stop' and + any(item.get('link_address') == address for item in info['locations']) + for info in self.breakpoint_info.values()) + + def _source_step(self, kind: str) -> dict: + commands = {'into': 'step', 'over': 'step_over', 'out': 'step_out'} + if kind not in commands: + raise SessionError('Неизвестный вид source step') + if self.running: + raise SessionError('CPU уже выполняется; сначала Pause') + start = self.session.where() + if start.get('stale_source'): + raise SessionError('Исходник изменён после сборки; пересоберите программу') + initial = self._source_identity(start) + if not initial: + raise SessionError('Текущий PC не имеет проверенной C-позиции') + self.source_step = { + 'initial': initial, 'kind': kind, 'command': commands[kind], + 'instructions': 1, 'location': start, + } + try: + self.session.bridge.request(commands[kind]) + except BaseException: + self.source_step = None + raise + if kind == 'out': + # Первый out выходит из машинного frame. У банкового вызова + # дальше идём через over до первой позиции C вызывающей функции. + self.source_step['command'] = 'step_over' + self.running = True + self._emit('continued', {'reason': 'step'}) + return {'accepted': True} + + def _source_step_stopped(self, location: dict) -> None: + step = self.source_step + self._handle_logpoints(location) + reason = None + if self._has_stop_breakpoint(location): + reason = 'breakpoint' + elif self._source_identity(location) and self._source_identity(location) != step['initial']: + reason = 'step' + elif step['instructions'] >= 512: + reason = 'step' + self._emit('output', {'category': 'stderr', + 'output': 'sdbg: source step достиг лимита; CPU остановлен\n'}) + if reason is not None: + self.source_step = None + self.running = False + self._emit('stopped', {'reason': reason, 'location': location}) + return + # Машинный over может ждать клавишу сколь угодно долго. Пока CPU + # выполняется, этот автомат не занимает session lock и не ставит + # таймер; пользователь может направить ввод в MAME или нажать Pause. + self.session.bridge.request(step['command']) + step['instructions'] += 1 + step['location'] = location + + def _set_source_breakpoints(self, filename: str, specifications) -> dict: + filename = str(Path(filename).resolve()) + requested = [] + for value in specifications: + if not isinstance(value, dict): + raise SessionError('Описание breakpoint должно быть объектом') + line = int(value['line']) + if line <= 0 or any(item['line'] == line for item in requested): + raise SessionError('Номер строки должен быть положительным и уникальным') + if value.get('condition') or value.get('hitCondition'): + raise SessionError('Условия и hitCondition пока не поддержаны') + message = value.get('logMessage') + if message is not None: + self._validate_log_message(message) + requested.append({'line': line, 'logMessage': message}) + created = [] + results = [] + try: + for specification in requested: + line = specification['line'] + item = self.session.break_line(filename, line, enabled=False) + created.append(item['id']) + kind = 'log' if specification['logMessage'] is not None else 'stop' + self.breakpoint_info[item['id']] = { + 'kind': kind, 'message': specification['logMessage'], + 'locations': item['locations'], 'hits': 0, + } + results.append({'line': line, 'verified': True, + 'logMessage': specification['logMessage'], **item}) + except BaseException: + for identifier in created: + self.breakpoint_info.pop(identifier, None) + try: + self.session.clear_breakpoint(identifier) + except BaseException: + pass + raise + previous = self.source_breakpoints.get(filename, []) + for identifier in previous: + self.breakpoint_info.pop(identifier, None) + self.session.clear_breakpoint(identifier) + self.source_breakpoints[filename] = created + self.session.activate_breakpoints() + return {'file': filename, 'breakpoints': results} + + def _set_function_breakpoints(self, names) -> dict: + requested = [] + for value in names: + name = str(value) + if not name or name in requested: + raise SessionError('Имя функции должно быть непустым и уникальным') + requested.append(name) + created = [] + results = [] + try: + for name in requested: + item = self.session.break_function(name, enabled=False) + created.append(item['id']) + self.breakpoint_info[item['id']] = { + 'kind': 'stop', 'message': None, 'locations': item['locations'], 'hits': 0} + results.append({'name': name, 'verified': True, **item}) + except BaseException: + for identifier in created: + self.breakpoint_info.pop(identifier, None) + try: + self.session.clear_breakpoint(identifier) + except BaseException: + pass + raise + for identifier in self.function_breakpoints: + self.breakpoint_info.pop(identifier, None) + self.session.clear_breakpoint(identifier) + self.function_breakpoints = created + self.session.activate_breakpoints() + return {'breakpoints': results} + + @staticmethod + def _validate_log_message(message: str) -> None: + try: + validate_log_message(message) + except ValueError as error: + raise SessionError(str(error)) from error + + def _read_log_variable(self, name: str, module: str | None) -> dict: + if module is None: + return self.session.read_variable(name) + candidates = [item for item in self.session.model.variables + if item['name'] == name and item['module'] in (None, module)] + if len(candidates) != 1: + raise SessionError('Переменная не найдена или имя неоднозначно') + return self.session.read_variable(name, candidates[0]['module']) + + def _render_log_message(self, message: str, module: str | None = None) -> str: + output = [] + for literal, name, _, _ in string.Formatter().parse(message): + output.append(literal) + if name is not None: + try: + output.append(str(self._read_log_variable(name, module)['value'])) + except SessionError as error: + output.append('') + return ''.join(output) + + def _handle_logpoints(self, location: dict) -> bool: + address = location.get('link_address') + matched_logs = [] + matched_any_log = False + matched_stop = False + for info in self.breakpoint_info.values(): + if not any(item.get('link_address') == address for item in info['locations']): + continue + if info['kind'] == 'stop': + matched_stop = True + else: + matched_any_log = True + condition = info.get('condition') + if condition is not None: + try: + if not self._read_log_variable(condition, info.get('module'))['value']: + continue + except SessionError as error: + if not info.get('condition_warned'): + info['condition_warned'] = True + self._emit('output', {'category': 'stderr', + 'output': 'sdbg: SDBG_LOGIF ' + info.get('tag', '') + + ': ' + str(error) + '\n'}) + continue + info['hits'] += 1 + matched_logs.append(info) + for info in matched_logs: + rendered = self._render_log_message(info['message'], info.get('module')) + self._emit('output', {'category': 'console', + 'output': rendered + '\n', + 'location': location, 'hit': info['hits'], + 'tag': info.get('tag')}) + if self.mame_console: + try: + self.session.bridge.request('console_print', text=rendered) + except (BridgeError, OSError) as error: + if not info.get('console_warned'): + info['console_warned'] = True + self._emit('output', {'category': 'stderr', + 'output': 'sdbg: MAME console: ' + str(error) + '\n'}) + return matched_any_log and not matched_stop + + +class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + + +class SessionRpcServer: + def __init__(self, path, controller: SessionController): + self.path = Path(path) + self.controller = controller + self.path.parent.mkdir(parents=True, exist_ok=True) + if self.path.exists(): + try: + with socket.socket(socket.AF_UNIX) as probe: + probe.connect(str(self.path)) + except OSError: + self.path.unlink() + else: + raise OSError('RPC socket уже занят: ' + str(self.path)) + controller_ref = controller + + class Handler(socketserver.StreamRequestHandler): + def handle(self): + raw = self.rfile.readline(1_048_577) + response = {'id': None, 'ok': False} + try: + if len(raw) > 1_048_576: + raise ValueError('RPC-запрос слишком велик') + request = json.loads(raw) + if not isinstance(request, dict) or not isinstance(request.get('method'), str): + raise ValueError('Неверная структура RPC-запроса') + arguments = request.get('arguments', {}) + if not isinstance(arguments, dict): + raise ValueError('RPC arguments должен быть объектом') + response = {'id': request.get('id'), 'ok': True, + 'result': controller_ref.call(request['method'], arguments)} + except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error: + response.update(error=str(error)) + self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode()) + + self.server = _ThreadedUnixServer(str(self.path), Handler) + self.path.chmod(0o600) + + def serve_forever(self): + try: + self.server.serve_forever(poll_interval=.1) + finally: + self.close() + + def close(self): + self.server.server_close() + self.path.unlink(missing_ok=True) + + +def rpc_call(path, method: str, arguments=None, timeout=10): + request = {'id': 1, 'method': method, 'arguments': arguments or {}} + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(timeout) + client.connect(str(path)) + client.sendall((json.dumps(request, ensure_ascii=False) + '\n').encode()) + file = client.makefile('rb') + raw = file.readline(1_048_577) + if not raw: + raise SessionError('RPC server закрыл соединение без ответа') + response = json.loads(raw) + if not response.get('ok'): + raise SessionError(response.get('error', 'Неизвестная RPC-ошибка')) + return response['result'] diff --git a/toolchain/sdbg/session.py b/toolchain/sdbg/session.py new file mode 100644 index 0000000..531f2e8 --- /dev/null +++ b/toolchain/sdbg/session.py @@ -0,0 +1,276 @@ +"""Высокоуровневая source-debug сессия поверх проверенной карты и IPC.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .image import read_ihx +from .model import DebugMap +from .transport import FileBridge + + +class SessionError(RuntimeError): + pass + + +@dataclass(frozen=True) +class MappingSnapshot: + generation: int + registers: dict[str, int] + bank_pages: dict[int, int] + + +class DebugSession: + """Связывает build identity, состояние CPU и логические операции IDE.""" + + def __init__(self, model: DebugMap, bridge: FileBridge): + self.model = model + self.bridge = bridge + self.mapping: MappingSnapshot | None = None + self.resident_pages: dict[int, int] = {} + self.breakpoints: dict[int, list[int]] = {} + self._next_breakpoint = 1 + stem = Path(model.manifest['executable']).stem + self.image = read_ihx(model.directory / (stem + '.ihx')) + + def attach(self) -> dict: + """Проверяет пакет, остановку CPU, резидентный код и bank mapping.""" + try: + self.model.verify_executable() + hello = self.bridge.handshake() + snapshot = self.bridge.wait_stopped() + mapping = self._mapping(snapshot) + if mapping.bank_pages and not hello.get('capabilities', {}).get('bank_guard'): + raise SessionError('Backend не поддерживает безопасные банковские точки') + self._verify_loaded_code(mapping) + except (ValueError, OSError) as error: + raise SessionError(str(error)) from error + self.mapping = mapping + self._remember_resident_pages(mapping) + return { + 'build_id': self.model.manifest['build_id'], + 'state': snapshot['state'], + 'protocol': hello['protocol'], + 'capabilities': hello.get('capabilities', {}), + 'generation': mapping.generation, + 'pc': mapping.registers['PC'], + 'bank_pages': mapping.bank_pages, + 'stale_sources': self.model.stale_sources, + 'location': self.where(snapshot), + } + + def refresh(self) -> MappingSnapshot: + snapshot = self.bridge.wait_stopped() + self.mapping = self._mapping(snapshot) + self._remember_resident_pages(self.mapping) + self._verify_loaded_code(self.mapping) + return self.mapping + + def _remember_resident_pages(self, mapping: MappingSnapshot) -> None: + bank_values = set(mapping.bank_pages.values()) + windows = {instruction['window'] for instruction in self.model.instructions.values() + if instruction['bank'] is not None} + for window in windows: + value = self._page(mapping, window) + if value is not None and value not in bank_values: + self.resident_pages[window] = value + + @staticmethod + def _page(mapping: MappingSnapshot, window: int) -> int | None: + """Sprinter state PG может включать старшие флаги; port хранит byte.""" + value = mapping.registers.get(f'PG{window}') + return None if value is None else value & 0xff + + def _mapping(self, snapshot: dict) -> MappingSnapshot: + registers = snapshot.get('registers') + if snapshot.get('state') != 'stopped' or not isinstance(registers, dict): + raise SessionError('Backend не предоставил регистры остановленного CPU') + if 'PC' not in registers: + raise SessionError('Backend не предоставил PC') + maximum = max((location['bank'] or 0 for location in + [*self.model.functions, *self.model.variables, + *self.model.instructions.values()]), default=0) + pages: dict[int, int] = {} + table = self.model.symbols.get('_bank_pages') + if maximum: + if table is None: + raise SessionError('В банковской сборке отсутствует _bank_pages') + data = bytes.fromhex(self.bridge.request( + 'memory', address=table, length=maximum + 1)['hex']) + pages = {bank: data[bank] for bank in range(1, maximum + 1)} + if any(value == 0 for value in pages.values()) or \ + len(set(pages.values())) != len(pages): + raise SessionError('_bank_pages ещё не готова или содержит нули/дубликаты') + windows = {location['window'] for location in + [*self.model.functions, *self.model.variables] + if location['bank'] is not None} + missing = [window for window in windows if f'PG{window}' not in registers] + if missing: + raise SessionError('Backend не предоставил PG для банковских окон') + generation = self.bridge.generation + if generation is None: + raise SessionError('Backend не предоставил generation') + return MappingSnapshot(generation, dict(registers), pages) + + def _expected_ranges(self, mapping: MappingSnapshot): + expected: dict[int, int] = {} + active_windows = {instruction['window'] for instruction in self.model.instructions.values() + if instruction['bank'] is not None and + self._page(mapping, instruction['window']) == + mapping.bank_pages[instruction['bank']]} + for instruction in self.model.instructions.values(): + bank = instruction['bank'] + if bank is not None: + if self._page(mapping, instruction['window']) != mapping.bank_pages[bank]: + continue + elif instruction['window'] in active_windows: + # Физическая страница банка закрыла весь resident window. + continue + start = instruction['link_address'] + logical = instruction['logical_address'] + for offset in range(instruction['size']): + if start + offset not in self.image: + raise SessionError(f'В IHX нет инструкции по адресу {start + offset:#x}') + value = self.image[start + offset] + if logical + offset in expected and expected[logical + offset] != value: + raise SessionError(f'Неоднозначный ожидаемый байт {logical + offset:#x}') + expected[logical + offset] = value + return expected + + def _verify_loaded_code(self, mapping: MappingSnapshot) -> None: + expected = self._expected_ranges(mapping) + addresses = sorted(expected) + ranges: list[tuple[int, int]] = [] + for address in addresses: + if not ranges or address != ranges[-1][1] or address - ranges[-1][0] >= 4096: + ranges.append((address, address + 1)) + else: + ranges[-1] = (ranges[-1][0], address + 1) + for start, end in ranges: + actual = bytes.fromhex(self.bridge.request( + 'memory', address=start, length=end - start)['hex']) + wanted = bytes(expected[address] for address in range(start, end)) + if actual != wanted: + mismatch = next(i for i, pair in enumerate(zip(actual, wanted)) + if pair[0] != pair[1]) + raise SessionError( + f'Образ в MAME не соответствует build по адресу {start + mismatch:#x}') + + def _link_address(self, pc: int, mapping: MappingSnapshot) -> int: + candidates = [] + for instruction in self.model.instructions.values(): + if instruction['bank'] is None: + continue + if not (instruction['logical_address'] <= pc < + instruction['logical_address'] + instruction['size']): + continue + if self._page(mapping, instruction['window']) == mapping.bank_pages[instruction['bank']]: + candidates.append(instruction['link_address'] + + pc - instruction['logical_address']) + candidates = sorted(set(candidates)) + if len(candidates) > 1: + raise SessionError(f'Неоднозначное банковское отображение PC={pc:#x}') + return candidates[0] if candidates else pc + + def where(self, snapshot: dict | None = None) -> dict: + mapping = self._mapping(snapshot) if snapshot is not None else self.refresh() + if snapshot is not None: + self._verify_loaded_code(mapping) + pc = mapping.registers['PC'] + link = self._link_address(pc, mapping) + return {'pc': pc, 'link_address': link, **self.model.addr2line(link)} + + def _guard(self, location: dict, mapping: MappingSnapshot) -> dict: + bank = location['bank'] + window = location['window'] + banked_window = any(instruction['bank'] is not None and + instruction['window'] == window + for instruction in self.model.instructions.values()) + if bank is not None: + return {'window': window, 'page': mapping.bank_pages[bank]} + if not banked_window: + return {} + if window not in self.resident_pages: + raise SessionError('Физическая страница resident window ещё не установлена') + return {'window': window, 'page': self.resident_pages[window]} + + def _install(self, locations: list[dict], enabled: bool = True) -> dict: + if not locations: + raise SessionError('Для точки остановки нет исполняемых адресов') + mapping = self.refresh() + ids = [] + conditions = [] + try: + for location in locations: + arguments = self._guard(location, mapping) + if not enabled: + arguments['enabled'] = False + result = self.bridge.request( + 'breakpoint', address=location['logical_address'], + **arguments) + ids.append(result['id']) + conditions.append(result.get('condition', '')) + except BaseException: + for identifier in ids: + try: + self.bridge.request('clear', id=identifier) + except BaseException: + pass + raise + logical = self._next_breakpoint + self._next_breakpoint += 1 + self.breakpoints[logical] = ids + return {'id': logical, 'backend_ids': ids, 'conditions': conditions, + 'locations': locations} + + def break_line(self, filename: str, line: int, enabled: bool = True) -> dict: + resolved = self.model.line_locations(filename, line) + if resolved['stale_source']: + raise SessionError('Исходник изменён после сборки; пересоберите программу') + return self._install(resolved['locations'], enabled) + + def break_function(self, name: str, enabled: bool = True) -> dict: + locations = [function for function in self.model.functions + if function['name'] == name] + return self._install(locations, enabled) + + def break_anchor(self, location: dict, enabled: bool = True) -> dict: + if not location.get('verified') or location.get('source') in self.model.stale_sources: + raise SessionError('SDBG_LOG-якорь не проверен или исходник устарел') + return self._install([location], enabled) + + def activate_breakpoints(self) -> dict: + return self.bridge.request('activate_breakpoints') + + def deactivate_breakpoints(self) -> dict: + return self.bridge.request('deactivate_breakpoints') + + def clear_breakpoint(self, identifier: int) -> dict: + ids = self.breakpoints.pop(identifier, None) + if ids is None: + raise SessionError('Неизвестная логическая точка остановки') + for backend_id in ids: + self.bridge.request('clear', id=backend_id) + return {'cleared': identifier, 'backend_ids': ids} + + def read_variable(self, name: str, module: str | None = None) -> dict: + matches = [variable for variable in self.model.variables + if variable['name'] == name and + (module is None or variable['module'] == module)] + if len(matches) != 1: + raise SessionError('Переменная не найдена или имя неоднозначно') + variable = matches[0] + if not variable['supported'] or variable['size'] not in (1, 2, 4): + raise SessionError('Тип переменной пока не поддерживается') + mapping = self.refresh() + if variable['bank'] is not None: + if self._page(mapping, variable['window']) != mapping.bank_pages[variable['bank']]: + raise SessionError('Банк переменной сейчас не отображён') + else: + bank_pages = set(mapping.bank_pages.values()) + if self._page(mapping, variable['window']) in bank_pages: + raise SessionError('Resident-страница переменной сейчас закрыта банком') + data = bytes.fromhex(self.bridge.request( + 'memory', address=variable['logical_address'], length=variable['size'])['hex']) + value = int.from_bytes(data, 'little', signed=variable['signed']) + return {**variable, 'value': value, 'hex': data.hex()} diff --git a/toolchain/sdbg/transport.py b/toolchain/sdbg/transport.py new file mode 100644 index 0000000..b59e02d --- /dev/null +++ b/toolchain/sdbg/transport.py @@ -0,0 +1,84 @@ +"""Один владелец файлового backend; timeout инвалидирует канал mutations.""" +from __future__ import annotations +import fcntl +import json +from pathlib import Path +import threading +import time +import uuid +from .build import write_json + + +class BridgeError(RuntimeError): + pass + + +class FileBridge: + def __init__(self, directory, session, timeout=5): + self.directory = Path(directory) + self.session = session + self.timeout = timeout + self.generation = None + self.invalid = False + self._mutex = threading.Lock() + self._owner = (self.directory/'owner.lock').open('a') + try: + fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + self._owner.close() + raise BridgeError('У backend уже есть управляющая сессия') + + def close(self): + with self._mutex: + self._owner.close() + + def request(self, command, **args): + with self._mutex: + if self._owner.closed: + raise BridgeError('Канал закрыт') + if self.invalid: + raise BridgeError('Канал инвалидирован после timeout; требуется новая сессия') + identity = str(uuid.uuid4().int) + request = self.directory/f'req_{identity}.json' + response = self.directory/f'resp_{identity}.json' + write_json(request, {'session': self.session, 'generation': self.generation, + 'command': command, 'args': args}) + deadline = time.monotonic()+self.timeout + while time.monotonic() < deadline: + if response.exists(): + try: + value = json.loads(response.read_text()) + if not isinstance(value, dict) or not {'session', 'generation', 'ok'} <= value.keys(): + raise ValueError('Неверная структура ответа') + except (OSError, ValueError) as error: + self.invalid = True + raise BridgeError('Повреждённый ответ backend: ' + str(error)) from error + response.unlink() + if value['session'] != self.session: + self.invalid = True + raise BridgeError('Ответ от другой сессии') + self.generation = value['generation'] + if not value['ok']: + raise BridgeError(value['error']) + return value['result'] + time.sleep(.005) + self.invalid = True + request.unlink(missing_ok=True) + raise BridgeError('Timeout: результат команды неизвестен; автоматический повтор запрещён') + + def wait_stopped(self, timeout=5): + deadline = time.monotonic()+timeout + while time.monotonic() < deadline: + snapshot = self.request('snapshot') + if snapshot['state']=='stopped': + return snapshot + if snapshot['state']=='invalidated': + raise BridgeError('Сессия MAME инвалидирована reset/load') + time.sleep(.005) + raise BridgeError('CPU не остановился за отведённое время') + + def handshake(self): + response = self.request('hello') + if response['protocol'] != 1: + raise BridgeError('Неподдержанная версия протокола') + return response diff --git a/toolchain/sdbg_build.py b/toolchain/sdbg_build.py new file mode 100644 index 0000000..ef39851 --- /dev/null +++ b/toolchain/sdbg_build.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Внутренний сборочный интерфейс sprinter-cc с краткой диагностикой.""" +import argparse +import subprocess +import sys +from pathlib import Path +from sdbg.build import compile_unit + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument('command', choices=['compile']) +p.add_argument('--plain', action='store_true') +p.add_argument('--sdcc', type=Path, required=True) +p.add_argument('--assembler', type=Path, required=True) +p.add_argument('--source', type=Path, required=True) +p.add_argument('--output', type=Path, required=True) +args = sys.argv[1:] +separator = args.index('--') if '--' in args else len(args) +a = p.parse_args(args[:separator]) +try: + compile_unit(a.sdcc, a.assembler, a.source, a.output, + args[separator + 1:], debug=not a.plain) +except subprocess.CalledProcessError as error: + if error.output: + output = error.output.decode(errors='replace') if isinstance(error.output, bytes) else error.output + print(output, file=sys.stderr, end='' if output.endswith('\n') else '\n') + print(f'sdbg: компиляция {a.source.name} не прошла (код {error.returncode})', + file=sys.stderr) + sys.exit(1) +except (ValueError, OSError) as error: + print('sdbg: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/toolchain/sdbg_client.py b/toolchain/sdbg_client.py new file mode 100644 index 0000000..f4e3837 --- /dev/null +++ b/toolchain/sdbg_client.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""CLI-клиент постоянной sdbg-сессии.""" +import argparse +import json +import sys + +from sdbg.server import rpc_call +from sdbg.session import SessionError + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--socket', required=True) + parser.add_argument('method') + parser.add_argument('arguments', nargs='?', default='{}', + help='JSON-объект аргументов') + args = parser.parse_args() + try: + arguments = json.loads(args.arguments) + if not isinstance(arguments, dict): + raise ValueError('arguments должен быть JSON-объектом') + result = rpc_call(args.socket, args.method, arguments, + timeout=35 if args.method == 'events' else 10) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (SessionError, ValueError, OSError) as error: + print('sdbg-client: ' + str(error), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/toolchain/sdbg_config.py b/toolchain/sdbg_config.py new file mode 100644 index 0000000..1ed380e --- /dev/null +++ b/toolchain/sdbg_config.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Обновляет stamp только при смене команды или содержимого зависимостей.""" +import hashlib +import json +from pathlib import Path +import sys + +mode = sys.argv[1] +if mode not in ('check', 'save'): + raise SystemExit('Ожидается check или save') +stamp = Path(sys.argv[2]) +args = sys.argv[3:] +files = set() +for arg in args: + path = Path(arg.split('=', 1)[-1] if '=' in arg else arg) + if path.is_file(): files.add(path.resolve()) +# Manifest предыдущей debug-сборки содержит все препроцессорные зависимости. +for name in list(files): + if name.name == 'manifest.json': + files.update(Path(p) for p in json.loads(name.read_text()).get('sources', {})) + files.discard(name) +content = json.dumps({'args': args, 'files': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() + if p.is_file() else None for p in sorted(files)}}, sort_keys=True) +changed = not stamp.is_file() or stamp.read_text() != content +if mode == 'check': + print('changed' if changed else 'same') +elif changed: + stamp.parent.mkdir(parents=True, exist_ok=True) + temporary = stamp.with_suffix('.tmp') + temporary.write_text(content) + temporary.replace(stamp) diff --git a/toolchain/sdbg_dap.py b/toolchain/sdbg_dap.py new file mode 100644 index 0000000..74cbad3 --- /dev/null +++ b/toolchain/sdbg_dap.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""stdio DAP-адаптер Sprinter MAME; stdout содержит только DAP frames.""" +from __future__ import annotations + +import json +from pathlib import Path +import select +import subprocess +import sys +import tempfile +import threading +import uuid + +from sdbg.dap import DapEngine +from sdbg.session import SessionError + + +class Protocol: + def __init__(self): + self.engine = DapEngine() + self.sequence = 1 + self.write_lock = threading.Lock() + self.stopping = threading.Event() + self.poller = None + self.launcher = None + self.launcher_log = None + + def send(self, message): + with self.write_lock: + message['seq'] = self.sequence + self.sequence += 1 + data = json.dumps(message, ensure_ascii=False, separators=(',', ':')).encode() + sys.stdout.buffer.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + sys.stdout.buffer.flush() + + def event(self, name, body): + self.send({'type': 'event', 'event': name, 'body': body}) + + def _poll(self): + while not self.stopping.is_set(): + try: + events, closed = self.engine.poll_events(1) + for name, body in events: + self.event(name, body) + if closed: + return + except (SessionError, OSError, ValueError) as error: + self.event('output', {'category': 'stderr', 'output': 'sdbg: ' + str(error) + '\n'}) + self.event('terminated', {'restart': False}) + return + + def request(self, request): + response = {'type': 'response', 'request_seq': request.get('seq', 0), + 'command': request.get('command', ''), 'success': True} + events = [] + try: + if request['command'] == 'launch': + body, events = self.launch(request.get('arguments') or {}) + else: + body, events = self.engine.handle(request['command'], request.get('arguments') or {}) + response['body'] = body + except (SessionError, OSError, ValueError, KeyError, TypeError) as error: + response.update(success=False, message=str(error)) + self.send(response) + for name, body in events: + self.event(name, body) + if request.get('command') in ('attach','launch') and response['success'] and self.poller is None: + self.poller = threading.Thread(target=self._poll, name='dap-events', daemon=True) + self.poller.start() + if request.get('command') == 'disconnect': + self.stopping.set() + self.stop_launcher() + + def stop_launcher(self): + if self.launcher is not None and self.launcher.poll() is None: + self.launcher.terminate() + if self.launcher_log is not None: + self.launcher_log.close() + self.launcher_log = None + + def launcher_diagnostics(self): + if self.launcher_log is None: + return '' + self.launcher_log.flush() + self.launcher_log.seek(0) + data = self.launcher_log.read()[-4000:].strip() + return '\n' + data if data else '' + + def launch(self, arguments): + build = arguments.get('build') + if not isinstance(build, str) or not build: + raise SessionError('В launch требуется build с debug-пакетом') + socket_path = arguments.get('socket') or '/tmp/sprinter-sdbg-' + uuid.uuid4().hex + '.sock' + command = [sys.executable, str(Path(__file__).with_name('sdbg_launcher.py')), + '--build', build, '--socket', socket_path] + if arguments.get('mame'): + command.extend(['--mame', arguments['mame']]) + if arguments.get('debugger'): + command.extend(['--debugger', arguments['debugger']]) + if arguments.get('launchAt') is not None: + command.extend(['--launch-at', str(arguments['launchAt'])]) + if arguments.get('dssTimeout') is not None: + command.extend(['--dss-timeout', str(arguments['dssTimeout'])]) + for filename in arguments.get('data', []): + command.extend(['--data', filename]) + self.launcher_log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8') + try: + self.launcher = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=self.launcher_log, text=True) + except OSError: + self.launcher_log.close() + self.launcher_log = None + raise + try: + readable, _, _ = select.select([self.launcher.stdout], [], [], 75) + if not readable: + raise SessionError('Launcher не дошёл до main за 75 секунд' + + self.launcher_diagnostics()) + line = self.launcher.stdout.readline() + if not line: + code = self.launcher.wait(timeout=5) + raise SessionError(f'Launcher завершился до готовности, rc={code}' + + self.launcher_diagnostics()) + ready = json.loads(line) + if not ready.get('ready'): + raise SessionError('Launcher вернул неверный ready') + body, events = self.engine.handle('attach', {'socket': ready['socket']}) + body['entry'] = ready['entry'] + body['mamePid'] = ready['pid'] + body['socket'] = ready['socket'] + return body, events + except BaseException: + self.stop_launcher() + raise + + def run(self): + stream = sys.stdin.buffer + while not self.stopping.is_set(): + headers = {} + while True: + line = stream.readline() + if not line: + self.stopping.set() + return + if line in (b'\r\n', b'\n'): + break + name, value = line.decode('ascii').split(':', 1) + headers[name.lower()] = value.strip() + length = int(headers['content-length']) + if length > 1_048_576: + raise ValueError('DAP frame слишком велик') + request = json.loads(stream.read(length)) + if request.get('type') == 'request': + self.request(request) + + +if __name__ == '__main__': + try: + Protocol().run() + except (ValueError, OSError, KeyError) as error: + print('sdbg-dap: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/toolchain/sdbg_driver.py b/toolchain/sdbg_driver.py new file mode 100644 index 0000000..1a38b43 --- /dev/null +++ b/toolchain/sdbg_driver.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Транзакционная debug-сборка: ошибочная линковка не публикует старый ihx.""" +from __future__ import annotations +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from sdbg.build import digest, write_json + +ROOT = Path(__file__).resolve().parents[1] + + +def metadata(args): + work, exe = map(Path, args[:2]) + layout = dict(zip(('mode', 'code', 'data', 'load', 'entry', 'stack'), args[2:])) + units = [json.loads(p.read_text()) for p in sorted(work.glob('*.sdbg-unit.json'))] + if not units: + raise ValueError('Нет отладочных единиц трансляции') + inputs = {} + snapshots = work / 'sources' + snapshots.mkdir() + dependencies = {} + for unit in units + [json.loads(p.read_text()) for p in work.glob('*.sdbg-input.json')]: + for name, expected in unit['dependency_hashes'].items(): + if name in dependencies and dependencies[name] != expected: + raise ValueError('Зависимость изменилась между TU: ' + name) + dependencies[name] = expected + for name, expected in dependencies.items(): + source = Path(name) + sha = digest(source) + if sha != expected: + raise ValueError('Зависимость изменилась после компиляции: ' + name) + snapshot = 'sources/' + sha + source.suffix + shutil.copyfile(source, work / snapshot) + inputs[name] = {'sha256': sha, 'snapshot': snapshot} + artifacts = {str(p.relative_to(work)): digest(p) for p in work.rglob('*') if p.is_file()} + write_json(work / 'manifest.json', { + 'schema_version': 1, 'build_id': digest(exe), 'executable_sha256': digest(exe), + 'executable': exe.name, 'layout': layout, 'units': units, + 'sources': inputs, 'artifacts': artifacts, + 'sdcc': subprocess.check_output([str(ROOT/'third_party/sdcc/bin/sdcc'), '--version'], text=True).strip(), + 'limitations': ['Нет карты библиотек', 'Нет location ranges локальных', + 'Только оффлайновая карта; образ в MAME не проверен'], + }) + + +def run(wrapper, args): + if '-o' not in args or args.index('-o') + 1 >= len(args): + raise ValueError('Требуется -o FILE') + if '--src-debug' in args and '--src-debug-file' in args: + raise ValueError('--src-debug и --src-debug-file взаимоисключающие') + # Валидируем выбранные TU до запуска compiler. Значения иных опций не TU. + sources, selected = [], [] + i = 0 + valued = {'-o', '-I', '-L', '-E', '-S', '--code-loc', '--data-loc', + '--memory', '--memory-manual', '--stack-size', '-Wl', '--mkexe', + '--max-allocs', '--gfx'} + while i < len(args): + arg = args[i] + if arg in valued | {'--bank', '--w3', '--src-debug-file'}: + if i + 1 == len(args): + raise ValueError(arg + ' требует аргумент') + value = args[i+1] + if arg == '--bank': sources.append(Path(value.split('=', 1)[-1]).resolve()) + if arg == '--w3': sources.append(Path(value).resolve()) + if arg == '--src-debug-file': selected.append(Path(value).resolve()) + i += 2 + elif arg == '--bank-data' and i+1 < len(args) and args[i+1].isdigit(): + i += 2 + else: + if not arg.startswith('-'): sources.append(Path(arg).resolve()) + i += 1 + if any(p not in sources or not p.is_file() for p in selected): + raise ValueError('--src-debug-file должен указывать на входной TU') + output = Path(args[args.index('-o')+1]).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + final_work = output.parent / ('.sprinter-cc-' + output.stem) + locks = output.parent / '.resource-stamps' + locks.mkdir(exist_ok=True) + with (locks / (final_work.name + '.lock')).open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + with tempfile.TemporaryDirectory(prefix='.sdbg-build-', dir=output.parent) as temporary: + stage = Path(temporary) + new_exe = stage / output.name + command = list(args) + command[command.index('-o')+1] = str(new_exe) + env = dict(os.environ, SPRINTER_SDBG_ACTIVE='1', SPRINTER_PYTHON=sys.executable) + subprocess.run([str(Path(wrapper).resolve()), *command], env=env, check=True) + work = stage / final_work.name + manifest = json.loads((work/'manifest.json').read_text()) + manifest['command'] = [str(Path(wrapper).resolve()), *args] + manifest['working_directory'] = str(Path.cwd()) + manifest['selected_sources'] = [str(p) for p in selected or sources] + manifest['input_sources'] = [str(p) for p in sources] + manifest['executable_path'] = str(output) + identity = {'executable': manifest['executable_sha256'], + 'sources': {p: v['sha256'] for p,v in manifest['sources'].items()}, + 'command': manifest['command'], 'sdcc': manifest['sdcc']} + manifest['build_id'] = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + write_json(work/'manifest.json', manifest) + # Проверяем пакет до публикации. Старые exe/work переживают любой + # сбой compiler/linker/парсера; manifest не создаётся из stale ihx. + from sdbg.model import DebugMap + model = DebugMap(work) + index = work/(output.stem+'.sdbg.json') + write_json(index, {'schema_version': 1, 'build_id': manifest['build_id'], + 'functions': model.functions, 'variables': model.variables, + 'instructions': list(model.instructions.values()), + 'markers': model.markers, 'logpoints': model.logpoints}) + manifest['artifacts'][index.name] = digest(index) + write_json(work/'manifest.json', manifest) + backup = stage/'previous-work' + if final_work.exists(): final_work.rename(backup) + try: + work.rename(final_work) + os.replace(new_exe, output) + except BaseException: + if final_work.exists(): shutil.rmtree(final_work) + if backup.exists(): backup.rename(final_work) + raise + print('sdbg: пакет ' + str(final_work)) + + +if __name__ == '__main__': + try: + if sys.argv[1:2] == ['--metadata']: metadata(sys.argv[2:]) + else: run(sys.argv[1], sys.argv[2:]) + except subprocess.CalledProcessError as error: + print(f'sdbg: сборка не прошла (код {error.returncode}); предыдущий пакет сохранён', + file=sys.stderr) + sys.exit(1) + except (ValueError, OSError) as error: + print('sdbg: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/toolchain/sdbg_launcher.py b/toolchain/sdbg_launcher.py new file mode 100644 index 0000000..18cdb98 --- /dev/null +++ b/toolchain/sdbg_launcher.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Изолированный DSS→EXE→main launcher для DAP/session server.""" +from __future__ import annotations + +import argparse +from contextlib import redirect_stdout +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT/'toolchain')) +from mame_interactive import basename_83, build_events +from sdbg.image import read_ihx +from sdbg.model import DebugMap + + +def disk_entries(exe: Path, data) -> list[tuple[str, str]]: + paths = [Path(exe), *(Path(name).resolve() for name in data)] + if any(not path.is_file() for path in paths): + missing = next(path for path in paths if not path.is_file()) + raise ValueError('Не найден файл для debug-дискеты: ' + str(missing)) + entries = [(basename_83(path.name), str(path)) for path in paths] + names = [name.replace(' ', '') for name, _ in entries] + if len(set(names)) != len(names): + raise ValueError('Коллизия имён 8.3 на debug-дискете') + clusters = sum((path.stat().st_size + 511) // 512 for path in paths) + if len(paths) > 224 or clusters > 2847: + raise ValueError('Файлы не помещаются на FAT12 debug-дискету') + return entries + + +def write_keyboard_config(cfg_directory: Path) -> None: + """Включить обе клавиатуры Sprinter для физического ввода в DSS.""" + (cfg_directory/'sprinter.cfg').write_text( + '\n' + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '\n', encoding='utf-8') + + +def lua_script(path, ready, main_address, signature, events, launch_at, + dss_timeout=30): + rows = ',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' + for t,tag,mask,value in events) + text = '''if _G.sdbg_launcher_loaded then return end +_G.sdbg_launcher_loaded=true +local machine=manager.machine +local cpu=machine.devices[":maincpu"] +local debug=machine.debugger +local events={EVENTS} +local signature={SIGNATURE} +local index=1 +local phase="booting" +local active=true +local bp=nil +local false_hits=0 +local launch_started=nil +local prompt_since=nil +local prompt_row=nil +local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end +local vram=nil +for tag,share in pairs(machine.memory.shares) do + if tag:find("vram",1,true) then vram=share;break end +end +local function dss_prompt_row() + if not vram then return nil end + local rgmod=cpu.state.RGMOD.value & 1 + local function char_at(row,col) + return vram:read_u8((1+col+0x80*rgmod)*1024+0x300+row*4+1) + end + for row=0,31 do + local drive=char_at(row,0) + if ((drive>=65 and drive<=90) or (drive>=97 and drive<=122)) and + char_at(row,1)==58 then + for col=2,38 do + local value=char_at(row,col) + if value==62 then + local clean=true + for tail=col+1,math.min(col+8,79) do + local after=char_at(row,tail) + if after~=0 and after~=32 and after~=95 then clean=false;break end + end + if clean then return row end + elseif value<32 then + break + end + end + end + end + return nil +end +_G.sdbg_launcher_reset_subscription=emu.add_machine_reset_notifier(function() + if phase~="booting" then active=false end +end) +debug.execution_state="run" +emu.register_periodic(function() + if not active then return end + local current=now() + if phase=="booting" then + local row=nil + if current>=LAUNCH_NOT_BEFORE then row=dss_prompt_row() end + if row then + if prompt_row~=row then prompt_row=row;prompt_since=current end + if current-prompt_since>=0.25 then + machine.video:snapshot() + bp=cpu.debug:bpset(MAIN,"","") + launch_started=current + phase="loading" + end + else + prompt_row=nil;prompt_since=nil + end + end + while phase=="loading" and index<=#events and current>=launch_started+events[index][1] do + local e=events[index] + for _,field in pairs(machine.ioport.ports[e[2]].fields) do + if field.mask==e[3] then field:set_value(e[4]);break end + end + index=index+1 + end + if phase=="loading" and debug.execution_state=="stop" then + local pc=cpu.state.PC.value + local match=pc==MAIN + for i,b in ipairs(signature) do + if cpu.spaces.program:read_u8(0x10000+MAIN+i-1)~=b then match=false end + end + if not match then false_hits=false_hits+1;debug.execution_state="run";return end + cpu.debug:bpclear(bp) + local file=assert(io.open(READY..".tmp","wb")) + file:write('{"pc":'..pc..',"false_hits":'..false_hits.. + ',"dss_ready_time":'..launch_started..',"prompt_row":'..prompt_row..'}') + file:close();assert(os.rename(READY..".tmp",READY)) + phase="attached" + end + if phase=="booting" and current>DSS_TIMEOUT then + local file=io.open(READY..".error","wb") + if file then file:write("DSS prompt timeout");file:close() end + machine:exit() + elseif phase=="loading" and current>launch_started+30 then + local file=io.open(READY..".error","wb") + if file then file:write("main timeout");file:close() end + machine:exit() + end +end) +''' + text = (text.replace('EVENTS',rows).replace('SIGNATURE',','.join(map(str,signature))) + .replace('LAUNCH_NOT_BEFORE',str(launch_at)).replace('MAIN',str(main_address)) + .replace('READY',json.dumps(str(ready))).replace('DSS_TIMEOUT',str(dss_timeout))) + path.write_text(text) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build',required=True) + parser.add_argument('--socket',required=True) + parser.add_argument('--data',action='append',default=[]) + parser.add_argument('--launch-at',type=float,default=0, + help='не начинать ввод раньше этой секунды эмуляции') + parser.add_argument('--dss-timeout',type=float,default=30, + help='таймаут появления стабильного prompt DSS') + parser.add_argument('--mame',default=str(ROOT/'mame/v306/mame.arm')) + parser.add_argument('--debugger',default='sdbg', + help='OSD debugger provider (по умолчанию sdbg из project patch)') + args=parser.parse_args() + model=DebugMap(args.build) + model.verify_executable() + exe=Path(model.manifest['executable_path']) + functions=[item for item in model.functions if item['name']=='main'] + function=functions[0] if len(functions)==1 else None + if not function or function['bank'] is not None: + raise ValueError('Нужна единственная resident-функция main') + image=read_ihx(model.directory/(exe.stem+'.ihx')) + signature=bytes(image[address] for address in range( + function['start'],min(function['end'],function['start']+24))) + if not signature: + raise ValueError('У main нет проверяемой сигнатуры') + mame=Path(args.mame).resolve() + mame_dir=mame.parent + stopping=False + def stop(*_): + nonlocal stopping + stopping=True + signal.signal(signal.SIGTERM,stop) + signal.signal(signal.SIGINT,stop) + with tempfile.TemporaryDirectory(prefix='sprinter-sdbg-launch-') as temporary: + state=Path(temporary) + ipc=state/'ipc';ipc.mkdir() + for name in ('nvram','cfg','diff','snapshot'): (state/name).mkdir() + write_keyboard_config(state/'cfg') + system=state/'system.chd' + shutil.copyfile(mame_dir/'IMG/sp_hdd_sys.chd',system) + disk_path=state/'debug.img' + spec=importlib.util.spec_from_file_location('make_disk',mame_dir/'make_disk.py') + disk=importlib.util.module_from_spec(spec);spec.loader.exec_module(disk) + files=disk_entries(exe,args.data) + with redirect_stdout(sys.stderr): + if not disk.create_floppy_image(str(disk_path),files): + raise RuntimeError('Не удалось создать debug-дискету') + command_text='a:\\'+basename_83(exe.name).replace(' ','')+'\n' + events=build_events([(0,command_text)]) + ready=state/'main.json' + lua=state/'launch.lua' + lua_script(lua,ready,function['start'],signature,events,args.launch_at, + args.dss_timeout) + session_id=uuid.uuid4().hex + environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id) + command=[str(mame),'sprinter','-noreadconfig','-rompath',str(mame_dir/'roms'), + '-bios','v3.06','-kbd','ms_naturl,bios=sp2k','-video','soft','-window', + '-sound','none','-skip_gameinfo','-beta:wd179x:0','35hd', + '-flop1',str(disk_path),'-hard1',str(system),'-debug','-debugger',args.debugger, + '-plugin','sdbgbridge','-pluginspath',str(ROOT/'toolchain/mcp')+';'+str(ROOT/'mame/sources/MAME/plugins'), + '-autoboot_delay','0','-autoboot_script',str(lua)] + for name in ('nvram','cfg','diff','snapshot'): + command.extend(['-'+name+'_directory',str(state/name)]) + log=(state/'mame.log').open('w') + mame_process=subprocess.Popen(command,cwd=state,env=environment, + stdout=log,stderr=subprocess.STDOUT) + server=None + def mame_diagnostics(): + log.flush() + output=(state/'mame.log').read_text(errors='replace')[-4000:].strip() + return ('\nMAME log:\n'+output) if output else '' + try: + deadline=time.monotonic()+args.dss_timeout+45 + while not ready.exists(): + if mame_process.poll() is not None: + raise RuntimeError('MAME завершился до main; лог: '+ + str(state/'mame.log')+mame_diagnostics()) + if (state/'main.json.error').exists() or time.monotonic()>deadline: + raise RuntimeError('Таймаут ожидания main; лог: '+ + str(state/'mame.log')+mame_diagnostics()) + time.sleep(.05) + entry=json.loads(ready.read_text()) + server=subprocess.Popen([sys.executable,str(ROOT/'toolchain/sdbg_server.py'), + '--build',str(model.directory),'--ipc',str(ipc),'--session',session_id, + '--socket',args.socket],stdout=subprocess.PIPE,stderr=sys.stderr,text=True) + line=server.stdout.readline() + if not line: + raise RuntimeError('Session server не запустился') + server_ready=json.loads(line) + print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid, + 'entry':entry,'build_id':server_ready['build_id']}, + ensure_ascii=False),flush=True) + while not stopping and mame_process.poll() is None and server.poll() is None: + if os.getppid()==1: break + time.sleep(.1) + finally: + if server is not None and server.poll() is None: + server.terminate() + try: server.wait(timeout=3) + except subprocess.TimeoutExpired: server.kill();server.wait() + if mame_process.poll() is None: + mame_process.terminate() + try: mame_process.wait(timeout=5) + except subprocess.TimeoutExpired: mame_process.kill();mame_process.wait() + log.close() + return 0 + + +if __name__=='__main__': + try: + sys.exit(main()) + except (ValueError,OSError,RuntimeError,KeyError,subprocess.SubprocessError) as error: + print('sdbg-launcher: '+str(error),file=sys.stderr) + sys.exit(1) diff --git a/toolchain/sdbg_server.py b/toolchain/sdbg_server.py new file mode 100644 index 0000000..9380a88 --- /dev/null +++ b/toolchain/sdbg_server.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Постоянная sdbg-сессия: единственный владелец MAME для CLI/MCP/DAP.""" +import argparse +import json +import signal +import sys +import threading + +from sdbg.model import DebugMap +from sdbg.server import SessionController, SessionRpcServer +from sdbg.session import DebugSession, SessionError +from sdbg.transport import BridgeError, FileBridge + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', required=True) + parser.add_argument('--ipc', required=True) + parser.add_argument('--session', required=True) + parser.add_argument('--socket', required=True) + parser.add_argument('--timeout', type=float, default=5) + args = parser.parse_args() + bridge = controller = server = None + try: + bridge = FileBridge(args.ipc, args.session, args.timeout) + controller = SessionController(DebugSession(DebugMap(args.build), bridge)) + server = SessionRpcServer(args.socket, controller) + stop = lambda *_: threading.Thread(target=server.server.shutdown, daemon=True).start() + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print(json.dumps({'ready': True, 'socket': args.socket, + 'build_id': controller.attached['build_id']}, ensure_ascii=False), flush=True) + server.serve_forever() + return 0 + except (BridgeError, SessionError, ValueError, OSError, KeyError) as error: + print('sdbg-server: ' + str(error), file=sys.stderr) + return 1 + finally: + if server is not None: + server.close() + if controller is not None: + controller.close() + elif bridge is not None: + bridge.close() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/toolchain/sdbg_session.py b/toolchain/sdbg_session.py new file mode 100644 index 0000000..123ee55 --- /dev/null +++ b/toolchain/sdbg_session.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Одна проверенная live-операция source debugger через sdbgbridge.""" +import argparse +import json +import sys + +from sdbg.model import DebugMap +from sdbg.session import DebugSession, SessionError +from sdbg.transport import BridgeError, FileBridge + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', required=True, help='каталог .sprinter-cc-NAME') + parser.add_argument('--ipc', required=True, help='SDBG_IPC_DIR процесса MAME') + parser.add_argument('--session', required=True, help='SDBG_SESSION_ID процесса MAME') + parser.add_argument('--timeout', type=float, default=5) + commands = parser.add_subparsers(dest='command', required=True) + commands.add_parser('attach') + commands.add_parser('where') + step = commands.add_parser('step') + step.add_argument('--wait', type=float, default=5) + commands.add_parser('continue') + commands.add_parser('activate-breakpoints') + commands.add_parser('deactivate-breakpoints') + line = commands.add_parser('break-line') + line.add_argument('file') + line.add_argument('line', type=int) + line.add_argument('--disabled', action='store_true') + function = commands.add_parser('break-function') + function.add_argument('name') + function.add_argument('--disabled', action='store_true') + variable = commands.add_parser('read-variable') + variable.add_argument('name') + variable.add_argument('--module') + args = parser.parse_args() + bridge = None + try: + bridge = FileBridge(args.ipc, args.session, args.timeout) + session = DebugSession(DebugMap(args.build), bridge) + attached = session.attach() + if args.command == 'attach': result = attached + elif args.command == 'where': result = session.where() + elif args.command == 'break-line': + result = session.break_line(args.file, args.line, not args.disabled) + elif args.command == 'break-function': + result = session.break_function(args.name, not args.disabled) + elif args.command == 'activate-breakpoints': result = session.activate_breakpoints() + elif args.command == 'deactivate-breakpoints': result = session.deactivate_breakpoints() + elif args.command == 'read-variable': result = session.read_variable(args.name, args.module) + elif args.command == 'step': + bridge.request('step') + result = session.where(bridge.wait_stopped(args.wait)) + else: + result = bridge.request('continue') + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (BridgeError, SessionError, ValueError, OSError, KeyError) as error: + print('sdbg-session: ' + str(error), file=sys.stderr) + return 1 + finally: + if bridge is not None: + bridge.close() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/toolchain/vscode-sprinter-debug/README.md b/toolchain/vscode-sprinter-debug/README.md new file mode 100644 index 0000000..2b9aa7c --- /dev/null +++ b/toolchain/vscode-sprinter-debug/README.md @@ -0,0 +1,81 @@ +# Sprinter MAME Debug + +VS Code-клиент сборки и отладки для `toolchain/sdbg_dap.py`. Режим `launch` сам +поднимает изолированные MAME и session server; перед `attach` их запускают +вручную. + +> [!WARNING] +> Native Windows пока не поддерживает полный launch/attach: `windows` +> выбирает только debugger provider MAME, а host-часть всё ещё зависит от +> `fcntl`, Unix domain sockets и Unix launcher. + +Для разработки расширение можно открыть отдельным окном VS Code и запустить +Extension Development Host. Конфигурация проекта: + +```sh +code --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" "$PWD" +``` + +Python выбирается без зависимости от `PATH` GUI: при наличии local +`.python-version` используется `~/.pyenv/shims/python`; для внешнего workspace +без неё расширение ищет установленный `~/.pyenv/versions/3.12*/bin/python`. +Версия local передаётся задаче через `PYENV_VERSION`, поэтому сборка работает +и если `project` лежит вне workspace. При необходимости задайте абсолютный путь через +`sprinterDebugger.pythonCommand`. Выбранные пути печатаются в канале Output +`Sprinter MAME Debug`. + +```json +{ + "type": "sprinter-mame", + "request": "launch", + "name": "Sprinter MAME: Launch", + "build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello" +} +``` + +Перед F5 расширение по умолчанию находит Makefile проекта по пути `build` и +выполняет задачу `make SRC_DEBUG=1` в каталоге проекта через local Python 3.12. +Ошибки SDCC с файлом и строкой попадают в Problems. При ненулевом коде +сборки debug launch отменяется до старта MAME. Для нестандартной раскладки +укажите `"project": "${workspaceFolder}/path/to/app"`; для уже собранного +пакета можно задать `"autoBuild": false`. Явный `preLaunchTask` остаётся под +контролем стандартного механизма VS Code и отключает автоматическую задачу +расширения. + +Команда палитры `Sprinter: Build Active Project` строит проект открытого +C-файла. В `Tasks: Run Task` доступны задачи `Sprinter: Build ...` для +Makefile, включающих `app.mk`. Сборка не заменяет исходный `make` и не пишет +в общий образ дискеты MAME. + +По умолчанию patched backend `sdbg` не открывает отдельное окно debugger MAME. +Чтобы пользоваться им одновременно с VS Code, добавьте в launch: + +```json +"debugger": "osx" +``` + +Без project patch можно задать `auto`; явные native provider: `osx` на macOS, +`windows` в Windows, `qt` или `imgui` в Linux. Host-инструменты sdbg сейчас +рассчитаны на macOS/Linux (`fcntl`, Unix sockets и Unix launcher); native +Windows transport и end-to-end тесты ещё требуются. + +Готовность DSS определяется по стабильному prompt в VRAM. `dssTimeout` +задаёт таймаут (по умолчанию 30 секунд), а `launchAt` — необязательное самое +раннее время ввода команды. + +Ручной attach: + +```json +{ + "type": "sprinter-mame", + "request": "attach", + "name": "Sprinter MAME: Attach", + "socket": "/tmp/sprinter-sdbg.sock" +} +``` + +Сейчас доступны точки по исходнику/функции, `logMessage` с безопасными +подстановками `{variable}`, один проверенный frame, регистры, поддержанные +global/static, continue/pause и instruction step. Source step поддержан для +F10/F11/Shift+F11; банковский step-over и step-out проходят +служебные trampoline до следующей C-позиции. diff --git a/toolchain/vscode-sprinter-debug/build.js b/toolchain/vscode-sprinter-debug/build.js new file mode 100644 index 0000000..85e48a3 --- /dev/null +++ b/toolchain/vscode-sprinter-debug/build.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const path = require('path'); + +function absolutePath(value, workspace) { + if (!value || !workspace) return null; + const expanded = value.replace(/\$\{workspaceFolder\}/g, workspace); + return path.resolve(workspace, expanded); +} + +function projectForLaunch(configuration, workspace) { + const explicit = absolutePath(configuration.project, workspace); + if (explicit) { + if (!fs.existsSync(path.join(explicit, 'Makefile'))) { + throw new Error(`Нет Makefile в каталоге проекта: ${explicit}`); + } + return explicit; + } + const build = absolutePath(configuration.build, workspace); + if (!build) return null; + let directory = path.dirname(build); + while (directory !== path.dirname(directory)) { + if (fs.existsSync(path.join(directory, 'Makefile'))) { + return directory; + } + if (directory === workspace) break; + directory = path.dirname(directory); + } + return null; +} + +function isSprinterMakefile(filename) { + try { + return /include\s+\$\(PROJ_ROOT\)\/app\.mk/.test( + fs.readFileSync(filename, 'utf8')); + } catch (_) { + return false; + } +} + +function taskLabel(project, workspace) { + const relative = path.relative(workspace, project); + return `Sprinter: Build ${relative || '.'}`; +} + +function makeCommand() { + if (process.platform === 'win32') { + throw new Error('Native Windows build/debug пока не поддерживается'); + } + return fs.existsSync('/usr/bin/make') ? '/usr/bin/make' : 'make'; +} + +module.exports = {absolutePath, projectForLaunch, isSprinterMakefile, + taskLabel, makeCommand}; diff --git a/toolchain/vscode-sprinter-debug/build.test.js b/toolchain/vscode-sprinter-debug/build.test.js new file mode 100644 index 0000000..db6216d --- /dev/null +++ b/toolchain/vscode-sprinter-debug/build.test.js @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); +const {projectForLaunch, isSprinterMakefile, taskLabel} = + require('./build'); +const manifest = require('./package.json'); + +const workspace = path.resolve(__dirname, '..', '..'); +const hello = path.join(workspace, 'tests', 'hello'); + +test('launch находит Makefile рядом с debug-пакетом', () => { + assert.equal(projectForLaunch({ + build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello', + }, workspace), hello); + assert.equal(taskLabel(hello, workspace), 'Sprinter: Build tests/hello'); + assert.equal(isSprinterMakefile(path.join(hello, 'Makefile')), true); +}); + +test('пакет в build/ находит Makefile приложения уровнем выше', () => { + const project = path.join(workspace, 'applications', 'SprPoP'); + assert.equal(projectForLaunch({ + build: '${workspaceFolder}/applications/SprPoP/build/.sprinter-cc-sprpop', + }, workspace), project); +}); + +test('явный project без Makefile даёт ошибку вместо stale запуска', () => { + assert.throws(() => projectForLaunch({ + project: 'tests/sdbg/fixtures', build: 'tests/hello/.sprinter-cc-hello', + }, workspace), /Нет Makefile/); +}); + +test('matcher разрешает относительную ошибку SDCC внутри проекта', () => { + const matcher = manifest.contributes.problemMatchers[0]; + const line = 'hello.c:62: error 20: Undefined identifier \'missing_name\''; + const match = new RegExp(matcher.pattern.regexp).exec(line); + assert.deepEqual(match?.slice(1), + ['hello.c', '62', 'error', '20', + "Undefined identifier 'missing_name'"]); + assert.equal(matcher.fileLocation, 'relative'); +}); diff --git a/toolchain/vscode-sprinter-debug/extension.js b/toolchain/vscode-sprinter-debug/extension.js new file mode 100644 index 0000000..7409cb3 --- /dev/null +++ b/toolchain/vscode-sprinter-debug/extension.js @@ -0,0 +1,195 @@ +const path = require('path'); +const vscode = require('vscode'); +const {resolvePython, pyenvEnvironment} = require('./runtime'); +const {projectForLaunch, isSprinterMakefile, taskLabel, makeCommand} = + require('./build'); + +class SprinterAdapterFactory { + constructor(output) { + this.output = output; + } + + createDebugAdapterDescriptor(session) { + const settings = vscode.workspace.getConfiguration('sprinterDebugger'); + const folder = session.workspaceFolder || vscode.workspace.workspaceFolders?.[0]; + const configured = session.configuration.adapterPath; + if (!configured && !folder) { + throw new Error('Откройте workspace C-Compiler или задайте adapterPath'); + } + const adapter = configured || path.join(folder.uri.fsPath, 'toolchain', 'sdbg_dap.py'); + const runtime = resolvePython({ + command: settings.get('pythonCommand', 'auto'), + args: settings.get('pythonArguments', []), + workspace: folder?.uri.fsPath, + }); + const cwd = folder?.uri.fsPath || path.dirname(adapter); + this.output.appendLine(`Python: ${runtime.command}`); + this.output.appendLine(`DAP: ${adapter}`); + const options = {cwd}; + const env = pyenvEnvironment(runtime.command, cwd); + if (env) options.env = env; + return new vscode.DebugAdapterExecutable( + runtime.command, [...runtime.args, adapter], options); + } +} + +class SprinterTaskProvider { + createTask(folder, project, definitionOverride) { + const settings = vscode.workspace.getConfiguration('sprinterDebugger'); + const runtime = resolvePython({ + command: settings.get('pythonCommand', 'auto'), + args: settings.get('pythonArguments', []), + workspace: folder.uri.fsPath, + }); + const relative = path.relative(folder.uri.fsPath, project); + const definition = definitionOverride || + {type: 'sprinter', project: relative || '.'}; + const options = {cwd: project}; + const env = pyenvEnvironment(runtime.command, project) || + pyenvEnvironment(runtime.command, folder.uri.fsPath); + if (env) options.env = env; + const task = new vscode.Task( + definition, folder, taskLabel(project, folder.uri.fsPath), 'sprinter', + new vscode.ProcessExecution(makeCommand(), [ + 'SRC_DEBUG=1', `PYTHON=${runtime.command}`, + ], options), ['$sprinter-sdcc']); + task.group = vscode.TaskGroup.Build; + return task; + } + + async provideTasks() { + const folders = vscode.workspace.workspaceFolders || []; + const results = []; + for (const folder of folders) { + const files = await vscode.workspace.findFiles( + new vscode.RelativePattern(folder, '**/Makefile'), + '**/{third_party,mame,libc,libbgi,toolchain}/**'); + for (const file of files) { + if (isSprinterMakefile(file.fsPath)) { + results.push(this.createTask(folder, path.dirname(file.fsPath))); + } + } + } + return results; + } + + resolveTask(task) { + const folder = task.scope?.uri ? task.scope : + vscode.workspace.workspaceFolders?.[0]; + const project = task.definition.project; + if (!folder || typeof project !== 'string') return undefined; + const directory = path.resolve(folder.uri.fsPath, project); + if (!isSprinterMakefile(path.join(directory, 'Makefile'))) return undefined; + return this.createTask(folder, directory, task.definition); + } +} + +async function runBuildTask(task) { + const early = [], ended = []; + let execution, resolveResult, completed = false; + const result = new Promise(resolve => {resolveResult = resolve;}); + function finish(code) { + if (!completed) { + completed = true; + resolveResult(code); + } + } + const processListener = vscode.tasks.onDidEndTaskProcess(event => { + early.push(event); + if (execution && event.execution === execution) finish(event.exitCode); + }); + const endListener = vscode.tasks.onDidEndTask(event => { + ended.push(event); + if (execution && event.execution === execution) { + // При отмене задачи process exit event может не появиться. + setTimeout(() => finish(undefined), 100); + } + }); + try { + execution = await vscode.tasks.executeTask(task); + const finished = early.find(event => event.execution === execution); + if (finished) finish(finished.exitCode); + if (ended.some(event => event.execution === execution)) { + setTimeout(() => finish(undefined), 100); + } + return await result; + } finally { + processListener.dispose(); + endListener.dispose(); + } +} + +class SprinterConfigurationProvider { + constructor(tasks, output) { + this.tasks = tasks; + this.output = output; + } + + async resolveDebugConfiguration(folder, configuration) { + if (configuration.type !== 'sprinter-mame' || + configuration.request !== 'launch' || + configuration.autoBuild === false || configuration.preLaunchTask) { + return configuration; + } + const workspace = folder?.uri.fsPath || + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspace) return configuration; + try { + const project = projectForLaunch(configuration, workspace); + if (project && isSprinterMakefile(path.join(project, 'Makefile'))) { + const scope = folder || vscode.workspace.workspaceFolders[0]; + const task = this.tasks.createTask(scope, project); + this.output.appendLine(`Build: ${project}`); + const code = await runBuildTask(task); + if (code !== 0) { + vscode.window.showErrorMessage( + `Сборка Sprinter не прошла (код ${code ?? 'отмена'}); MAME не запущен`); + return undefined; + } + } + } catch (error) { + vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`); + return undefined; + } + return configuration; + } +} + +function activate(context) { + const output = vscode.window.createOutputChannel('Sprinter MAME Debug'); + context.subscriptions.push(output); + context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory( + 'sprinter-mame', new SprinterAdapterFactory(output))); + const tasks = new SprinterTaskProvider(); + context.subscriptions.push(vscode.tasks.registerTaskProvider('sprinter', tasks)); + context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider( + 'sprinter-mame', new SprinterConfigurationProvider(tasks, output))); + context.subscriptions.push(vscode.commands.registerCommand('sprinter.buildActive', async () => { + const editor = vscode.window.activeTextEditor; + const folder = editor && vscode.workspace.getWorkspaceFolder(editor.document.uri); + if (!editor || !folder) { + vscode.window.showErrorMessage('Откройте C-файл проекта Sprinter'); + return; + } + let directory = path.dirname(editor.document.uri.fsPath); + const root = folder.uri.fsPath; + while (directory !== path.dirname(directory)) { + if (isSprinterMakefile(path.join(directory, 'Makefile'))) { + try { + await vscode.tasks.executeTask(tasks.createTask(folder, directory)); + } catch (error) { + vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`); + } + return; + } + if (directory === root) break; + directory = path.dirname(directory); + } + vscode.window.showErrorMessage('Не найден проект Sprinter с app.mk'); + })); +} + +function deactivate() {} + +module.exports = {activate, deactivate, SprinterTaskProvider, + SprinterConfigurationProvider, runBuildTask}; diff --git a/toolchain/vscode-sprinter-debug/extension.test.js b/toolchain/vscode-sprinter-debug/extension.test.js new file mode 100644 index 0000000..7f75d95 --- /dev/null +++ b/toolchain/vscode-sprinter-debug/extension.test.js @@ -0,0 +1,134 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +test('F5 выполняет TaskProvider build и не запускает MAME после ошибки', async () => { + let factory, configurationProvider, taskProvider; + let onProcess, onEnd, exitCode = 0; + const errors = []; + const output = {appendLine() {}, dispose() {}}; + class DebugAdapterExecutable { + constructor(command, args, options) { + Object.assign(this, {command, args, options}); + } + } + class ProcessExecution { + constructor(command, args, options) { + Object.assign(this, {command, args, options}); + } + } + class Task { + constructor(definition, scope, name, source, execution, problemMatchers) { + Object.assign(this, {definition, scope, name, source, + execution, problemMatchers}); + } + } + class RelativePattern { + constructor(folder, pattern) {Object.assign(this, {folder, pattern});} + } + const vscode = { + workspace: { + workspaceFolders: [], + getConfiguration: () => ({get: (key, fallback) => fallback}), + findFiles: async () => [], + }, + window: {createOutputChannel: () => output, + showErrorMessage: message => errors.push(message)}, + debug: { + registerDebugAdapterDescriptorFactory: (type, value) => { + assert.equal(type, 'sprinter-mame'); + factory = value; + return {dispose() {}}; + }, + registerDebugConfigurationProvider: (type, value) => { + assert.equal(type, 'sprinter-mame'); + configurationProvider = value; + return {dispose() {}}; + }, + }, + tasks: { + registerTaskProvider: (type, value) => { + assert.equal(type, 'sprinter'); + taskProvider = value; + return {dispose() {}}; + }, + onDidEndTaskProcess: listener => { + onProcess = listener; + return {dispose() {onProcess = null;}}; + }, + onDidEndTask: listener => { + onEnd = listener; + return {dispose() {onEnd = null;}}; + }, + executeTask: async task => { + const execution = {task}; + onProcess({execution, exitCode}); + onEnd({execution}); + return execution; + }, + }, + commands: {registerCommand: (name) => { + assert.equal(name, 'sprinter.buildActive'); + return {dispose() {}}; + }}, + DebugAdapterExecutable, + ProcessExecution, Task, RelativePattern, + TaskGroup: {Build: 'build'}, + }; + const source = fs.readFileSync(path.join(__dirname, 'extension.js'), 'utf8'); + const module = {exports: {}}; + vm.runInNewContext(source, { + require: name => name === 'vscode' ? vscode : require(name), + module, setTimeout, + }, {filename: 'extension.js'}); + const context = {subscriptions: []}; + module.exports.activate(context); + assert.equal(context.subscriptions.length, 5); + + const workspace = path.resolve(__dirname, '..', '..'); + const session = { + workspaceFolder: {uri: {fsPath: workspace}}, + configuration: {}, + }; + const descriptor = factory.createDebugAdapterDescriptor(session); + assert.equal(descriptor.command, + path.join(require('node:os').homedir(), '.pyenv', 'shims', 'python')); + assert.equal(descriptor.args.at(-1), + path.join(workspace, 'toolchain', 'sdbg_dap.py')); + assert.equal(descriptor.options.cwd, workspace); + assert.equal(descriptor.options.env.PYENV_VERSION, '3.12'); + const configuration = { + type: 'sprinter-mame', request: 'launch', + build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello', + }; + assert.equal(await configurationProvider.resolveDebugConfiguration( + session.workspaceFolder, configuration), configuration, errors.join('\n')); + const task = taskProvider.createTask(session.workspaceFolder, + path.join(workspace, 'tests', 'hello')); + assert.equal(task.name, 'Sprinter: Build tests/hello'); + assert.equal(task.execution.command, '/usr/bin/make'); + assert.equal(task.execution.options.cwd, + path.join(workspace, 'tests', 'hello')); + assert.equal(task.execution.options.env.PYENV_VERSION, '3.12'); + assert.deepEqual(Array.from(task.execution.args), + ['SRC_DEBUG=1', `PYTHON=${descriptor.command}`]); + assert.equal(task.problemMatchers[0], '$sprinter-sdcc'); + vscode.workspace.workspaceFolders = [session.workspaceFolder]; + vscode.workspace.findFiles = async () => [ + {fsPath: path.join(workspace, 'tests', 'hello', 'Makefile')}, + {fsPath: path.join(workspace, 'Makefile')}, + ]; + const discovered = await taskProvider.provideTasks(); + assert.equal(discovered.length, 1); + assert.equal(discovered[0].name, task.name); + const unresolved = {scope: session.workspaceFolder, + definition: {type: 'sprinter', project: 'tests/hello'}}; + assert.equal(taskProvider.resolveTask(unresolved).definition, + unresolved.definition); + exitCode = 1; + assert.equal(await configurationProvider.resolveDebugConfiguration( + session.workspaceFolder, {...configuration}), undefined); + assert.match(errors.at(-1), /MAME не запущен/); +}); diff --git a/toolchain/vscode-sprinter-debug/package.json b/toolchain/vscode-sprinter-debug/package.json new file mode 100644 index 0000000..af162a2 --- /dev/null +++ b/toolchain/vscode-sprinter-debug/package.json @@ -0,0 +1,170 @@ +{ + "name": "sprinter-mame-debug", + "displayName": "Sprinter MAME Debug", + "description": "Сборка и DAP-отладка C-приложений Sprinter в MAME", + "version": "0.2.0", + "publisher": "sprinter-c-compiler", + "engines": {"vscode": "^1.85.0"}, + "categories": ["Debuggers"], + "main": "./extension.js", + "activationEvents": ["onDebug", "onTaskType:sprinter", "onCommand:sprinter.buildActive"], + "contributes": { + "commands": [ + {"command": "sprinter.buildActive", "title": "Sprinter: Build Active Project"} + ], + "taskDefinitions": [ + { + "type": "sprinter", + "required": ["project"], + "properties": { + "project": { + "type": "string", + "description": "Путь к каталогу с Makefile приложения относительно workspace" + } + } + } + ], + "problemMatchers": [ + { + "name": "sprinter-sdcc", + "owner": "sprinter-sdcc", + "fileLocation": "relative", + "pattern": { + "regexp": "^(.+?):(\\d+):\\s+(error|warning)\\s+(\\d+):\\s+(.+)$", + "file": 1, + "line": 2, + "severity": 3, + "code": 4, + "message": 5 + } + } + ], + "configuration": { + "title": "Sprinter MAME Debug", + "properties": { + "sprinterDebugger.pythonCommand": { + "type": "string", + "default": "auto", + "description": "Python 3.12 для DAP; auto выбирает local pyenv shim без зависимости от PATH GUI" + }, + "sprinterDebugger.pythonArguments": { + "type": "array", + "items": {"type": "string"}, + "default": [], + "description": "Аргументы пользовательской Python-команды перед путём DAP-адаптера" + } + } + }, + "debuggers": [ + { + "type": "sprinter-mame", + "label": "Sprinter MAME", + "languages": ["c"], + "configurationAttributes": { + "launch": { + "required": ["build"], + "properties": { + "build": { + "type": "string", + "description": "Путь к каталогу .sprinter-cc-NAME" + }, + "project": { + "type": "string", + "description": "Каталог с Makefile для сборки перед запуском; определяется из build, если не задан" + }, + "autoBuild": { + "type": "boolean", + "default": true, + "description": "Добавить Sprinter build task перед F5, когда найден app.mk" + }, + "data": { + "type": "array", + "items": {"type": "string"}, + "description": "Дополнительные файлы на debug-дискету" + }, + "mame": { + "type": "string", + "description": "Путь к mame.arm" + }, + "debugger": { + "type": "string", + "default": "sdbg", + "enum": ["sdbg", "auto", "osx", "windows", "qt", "imgui"], + "enumDescriptions": [ + "Только окно Sprinter и управление из VS Code", + "Выбрать доступный штатный backend MAME", + "VS Code вместе с Cocoa debugger MAME на macOS", + "VS Code вместе с native debugger MAME на Windows", + "Qt debugger MAME, если сборка включает USE_QTDEBUG", + "Debugger MAME внутри основного графического окна" + ], + "description": "OSD debugger provider; sdbg не открывает Cocoa debugger" + }, + "launchAt": { + "type": "number", + "default": 0, + "description": "Не начинать ввод раньше этой секунды эмуляции" + }, + "dssTimeout": { + "type": "number", + "default": 30, + "description": "Таймаут появления стабильного prompt DSS" + } + } + }, + "attach": { + "required": ["socket"], + "properties": { + "socket": { + "type": "string", + "description": "Unix socket запущенного sdbg_server.py" + }, + "adapterPath": { + "type": "string", + "description": "Путь к toolchain/sdbg_dap.py; по умолчанию из workspace" + } + } + } + }, + "configurationSnippets": [ + { + "label": "Sprinter MAME: Launch", + "description": "Запустить изолированный MAME и остановиться на main", + "body": { + "type": "sprinter-mame", + "request": "launch", + "name": "Sprinter MAME: Launch", + "build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\"" + } + }, + { + "label": "Sprinter MAME: Launch + native debugger", + "description": "Запустить VS Code debugger вместе с Cocoa debugger MAME", + "body": { + "type": "sprinter-mame", + "request": "launch", + "name": "Sprinter MAME: Launch + native debugger", + "build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\"", + "debugger": "osx" + } + }, + { + "label": "Sprinter MAME: Attach", + "description": "Подключиться к проверенной sdbg-сессии", + "body": { + "type": "sprinter-mame", + "request": "attach", + "name": "Sprinter MAME: Attach", + "socket": "^\"/tmp/sprinter-sdbg.sock\"" + } + } + ] + } + ] + }, + "scripts": { + "check": "node --check extension.js && node --check build.js && node --test runtime.test.js build.test.js extension.test.js" + }, + "files": ["extension.js", "runtime.js", "build.js", "README.md"], + "license": "BSD-3-Clause" +} diff --git a/toolchain/vscode-sprinter-debug/runtime.js b/toolchain/vscode-sprinter-debug/runtime.js new file mode 100644 index 0000000..7717a96 --- /dev/null +++ b/toolchain/vscode-sprinter-debug/runtime.js @@ -0,0 +1,84 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function executable(filename) { + try { + fs.accessSync(filename, fs.constants.X_OK); + return true; + } catch (_) { + return false; + } +} + +function localPythonVersion(workspace) { + if (!workspace) return null; + let directory = path.resolve(workspace); + while (directory !== path.dirname(directory)) { + try { + const version = fs.readFileSync(path.join(directory, '.python-version'), + 'utf8').trim().split(/\s+/)[0]; + return /^[A-Za-z0-9._-]+$/.test(version) ? version : null; + } catch (_) {} + directory = path.dirname(directory); + } + return null; +} + +function installedPyenvPythons(home) { + try { + const versions = fs.readdirSync(path.join(home, '.pyenv', 'versions')); + return versions.filter(version => /^3\.12(?:\.\d+)?$/.test(version)) + .sort((a, b) => Number(b.split('.')[2] || 0) - + Number(a.split('.')[2] || 0)) + .map(version => path.join(home, '.pyenv', 'versions', version, + 'bin', 'python')); + } catch (_) { + return []; + } +} + +function resolvePython(options = {}) { + const command = String(options.command || 'auto').trim(); + const args = Array.isArray(options.args) ? options.args : []; + if (command !== 'auto') { + return {command, args}; + } + + const home = options.home || os.homedir(); + const workspace = options.workspace; + const isExecutable = options.isExecutable || executable; + const candidates = []; + const shim = path.join(home, '.pyenv', 'shims', 'python'); + const version = options.localVersion === undefined ? + localPythonVersion(workspace) : options.localVersion; + const installed = options.installedPyenv === undefined ? + installedPyenvPythons(home) : options.installedPyenv; + if (version) candidates.push(shim); + candidates.push(...installed); + if (!version) candidates.push(shim); + if (workspace) { + candidates.push(path.join(workspace, '.venv', 'bin', 'python')); + } + candidates.push( + '/opt/homebrew/bin/python3.12', + '/usr/local/bin/python3.12', + '/usr/bin/python3.12', + ); + const found = candidates.find(isExecutable); + if (!found) { + throw new Error( + 'Не найден Python 3.12. Задайте абсолютный путь в ' + + 'sprinterDebugger.pythonCommand.'); + } + return {command: found, args: []}; +} + +function pyenvEnvironment(command, workspace, home = os.homedir()) { + const shim = path.join(home, '.pyenv', 'shims', 'python'); + const version = command === shim ? localPythonVersion(workspace) : null; + return version ? {PYENV_VERSION: version} : undefined; +} + +module.exports = {resolvePython, localPythonVersion, installedPyenvPythons, + pyenvEnvironment}; diff --git a/toolchain/vscode-sprinter-debug/runtime.test.js b/toolchain/vscode-sprinter-debug/runtime.test.js new file mode 100644 index 0000000..5ead0bd --- /dev/null +++ b/toolchain/vscode-sprinter-debug/runtime.test.js @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); +const {resolvePython, localPythonVersion, pyenvEnvironment} = require('./runtime'); + +test('auto использует pyenv shim без PATH GUI', () => { + const home = path.join(path.sep, 'Users', 'tester'); + const shim = path.join(home, '.pyenv', 'shims', 'python'); + const result = resolvePython({ + command: 'auto', + workspace: path.join(home, 'project'), + home, + isExecutable: filename => filename === shim, + }); + assert.deepEqual(result, {command: shim, args: []}); +}); + +test('local pyenv имеет приоритет над случайным .venv', () => { + const home = path.join(path.sep, 'Users', 'tester'); + const workspace = path.join(home, 'project'); + const shim = path.join(home, '.pyenv', 'shims', 'python'); + const venv = path.join(workspace, '.venv', 'bin', 'python'); + const result = resolvePython({ + command: 'auto', workspace, home, + isExecutable: filename => filename === shim || filename === venv, + }); + assert.equal(result.command, shim); +}); + +test('явная команда и аргументы сохраняются', () => { + const result = resolvePython({ + command: '/python/custom', + args: ['-I'], + isExecutable: () => false, + }); + assert.deepEqual(result, {command: '/python/custom', args: ['-I']}); +}); + +test('auto сообщает понятную ошибку без Python 3.12', () => { + assert.throws( + () => resolvePython({command: 'auto', isExecutable: () => false}), + /Задайте абсолютный путь/, + ); +}); + +test('внешний workspace без .python-version выбирает установленный pyenv 3.12', () => { + const home = path.join(path.sep, 'Users', 'tester'); + const installed = path.join(home, '.pyenv', 'versions', '3.12.14', + 'bin', 'python'); + const result = resolvePython({ + command: 'auto', workspace: '/tmp/external-project', home, + localVersion: null, installedPyenv: [installed], + isExecutable: filename => filename === installed || + filename === path.join(home, '.pyenv', 'shims', 'python'), + }); + assert.equal(result.command, installed); +}); + +test('shim получает версию workspace при сборке за пределами его дерева', () => { + const workspace = path.resolve(__dirname, '..', '..'); + const version = localPythonVersion(workspace); + assert.match(version, /^3\.12/); + const shim = path.join(require('node:os').homedir(), '.pyenv', + 'shims', 'python'); + assert.deepEqual(pyenvEnvironment(shim, workspace), + {PYENV_VERSION: version}); +});