Sprinter: добавить отладку C-исходников и интеграцию VS Code
This commit is contained in:
@@ -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
@@ -1,14 +1,10 @@
|
|||||||
[mcp_servers.mame-z80]
|
[mcp_servers.mame-z80]
|
||||||
command = "/Users/alex/.local/bin/uv"
|
command = "sh"
|
||||||
args = [
|
args = [
|
||||||
"run",
|
"-c",
|
||||||
"--python",
|
'root=$(git rev-parse --show-toplevel) || exit; exec sh "$root/toolchain/run-mame-mcp.sh"',
|
||||||
"3.12",
|
|
||||||
"--no-project",
|
|
||||||
"--with",
|
|
||||||
"mcp<2",
|
|
||||||
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
|
|
||||||
]
|
]
|
||||||
|
startup_timeout_sec = 60
|
||||||
|
|
||||||
[mcp_servers.mame-z80.tools.clear_breakpoint]
|
[mcp_servers.mame-z80.tools.clear_breakpoint]
|
||||||
approval_mode = "approve"
|
approval_mode = "approve"
|
||||||
@@ -27,3 +23,6 @@ approval_mode = "approve"
|
|||||||
|
|
||||||
[mcp_servers.mame-z80.tools.pause]
|
[mcp_servers.mame-z80.tools.pause]
|
||||||
approval_mode = "approve"
|
approval_mode = "approve"
|
||||||
|
|
||||||
|
[mcp_servers.mame-z80.tools.status]
|
||||||
|
approval_mode = "approve"
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ toolchain/mkexe/tests/*.actual
|
|||||||
*.obj
|
*.obj
|
||||||
*.dSYM/
|
*.dSYM/
|
||||||
|
|
||||||
|
# Python host-tools: bytecode всегда воспроизводим и не входит в исходники.
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Vendored / downloaded
|
# Vendored / downloaded
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -138,3 +142,6 @@ applications/PoP/R1/
|
|||||||
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
|
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
|
||||||
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
|
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
|
||||||
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
|
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
|
||||||
|
|
||||||
|
# Локальные пути MCP; общая конфигурация .codex остаётся в Git.
|
||||||
|
/.codex/mame.local.env
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -56,6 +56,16 @@ make size-baseline # принять текущие размеры эталон
|
|||||||
- Справочник API — docs/libc-reference.md (обновлять при добавлении
|
- Справочник 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/)
|
## ABI и платформа (кратко; детали в memory/)
|
||||||
|
|
||||||
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
|
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ DATA_FILES := \
|
|||||||
examples/mdview/SAMPLE.MD
|
examples/mdview/SAMPLE.MD
|
||||||
|
|
||||||
.PHONY: all tools lib tests examples check clean sdcc floppy \
|
.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
|
all: tools lib tests
|
||||||
|
|
||||||
@@ -87,6 +87,21 @@ HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
|
|||||||
host-tests:
|
host-tests:
|
||||||
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
|
@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.
|
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
|
||||||
size-check:
|
size-check:
|
||||||
python3 toolchain/size_check.py
|
python3 toolchain/size_check.py
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
# # STACK_SIZE := 2048 # bytes reserved for the stack
|
# # STACK_SIZE := 2048 # bytes reserved for the stack
|
||||||
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
|
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
|
||||||
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
|
# # 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`
|
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
|
||||||
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
|
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
|
||||||
#
|
#
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
# (in the repo root) still packs all examples.
|
# (in the repo root) still packs all examples.
|
||||||
# run floppy + launch MAME
|
# run floppy + launch MAME
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
SPRINTER_CC := $(PROJ_ROOT)/bin/sprinter-cc
|
SPRINTER_CC := $(PROJ_ROOT)/bin/sprinter-cc
|
||||||
MKEXE := $(PROJ_ROOT)/toolchain/mkexe/mkexe
|
MKEXE := $(PROJ_ROOT)/toolchain/mkexe/mkexe
|
||||||
LIB := $(PROJ_ROOT)/lib/sprinter.lib
|
LIB := $(PROJ_ROOT)/lib/sprinter.lib
|
||||||
@@ -77,6 +80,12 @@ ifneq ($(STACK_SIZE),)
|
|||||||
CC_FLAGS += --stack-size $(STACK_SIZE)
|
CC_FLAGS += --stack-size $(STACK_SIZE)
|
||||||
endif
|
endif
|
||||||
CC_FLAGS += $(EXTRA_FLAGS)
|
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)
|
all: $(EXE)
|
||||||
|
|
||||||
@@ -99,12 +108,24 @@ BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
|
|||||||
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
|
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
|
||||||
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
|
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
|
||||||
|
|
||||||
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
|
# Команда/зависимости карты участвуют в пересборке, включая смену режима.
|
||||||
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
|
SDBG_CONFIG := $(dir $(EXE)).resource-stamps/$(EXAMPLE)-config.json
|
||||||
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
|
SDBG_MANIFEST := $(dir $(EXE)).sprinter-cc-$(EXAMPLE)/manifest.json
|
||||||
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
|
.PHONY: sdbg-config-force
|
||||||
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
|
SDBG_CONFIG_ARGS = $(SDBG_CONFIG) $(SPRINTER_CC) $(CC_FLAGS) $(SOURCES) $(SDBG_MANIFEST)
|
||||||
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
|
|
||||||
|
# Проверка содержимого обязательна: 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):
|
$(MKEXE):
|
||||||
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
|
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
|
||||||
|
|||||||
+65
-10
@@ -65,6 +65,8 @@
|
|||||||
# --max-allocs N SDCC --max-allocs-per-node (default: 100000 — smaller/
|
# --max-allocs N SDCC --max-allocs-per-node (default: 100000 — smaller/
|
||||||
# faster code at the cost of compile time; pass a lower
|
# faster code at the cost of compile time; pass a lower
|
||||||
# value, e.g. SDCC's own default 3000, to compile faster).
|
# 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
|
# --debug enable runtime diagnostics — defines DEBUG_RT for both
|
||||||
# sdcc (-DDEBUG_RT) and the crt0 assembly (prepended
|
# sdcc (-DDEBUG_RT) and the crt0 assembly (prepended
|
||||||
# `DEBUG_RT = 1`). Exposes runtime introspection symbols
|
# `DEBUG_RT = 1`). Exposes runtime introspection symbols
|
||||||
@@ -81,6 +83,18 @@
|
|||||||
|
|
||||||
set -eo pipefail
|
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 ------------------------------------------------
|
# ------- Locate the toolchain ------------------------------------------------
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
PROJ_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
PROJ_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||||
@@ -109,6 +123,8 @@ LOAD_ADDR=""
|
|||||||
ENTRY_ADDR=""
|
ENTRY_ADDR=""
|
||||||
STACK_ADDR="0xBFFE"
|
STACK_ADDR="0xBFFE"
|
||||||
STACK_SIZE="" # if set, used to compute HEAP_TOP = STACK_ADDR + 1 - STACK_SIZE
|
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
|
DEBUG_RT=0 # if 1, prepend `DEBUG_RT = 1` to crt0 + pass -DDEBUG_RT to sdcc
|
||||||
VERBOSE=0
|
VERBOSE=0
|
||||||
USER_INCS=()
|
USER_INCS=()
|
||||||
@@ -135,6 +151,7 @@ SAFE_LIBS=0 # if 1, prefer *_safe.lib variants (fallback to standard)
|
|||||||
# ------- Parse args ----------------------------------------------------------
|
# ------- Parse args ----------------------------------------------------------
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
|
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
echo ' --src-debug / --src-debug-file FILE: карта исходников C и asm'
|
||||||
exit "${1:-0}"
|
exit "${1:-0}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +183,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
|
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
|
||||||
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
|
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
|
||||||
--gfx) GFX_MODE="$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;;
|
--debug) DEBUG_RT=1; shift;;
|
||||||
--safe) SAFE_LIBS=1; shift;;
|
--safe) SAFE_LIBS=1; shift;;
|
||||||
-v) VERBOSE=1; shift;;
|
-v) VERBOSE=1; shift;;
|
||||||
@@ -415,14 +436,44 @@ else
|
|||||||
fi
|
fi
|
||||||
run "$SDASZ80" -o "$HEAP_TOP_REL" "$HEAP_TOP_SRC"
|
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)
|
# 2. user sources → .rel (HOME)
|
||||||
USER_RELS=()
|
USER_RELS=()
|
||||||
CC_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size -I "$INC_DIR" -I "$BGI_INC_DIR" "${USER_INCS[@]}" "${USER_DEFS[@]}")
|
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)
|
[[ $DEBUG_RT -eq 1 ]] && CC_FLAGS+=(-DDEBUG_RT)
|
||||||
[[ -n "$MAX_ALLOCS" ]] && CC_FLAGS+=(--max-allocs-per-node "$MAX_ALLOCS")
|
[[ -n "$MAX_ALLOCS" ]] && CC_FLAGS+=(--max-allocs-per-node "$MAX_ALLOCS")
|
||||||
for src in "${SOURCES[@]}"; do
|
for src in "${SOURCES[@]}"; do
|
||||||
rel="$WORK/$(basename "$src" .c).rel"
|
rel=$(object_path "" "$src")
|
||||||
run "$SDCC" "${CC_FLAGS[@]}" -c -o "$rel" "$src"
|
compile_c "$src" "$rel"
|
||||||
USER_RELS+=("$rel")
|
USER_RELS+=("$rel")
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -431,9 +482,8 @@ done
|
|||||||
# остаются в обычном _DATA (W2). Прямые вызовы, без трамплинов.
|
# остаются в обычном _DATA (W2). Прямые вызовы, без трамплинов.
|
||||||
if [[ ${#W3_SPECS[@]} -gt 0 ]]; then
|
if [[ ${#W3_SPECS[@]} -gt 0 ]]; then
|
||||||
for src in "${W3_SPECS[@]}"; do
|
for src in "${W3_SPECS[@]}"; do
|
||||||
rel="$WORK/w3_$(basename "$src" .c).rel"
|
rel=$(object_path "w3_" "$src")
|
||||||
run "$SDCC" "${CC_FLAGS[@]}" --codeseg W3CODE --constseg W3CODE \
|
compile_c "$src" "$rel" --codeseg W3CODE --constseg W3CODE
|
||||||
-c -o "$rel" "$src"
|
|
||||||
W3_RELS+=("$rel")
|
W3_RELS+=("$rel")
|
||||||
done
|
done
|
||||||
W3_LD_FLAGS+=("-Wl-b_W3CODE=0xC000")
|
W3_LD_FLAGS+=("-Wl-b_W3CODE=0xC000")
|
||||||
@@ -469,7 +519,7 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
|
|||||||
for spec in "${BANK_SPECS[@]}"; do
|
for spec in "${BANK_SPECS[@]}"; do
|
||||||
bank_n="${spec%%=*}"
|
bank_n="${spec%%=*}"
|
||||||
bank_src="${spec#*=}"
|
bank_src="${spec#*=}"
|
||||||
rel="$WORK/bank${bank_n}_$(basename "$bank_src" .c).rel"
|
rel=$(object_path "bank${bank_n}_" "$bank_src")
|
||||||
# По умолчанию --dataseg НЕ задаём: писучие данные банкового
|
# По умолчанию --dataseg НЕ задаём: писучие данные банкового
|
||||||
# модуля (глобалы, file-static, статики функций) остаются в
|
# модуля (глобалы, file-static, статики функций) остаются в
|
||||||
# общем _DATA, то есть в W1/W2 — они замаплены всегда и видны
|
# общем _DATA, то есть в W1/W2 — они замаплены всегда и видны
|
||||||
@@ -484,10 +534,9 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
|
|||||||
[[ "$bl" == "$bank_n" ]] && bank_local=1
|
[[ "$bl" == "$bank_n" ]] && bank_local=1
|
||||||
done
|
done
|
||||||
[[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
|
[[ $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}" \
|
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \
|
||||||
"${bank_data_flags[@]}" \
|
"${bank_data_flags[@]}"
|
||||||
-c -o "$rel" "$bank_src"
|
|
||||||
BANK_RELS+=("$rel")
|
BANK_RELS+=("$rel")
|
||||||
# Virtual address: bank_n in upper byte, BANK_LOW16 in low half.
|
# Virtual address: bank_n in upper byte, BANK_LOW16 in low half.
|
||||||
addr=$(printf "0x%X" $(( (bank_n << 16) | BANK_LOW16 )))
|
addr=$(printf "0x%X" $(( (bank_n << 16) | BANK_LOW16 )))
|
||||||
@@ -500,6 +549,7 @@ fi
|
|||||||
IHX="$WORK/$(basename "$OUT" .exe).ihx"
|
IHX="$WORK/$(basename "$OUT" .exe).ihx"
|
||||||
LINK_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size
|
LINK_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size
|
||||||
--code-loc "$CODE_LOC" --data-loc "$DATA_LOC")
|
--code-loc "$CODE_LOC" --data-loc "$DATA_LOC")
|
||||||
|
[[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]] && LINK_FLAGS+=(--debug)
|
||||||
LINK_FLAGS+=("${BANK_LD_FLAGS[@]}")
|
LINK_FLAGS+=("${BANK_LD_FLAGS[@]}")
|
||||||
LINK_FLAGS+=("${W3_LD_FLAGS[@]}")
|
LINK_FLAGS+=("${W3_LD_FLAGS[@]}")
|
||||||
for f in "${LD_EXTRA[@]}"; do LINK_FLAGS+=("$f"); done
|
for f in "${LD_EXTRA[@]}"; do LINK_FLAGS+=("$f"); done
|
||||||
@@ -529,7 +579,7 @@ fi
|
|||||||
# Отчёт по раскладке памяти + проверка лимитов — для ЛЮБОЙ модели памяти
|
# Отчёт по раскладке памяти + проверка лимитов — для ЛЮБОЙ модели памяти
|
||||||
# (сколько свободно в W1/W2, W3 при --w3, и в каждом банке при --bank).
|
# (сколько свободно в W1/W2, W3 при --w3, и в каждом банке при --bank).
|
||||||
if [[ -f "${IHX%.ihx}.map" ]]; then
|
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
|
--mode "$MEMORY_MODE" --heap-top "$HEAP_TOP_VAL" --stack "$STACK_ADDR" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -544,4 +594,9 @@ MK_PREFIX+=("${MKEXE_EXTRA[@]}")
|
|||||||
MK_PREFIX+=(-L "$LOAD_ADDR" -E "$ENTRY_ADDR" -S "$STACK_ADDR" -o "$OUT")
|
MK_PREFIX+=(-L "$LOAD_ADDR" -E "$ENTRY_ADDR" -S "$STACK_ADDR" -o "$OUT")
|
||||||
run "$MKEXE" "${MK_PREFIX[@]}" "$IHX"
|
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"
|
echo "sprinter-cc: wrote $OUT"
|
||||||
|
|||||||
@@ -471,6 +471,21 @@ text_cursor load_cursor/get_cursor(mouse_cursor_t*) set_sensitivity
|
|||||||
get_sensitivity_x/y video_mode_changed`. Sensitivity = делитель
|
get_sensitivity_x/y video_mode_changed`. Sensitivity = делитель
|
||||||
(меньше = быстрее). Solid-C алиасы ms_* включены.
|
(меньше = быстрее). 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> — платформа
|
## <sprinter.h> — платформа
|
||||||
|
|
||||||
Константы портов (PORT_PAGE_W0..W3, PORT_RGADR, PORT_RGMOD), номера
|
Константы портов (PORT_PAGE_W0..W3, PORT_RGADR, PORT_RGMOD), номера
|
||||||
|
|||||||
+20
-1
@@ -239,7 +239,6 @@ end
|
|||||||
Lua-REPL — удобно нащупывать поля/тайминги вживую перед скриптованием.
|
Lua-REPL — удобно нащупывать поля/тайминги вживую перед скриптованием.
|
||||||
- **Второй видеорежим/варианты BIOS** — при необходимости менять
|
- **Второй видеорежим/варианты BIOS** — при необходимости менять
|
||||||
`COMMON_ARGS`.
|
`COMMON_ARGS`.
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Тайминги MCP-моста (run_bridge.sh) — НЕ ждать дольше
|
## 10. Тайминги MCP-моста (run_bridge.sh) — НЕ ждать дольше
|
||||||
|
|
||||||
@@ -258,3 +257,23 @@ end
|
|||||||
Пересобрал HDD-образ (`make hdd`) → MAME **обязан** полный рестарт
|
Пересобрал HDD-образ (`make hdd`) → MAME **обязан** полный рестарт
|
||||||
(`chdman -f` даёт новый inode; см. memory `mame_hdd_rebuild_restart`):
|
(`chdman -f` даёт новый inode; см. memory `mame_hdd_rebuild_restart`):
|
||||||
остановка через `exit` в дебаггере, затем `run_bridge.sh` заново.
|
остановка через `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 можно увеличить.
|
||||||
|
|||||||
@@ -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 не поддержаны, карта сообщает ограничения.
|
||||||
@@ -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 | 0–1, R02 |
|
||||||
|
| Два TU с общим вызываемым inline | Нет коллизий, адреса/строки обоих экземпляров | 0–1, R01/R04 |
|
||||||
|
| Ошибка линковки при старом ihx | Ошибка сохранена, новый пакет не опубликован | 1, R01/R12 |
|
||||||
|
| Два utils.c и перенесённый проект | TU identity и source mapping | 1, R12 |
|
||||||
|
| Цикл/ветка/удалённая строка/много адресов | Все позиции или unverified, без ложного переноса | 1–4, R04 |
|
||||||
|
| Два банка с одинаковыми PC и ret | Верная строка/точка, нет чужого комментария | 0–6, R03/R11 |
|
||||||
|
| huge W3, big W1, поддержанные manual | Адреса/guards из фактической карты | 1–5, R03 |
|
||||||
|
| bank-data, невключённый банк, граница окна | Physical чтение или Unavailable | 1–5, R03 |
|
||||||
|
| Watchpoint через CPU/alias | Адрес, момент и PC-источник корректны | 0–5, R03 |
|
||||||
|
| Entry до банков, ошибка loader | Нет ранней активации/ложного ready | 2, R05 |
|
||||||
|
| DSS/exit/restart/reset/state load | Нет чужих попаданий и stale handles | 2–5, R05/R12 |
|
||||||
|
| Изменённый source или чужой exe | Явное расхождение карты | 1–5, R12 |
|
||||||
|
| Log + user + temporary по одному PC | Лог есть, остановка сохранена | 2–4, R08 |
|
||||||
|
| Два клиента, ручной resume, reconnect | Арбитраж/события, нет повторных mutations | 2–4, R07/R15 |
|
||||||
|
| Одна строка в цикле, HALT/ISR/рекурсия/trampoline | Ограниченный отменяемый шаг | 2–5, R06/R13 |
|
||||||
|
| Signed 8/16, pointer/array, CP866, нет NUL | Знак/тип/кодировка, bounded read | 3–5, R10 |
|
||||||
|
| Горячий лог, переполнение, выход | Overhead, счётчик потерь, flush | 3, R09 |
|
||||||
|
| Debug libc/libbgi fast/safe, DCE | Строки/типы, нет роста кода | 0–1, R17 |
|
||||||
|
| Anchors с #if/include/wrappers/inline | Метаданные совпадают, unsupported отклонён | 6, R02/R04 |
|
||||||
|
| DAP replace/empty/events/references | Протокол и честные capabilities | 4, R13 |
|
||||||
|
| F5 с данными, ошибка ROM/build, restart | Полный цикл и диагностика | 5, R18 |
|
||||||
|
| MAME update с ручными изменениями | Установщик не затирает расхождение | 2, R16 |
|
||||||
|
|
||||||
|
Тесты карты/протокола — небольшие fixtures без MAME; CPU/банки/lifecycle —
|
||||||
|
живые интеграционные сценарии. Golden-карта сверяется независимо с
|
||||||
|
листингом, байтами и фактическими остановками, не только выводом парсера.
|
||||||
|
Tests/hello и tests/banked — стартовые кандидаты, не вся приёмка.
|
||||||
|
|
||||||
|
Замеры: без debugger, debugger без точек, resident/bank breakpoints,
|
||||||
|
горячий лог, instruction trace, transport RTT, end-to-end step.
|
||||||
|
Записывать хост/версию, emulation speed, объём журнала, latency p50/p95.
|
||||||
|
Пороги зафиксировать после baseline этапа 0 до выбора backend;
|
||||||
|
«21 МГц терпимо» не критерий приёмки.
|
||||||
|
|
||||||
|
## 12. Размещение и воспроизводимость
|
||||||
|
|
||||||
|
Исходники моста/MCP — `toolchain/mcp/`, патчи MAME —
|
||||||
|
`toolchain/mame-patches/`; патч SDCC при необходимости отдельно с версией.
|
||||||
|
`toolchain/install-mame-bridge.sh` проверяет upstream revision/хеши,
|
||||||
|
показывает diff при расхождении и не затирает неизвестные ручные изменения.
|
||||||
|
Повторная установка идемпотентна; protocol version проверяется handshake.
|
||||||
|
|
||||||
|
Самостоятельный sdbgbridge загружается прямо из toolchain/mcp через
|
||||||
|
pluginspath: это устраняет необходимость копировать его в vendor и риск
|
||||||
|
потери изменений. Установщик остаётся нужен для патчей существующего
|
||||||
|
MAME/моста, если они потребуются. Игнорируемое mame/ не источник истины. Документировать сборку/применение
|
||||||
|
патчей, проверку установленного бинарника и откат. Не хранить личные пути,
|
||||||
|
ROM и большие образы в исходниках расширения.
|
||||||
|
|
||||||
|
Defaults вместо прежних открытых вопросов: source debug явный/профиль IDE;
|
||||||
|
CDB errors не подавляются; отдельный журнал; общий ограниченный язык;
|
||||||
|
DAP — основной IDE-путь; одна сессия владеет backend; socket по измерениям;
|
||||||
|
debug-библиотеки после эксперимента. Технически открыты: способ R01,
|
||||||
|
наблюдение загрузки/выхода, полная mapping-формула, physical RAM и стоимость
|
||||||
|
hooks. У каждого — репро этапа 0 и условие допуска, а не молчаливое
|
||||||
|
предположение следующих этапов.
|
||||||
|
|
||||||
|
## 13. Внешние спецификации
|
||||||
|
|
||||||
|
Локальные версии исходников и репро первичны для конкретной сборки.
|
||||||
|
Online-документация задаёт общую семантику; при реализации фиксировать
|
||||||
|
использованную версию.
|
||||||
|
|
||||||
|
- [DAP specification](https://microsoft.github.io/debug-adapter-protocol/specification): запросы, события, capabilities и references.
|
||||||
|
- [DAP specification source](https://github.com/microsoft/debug-adapter-protocol/blob/main/specification.md): полный контракт.
|
||||||
|
- [VS Code debugger extension](https://code.visualstudio.com/api/extension-guides/debugger-extension): регистрация/упаковка адаптера.
|
||||||
|
- [MAME general commands](https://docs.mamedev.org/debugger/general.html): printf/tracelog/source/trackpc.
|
||||||
|
- [MAME execution commands](https://docs.mamedev.org/debugger/execution.html): шаги и трассировка.
|
||||||
|
- [MAME Lua debugger classes](https://docs.mamedev.org/luascript/ref-debugger.html): низкоуровневый API.
|
||||||
|
- [GDB Dynamic Printf](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Dynamic-Printf.html): логирование без пересборки.
|
||||||
|
- [GDB Overlays](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Overlays.html): перекрывающиеся размещения и target-интеграция.
|
||||||
@@ -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`.
|
||||||
@@ -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]`; адаптер не выбирает строку молча.
|
||||||
@@ -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
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
errno = -1;
|
errno = -2;
|
||||||
int16_t a0 = 0, a1 = 0;
|
int16_t a0 = 0, a1 = 0;
|
||||||
a0 = get_text_attr();
|
a0 = get_text_attr();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* Общий inline выявляет коллизии CDB между единицами трансляции. */
|
||||||
|
static inline unsigned char transform(unsigned char value)
|
||||||
|
{
|
||||||
|
return value ^ 0x5a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#include "common.h"
|
||||||
|
unsigned char helper(unsigned char value)
|
||||||
|
{
|
||||||
|
return transform(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
static int counter;
|
||||||
|
int left(void) { counter = 1; return counter; }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
static int counter;
|
||||||
|
int right(void) { counter = 2; return counter; }
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+18
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"plugin":{"name":"sdbgbridge","description":"Sprinter: протокол сессии отладки","version":"0.1.0","author":"Sprinter C-Compiler contributors","type":"plugin","start":"false"}}
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Карта оптимизированного кода SDCC и пакет отладки Sprinter."""
|
||||||
@@ -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()},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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']
|
||||||
@@ -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()}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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-позиции.
|
||||||
@@ -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});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user