Sprinter: добавить отладку C-исходников и интеграцию VS Code

This commit is contained in:
2026-09-15 17:58:41 +03:00
parent 50c6e56b7b
commit e4695b8281
62 changed files with 7147 additions and 27 deletions
+27
View File
@@ -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`).
+7 -8
View File
@@ -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"
+7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.12
+10
View File
@@ -56,6 +56,16 @@ make size-baseline # принять текущие размеры эталон
- Справочник API — docs/libc-reference.md (обновлять при добавлении
функций).
## Документация source debugger
При изменении `<sdbg.h>`, извлечения/форматирования 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, остальные
+16 -1
View File
@@ -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
+27 -6
View File
@@ -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
+65 -10
View File
@@ -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"
+15
View File
@@ -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_* включены.
## <sdbg.h> — логи 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 сессии; без неё макрос сам ничего не печатает.
## <sprinter.h> — платформа
Константы портов (PORT_PAGE_W0..W3, PORT_RGADR, PORT_RGMOD), номера
+20 -1
View File
@@ -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 можно увеличить.
+326
View File
@@ -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.h>`: `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 не поддержаны, карта сообщает ограничения.
+817
View File
@@ -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-<name>/`: `.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.h>`: `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 | 01, 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 | 15, R03 |
| Watchpoint через CPU/alias | Адрес, момент и PC-источник корректны | 0–5, R03 |
| Entry до банков, ошибка loader | Нет ранней активации/ложного ready | 2, R05 |
| DSS/exit/restart/reset/state load | Нет чужих попаданий и stale handles | 25, R05/R12 |
| Изменённый source или чужой exe | Явное расхождение карты | 1–5, R12 |
| Log + user + temporary по одному PC | Лог есть, остановка сохранена | 2–4, R08 |
| Два клиента, ручной resume, reconnect | Арбитраж/события, нет повторных mutations | 24, R07/R15 |
| Одна строка в цикле, HALT/ISR/рекурсия/trampoline | Ограниченный отменяемый шаг | 2–5, R06/R13 |
| Signed 8/16, pointer/array, CP866, нет NUL | Знак/тип/кодировка, bounded read | 35, 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-интеграция.
+204
View File
@@ -0,0 +1,204 @@
# Логи C-приложения через `SDBG_LOG`
Этот документ описывает **работающий контракт** макросов из `<sdbg.h>`.
Они ставят авторские logpoints в MAME debugger без вызовов DSS/BIOS и без
кода печати в приложении. Сообщение попадает в Debug Console VS Code и в
журнал debugger MAME. Отдельное окно MAME debugger видно при launch с
`"debugger": "osx"`; режим `sdbg` оставляет окно Sprinter и журнал MAME,
но не открывает штатное debugger-окно.
## Быстрый пример
```c
#include <stdint.h>
#include <sdbg.h>
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. Если имя не найдено/неоднозначно, тип неподдержан или физический банк
сейчас не отображён, вместо значения выводится `<unavailable: причина>`.
Локальные/параметры функции не имеют доказанных 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 | Якорь не совпал с исполняемой инструкцией; переместите макрос к доказанной границе и пересоберите |
| `<unavailable: …>` | Проверьте global/static тип и отображение страницы банка; локальные пока не поддерживаются |
| Нет сообщения в окне MAME при `sdbg` | Штатное debugger-окно в этом режиме скрыто; Debug Console VS Code работает, для окна выберите `osx` |
| При частом логе программа заметно медленнее | Каждое попадание останавливает CPU; уменьшите частоту или используйте условный макрос |
## Актуализация контракта
При изменении `<sdbg.h>`, парсера/форматтера шаблона, набора читаемых типов,
источников значений, условий, поведения 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`.
+272
View File
@@ -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.h>`;
полный синтаксис и таблица поддержанных типов — в
[руководстве по SDBG_LOG](sdbg-log-macros.md):
```c
#include <sdbg.h>
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]`; адаптер не выбирает строку молча.
+26
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@
int main(void)
{
errno = -1;
errno = -2;
int16_t a0 = 0, a1 = 0;
a0 = get_text_attr();
+5
View File
@@ -0,0 +1,5 @@
/* Общий inline выявляет коллизии CDB между единицами трансляции. */
static inline unsigned char transform(unsigned char value)
{
return value ^ 0x5a;
}
+5
View File
@@ -0,0 +1,5 @@
#include "common.h"
unsigned char helper(unsigned char value)
{
return transform(value);
}
+2
View File
@@ -0,0 +1,2 @@
static int counter;
int left(void) { counter = 1; return counter; }
+17
View File
@@ -0,0 +1,17 @@
#include <sdbg.h>
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;
}
+12
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
static int counter;
int right(void) { counter = 2; return counter; }
+86
View File
@@ -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)
+389
View File
@@ -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()<deadline:
events,closed=engine.poll_events(min(.5,deadline-time.monotonic()))
seen.extend(events)
if any(name=='stopped' for name,_ in events): return seen
if closed: break
raise RuntimeError('Исходный шаг не дал stopped: '+str(seen))
def main():
banked_mode = '--banked' in sys.argv
work=ROOT/'build/sdbg-live'
work.mkdir(parents=True,exist_ok=True)
source=work/'probe.c'
source.write_text('''volatile int total;
int main(void) {
total = 42;
while (total) {
total++;
}
return 0;
}
''')
bank_source=work/'probe_bank.c'
if banked_mode:
source.write_text('''volatile int total;
void worker(void) __banked;
int main(void) {
total = 1;
worker();
while (total) { }
return 0;
}
''')
bank_source.write_text('''volatile int bank_value;
void worker(void) __banked {
bank_value = 77;
}
''')
exe=work/'probe.exe'
build=[str(ROOT/'bin/sprinter-cc'),'--src-debug','-o',str(exe)]
if banked_mode:
build.extend(['--memory','huge','--bank','1='+str(bank_source)])
build.append(str(source))
subprocess.run(build,check=True)
model=DebugMap(work/'.sprinter-cc-probe')
function=next(f for f in model.functions if f['name']=='main')
memory=read_ihx(model.directory/'probe.ihx')
signature=[memory[a] for a in range(function['start'],min(function['end'],function['start']+24))]
main_address=function['start']
if '--launcher' in sys.argv:
assert banked_mode,'--launcher probe пока требует --banked'
from sdbg.dap import DapEngine
socket_path=Path('/tmp')/('sprinter-launch-'+uuid.uuid4().hex+'.sock')
launcher_command=[sys.executable,str(ROOT/'toolchain/sdbg_launcher.py'),
'--build',str(model.directory),'--socket',str(socket_path)]
if '--native-debugger' in sys.argv:
launcher_command.extend(['--debugger','osx'])
launcher=subprocess.Popen(launcher_command,
stdout=subprocess.PIPE,stderr=sys.stderr,text=True)
try:
readable,_,_=select.select([launcher.stdout],[],[],75)
assert readable,'Launcher не сообщил ready'
ready=json.loads(launcher.stdout.readline())
assert ready['entry']['false_hits']==0,ready
engine=DapEngine()
engine.handle('attach',{'socket':ready['socket']})
if '--source-step' in sys.argv:
entry_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
engine.handle('stepIn',{})
wait_dap_stop(engine)
into_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
engine.handle('next',{})
wait_dap_stop(engine)
over_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert entry_frame['name'].startswith('main'),entry_frame
assert into_frame['name'].startswith('main'),into_frame
assert over_frame['name'].startswith('main'),over_frame
assert into_frame['line'] != entry_frame['line'],(
entry_frame,into_frame)
assert over_frame['line'] != into_frame['line'],(
into_frame,over_frame)
print(json.dumps({'event':'source_step_verified',
'entry':entry_frame,'into':into_frame,
'over':over_frame,
'dss_ready_time':ready['entry']['dss_ready_time']}))
return 0
engine.handle('setBreakpoints',{'source':{'path':str(bank_source)},
'breakpoints':[{'line':3,'logMessage':'bank_value={bank_value}'}]})
engine.handle('setFunctionBreakpoints',{'breakpoints':[{'name':'worker'}]})
engine.handle('continue',{})
deadline=time.monotonic()+5
stopped=False
dap_events=[]
while time.monotonic()<deadline:
events,closed=engine.poll_events(.5)
dap_events.extend(events)
if any(name=='stopped' for name,_ in events): stopped=True;break
assert stopped
assert any(name=='output' and body['output']=='bank_value=0\n'
for name,body in dap_events),dap_events
frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert frame['name'].startswith('worker'),frame
if '--step-out' in sys.argv:
engine.handle('stepOut',{})
wait_dap_stop(engine)
caller=engine.handle('stackTrace',{})[0]['stackFrames'][0]
assert caller['name'].startswith('main'),caller
print(json.dumps({'event':'step_out_verified',
'worker':frame,'caller':caller,
'dss_ready_time':ready['entry']['dss_ready_time']}))
return 0
print(json.dumps({'event':'launcher_verified','entry':ready['entry'],
'build_id':ready['build_id'],'frame':frame['name'],
'logpoint':'bank_value=0'}))
return 0
finally:
if launcher.poll() is None:
launcher.terminate()
try: launcher.wait(timeout=8)
except subprocess.TimeoutExpired: launcher.kill();launcher.wait()
spec=importlib.util.spec_from_file_location('make_disk',ROOT/'mame/v306/make_disk.py')
disk=importlib.util.module_from_spec(spec);spec.loader.exec_module(disk)
disk.create_floppy_image(str(work/'probe.img'),[('PROBE.EXE',str(exe))])
system=work/'system.chd'
if not system.exists(): shutil.copyfile(ROOT/'mame/v306/IMG/sp_hdd_sys.chd',system)
events=build_events([(10,'a:\\probe.exe\n')])
ev=',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events)
report=work/'result.jsonl'
report.write_text('')
lua=work/'probe.lua'
lua.write_text('''if _G.sdbg_probe_loaded then return end
_G.sdbg_probe_loaded=true
local machine=manager.machine
local cpu=machine.devices[":maincpu"]
local debug=machine.debugger
local report=io.open(REPORT,"a")
local function emit(text) report:write(text .. "\\n");report:flush() end
local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end
local events={EVENTS}
local index=1
local signature={SIGNATURE}
local phase="booting"
local last_time=nil
local ticks=0
local bp=nil
local false_hits=0
local launch_reported=false
local active=true
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
-- Первый notifier относится к начальному machine start. После arm это
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
if phase~="booting" then active=false end
end)
debug.execution_state="run"
emu.register_periodic(function()
if not active then return end
ticks=ticks+1
-- До окна запуска приложения DSS работает вообще без наших точек.
-- Первая клавиша launch sequence придёт через полсекунды после arm.
if phase=="booting" and now()>=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()<deadline:
found,closed=dap.poll_events(.5)
dap_events.extend(found)
if any(name=='stopped' for name,_ in dap_events): break
assert any(name=='stopped' for name,_ in dap_events),dap_events
frame=dap.handle('stackTrace',{})[0]['stackFrames'][0]
assert frame['name'].startswith('worker'),frame
location=dap.rpc('where')
assert location['function']['bank']==1
debug_wall_ms=round((time.monotonic()-started)*1000,2)
with report.open('a') as file:
file.write(json.dumps({'event':'server_dap_verified',
'build_id':attached['buildId'],'frame':frame['name'],
'line_break_pc':location['pc'],'bank':1,
'debug_wall_ms':debug_wall_ms})+'\n')
else:
bridge=FileBridge(ipc,env['SDBG_SESSION_ID'])
assert bridge.handshake()['protocol']==1
try:
FileBridge(ipc,env['SDBG_SESSION_ID'])
raise AssertionError('Допущен второй управляющий клиент')
except BridgeError: pass
session=DebugSession(model,bridge)
attached=session.attach()
assert attached['pc']==main_address
assert session.read_variable('total')['value']==0
bridge.generation-=1
try:
bridge.request('memory',address=main_address,length=1)
raise AssertionError('Принята устаревшая generation')
except BridgeError as error:
assert 'generation' in str(error)
try:
bridge.request('clear',id=999999)
raise AssertionError('Удалена чужая точка')
except BridgeError: pass
started=time.monotonic()
if banked_mode:
breakpoint=session.break_function('worker',enabled=False)
expected='ib@e2=='+format(attached['bank_pages'][1],'x')
assert breakpoint['conditions']==[expected]
assert session.activate_breakpoints()['enabled']>=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())
+239
View File
@@ -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)
+146
View File
@@ -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()
+48
View File
@@ -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()
+231
View File
@@ -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 <sdbg.h>\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 <sdbg.h>\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 <sdbg.h>\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()
+317
View File
@@ -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()
+143
View File
@@ -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()
+71
View File
@@ -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()
+18
View File
@@ -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
@@ -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_common_t &>(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 <Cocoa/Cocoa.h>
+
+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
+242
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"plugin":{"name":"sdbgbridge","description":"Sprinter: протокол сессии отладки","version":"0.1.0","author":"Sprinter C-Compiler contributors","type":"plugin","start":"false"}}
+19
View File
@@ -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"
+39
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Карта оптимизированного кода SDCC и пакет отладки Sprinter."""
+113
View File
@@ -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()},
})
+198
View File
@@ -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', '<unknown>') +
(' [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 = '<unavailable: ' + str(error) + '>'
result.append({'name': label, 'value': text, 'type': variable['type'],
'variablesReference': 0,
'memoryReference': hex(variable['link_address'])})
return result
+51
View File
@@ -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
+152
View File
@@ -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
+193
View File
@@ -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
+464
View File
@@ -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('<unavailable: ' + str(error) + '>')
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']
+276
View File
@@ -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()}
+84
View File
@@ -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
+31
View File
@@ -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)
+32
View File
@@ -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())
+31
View File
@@ -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)
+162
View File
@@ -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)
+139
View File
@@ -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)
+279
View File
@@ -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(
'<?xml version="1.0"?>\n'
'<mameconfig version="10">\n'
' <system name="sprinter">\n'
' <input>\n'
' <keyboard tag=":" enabled="1" />\n'
' <keyboard tag=":kbd:ms_naturl" enabled="1" />\n'
' </input>\n'
' </system>\n'
'</mameconfig>\n', encoding='utf-8')
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)
+48
View File
@@ -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())
+67
View File
@@ -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())
+81
View File
@@ -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-позиции.
+53
View File
@@ -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};
@@ -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');
});
@@ -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};
@@ -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 не запущен/);
});
@@ -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"
}
@@ -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};
@@ -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});
});