Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5323b168a7 | |||
| e4695b8281 | |||
| 50c6e56b7b |
@@ -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]
|
||||
command = "/Users/alex/.local/bin/uv"
|
||||
command = "sh"
|
||||
args = [
|
||||
"run",
|
||||
"--python",
|
||||
"3.12",
|
||||
"--no-project",
|
||||
"--with",
|
||||
"mcp<2",
|
||||
"/Volumes/SAM8/Projects/DIY/Z80/Sprinter/C-Compiler/mame/sources/MAME/src/mame_mcp.py",
|
||||
"-c",
|
||||
'root=$(git rev-parse --show-toplevel) || exit; exec sh "$root/toolchain/run-mame-mcp.sh"',
|
||||
]
|
||||
startup_timeout_sec = 60
|
||||
|
||||
[mcp_servers.mame-z80.tools.clear_breakpoint]
|
||||
approval_mode = "approve"
|
||||
@@ -27,3 +23,6 @@ approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.pause]
|
||||
approval_mode = "approve"
|
||||
|
||||
[mcp_servers.mame-z80.tools.status]
|
||||
approval_mode = "approve"
|
||||
|
||||
@@ -78,6 +78,10 @@ toolchain/mkexe/tests/*.actual
|
||||
*.obj
|
||||
*.dSYM/
|
||||
|
||||
# Python host-tools: bytecode всегда воспроизводим и не входит в исходники.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# ===========================================================================
|
||||
# Vendored / downloaded
|
||||
# ===========================================================================
|
||||
@@ -138,3 +142,6 @@ applications/PoP/R1/
|
||||
# здесь его содержимое НЕ дублируется (иначе разъедется). Коротко: в
|
||||
# репозиторий не идут assets/orig/ (чужие данные — их выкачивает
|
||||
# `make fetch`) и assets/packed/LEVELS/ (уровни из оригинала как есть).
|
||||
|
||||
# Локальные пути MCP; общая конфигурация .codex остаётся в Git.
|
||||
/.codex/mame.local.env
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -56,6 +56,16 @@ make size-baseline # принять текущие размеры эталон
|
||||
- Справочник API — docs/libc-reference.md (обновлять при добавлении
|
||||
функций).
|
||||
|
||||
## Документация source debugger
|
||||
|
||||
При изменении `<sdbg.h>`, извлечения/форматирования logMessage,
|
||||
поддержанных типов/регистров, чтения указателей или маршрутов MAME/DAP
|
||||
одновременно обновлять `docs/sdbg-log-macros.md` и проверяемые примеры;
|
||||
ссылки и краткий статус синхронизировать с `docs/mame-source-debug.md`,
|
||||
`docs/vscode-sprinter-debug.md`, `docs/mame-source-debug-status.md` и
|
||||
`docs/libc-reference.md`. Финальные задачи по hex и разыменованию
|
||||
указателей пока только в плане, не считать их рабочим API.
|
||||
|
||||
## ABI и платформа (кратко; детали в memory/)
|
||||
|
||||
- SDCC `__sdcccall(1)`: arg1 → HL (8-бит → A), arg2 → DE, остальные
|
||||
|
||||
@@ -43,7 +43,7 @@ DATA_FILES := \
|
||||
examples/mdview/SAMPLE.MD
|
||||
|
||||
.PHONY: all tools lib tests examples check clean sdcc floppy \
|
||||
size-check size-baseline host-tests $(TESTS) $(APPS)
|
||||
size-check size-baseline host-tests sdbg-tests mame-sdbg-patch mame-sdbg $(TESTS) $(APPS)
|
||||
|
||||
all: tools lib tests
|
||||
|
||||
@@ -87,6 +87,21 @@ HOST_TEST_DIRS := testkit applications/SprPoP/tests/host
|
||||
host-tests:
|
||||
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
|
||||
|
||||
# Отладочная карта: реальные SDCC/linker и протокол транспорта без MAME.
|
||||
# Для local pyenv: pyenv exec make sdbg-tests.
|
||||
sdbg-tests: tools lib
|
||||
python3 -m unittest discover -s tests/sdbg -v
|
||||
|
||||
# Backend удерживает MAME в stopped-loop без Cocoa debugger; Lua bridge при
|
||||
# этом обслуживается из periodic_check ядра. Повторный вызов безопасен.
|
||||
mame-sdbg-patch:
|
||||
sh toolchain/apply-mame-sdbg-patch.sh
|
||||
|
||||
# Инкрементально собирает patched checkout и устанавливает бинарник в v306.
|
||||
mame-sdbg: mame-sdbg-patch
|
||||
$(MAKE) -C mame/sources/MAME
|
||||
cp mame/sources/MAME/mame $(MAME_DIR)/mame.arm
|
||||
|
||||
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
|
||||
size-check:
|
||||
python3 toolchain/size_check.py
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# # STACK_SIZE := 2048 # bytes reserved for the stack
|
||||
# # EXTRA_SRCS := helper.c util.c # additional .c files in this dir
|
||||
# # EXTRA_FLAGS := --crt0=minimal # passed through to sprinter-cc
|
||||
# # SRC_DEBUG := 1 # карта C/asm и проверенный debug-пакет
|
||||
# # SRC_DEBUG_FILES := helper.c # либо карта только выбранных TU
|
||||
# # EXTRA_DATA := test.txt # extra files to add to `make floppy`
|
||||
# # HDD_DEST_DIR := games/myapp # общий каталог файлов в `make hdd`
|
||||
#
|
||||
@@ -31,6 +33,7 @@
|
||||
# (in the repo root) still packs all examples.
|
||||
# run floppy + launch MAME
|
||||
|
||||
PYTHON ?= python3
|
||||
SPRINTER_CC := $(PROJ_ROOT)/bin/sprinter-cc
|
||||
MKEXE := $(PROJ_ROOT)/toolchain/mkexe/mkexe
|
||||
LIB := $(PROJ_ROOT)/lib/sprinter.lib
|
||||
@@ -77,6 +80,12 @@ ifneq ($(STACK_SIZE),)
|
||||
CC_FLAGS += --stack-size $(STACK_SIZE)
|
||||
endif
|
||||
CC_FLAGS += $(EXTRA_FLAGS)
|
||||
ifeq ($(SRC_DEBUG),1)
|
||||
CC_FLAGS += --src-debug
|
||||
endif
|
||||
ifneq ($(strip $(SRC_DEBUG_FILES)),)
|
||||
CC_FLAGS += $(foreach src,$(SRC_DEBUG_FILES),--src-debug-file $(src))
|
||||
endif
|
||||
|
||||
all: $(EXE)
|
||||
|
||||
@@ -99,12 +108,24 @@ BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
|
||||
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
|
||||
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.py
|
||||
|
||||
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
|
||||
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
|
||||
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
|
||||
@d=$(dir $@).sprinter-cc-$(EXAMPLE); \
|
||||
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
|
||||
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
|
||||
# Команда/зависимости карты участвуют в пересборке, включая смену режима.
|
||||
SDBG_CONFIG := $(dir $(EXE)).resource-stamps/$(EXAMPLE)-config.json
|
||||
SDBG_MANIFEST := $(dir $(EXE)).sprinter-cc-$(EXAMPLE)/manifest.json
|
||||
.PHONY: sdbg-config-force
|
||||
SDBG_CONFIG_ARGS = $(SDBG_CONFIG) $(SPRINTER_CC) $(CC_FLAGS) $(SOURCES) $(SDBG_MANIFEST)
|
||||
|
||||
# Проверка содержимого обязательна: mtime make может иметь точность в секунду.
|
||||
# Сохранение fingerprint только после успешной сборки позволяет повторить сбой.
|
||||
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS) sdbg-config-force $(SPRINTER_CC) $(wildcard $(PROJ_ROOT)/toolchain/sdbg/*.py) $(wildcard $(PROJ_ROOT)/toolchain/sdbg_*.py)
|
||||
@state=$$($(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py check $(SDBG_CONFIG_ARGS)) || exit $$?; \
|
||||
if [ "$$state" = changed ] || [ -n "$(filter-out sdbg-config-force,$?)" ]; then \
|
||||
mkdir -p $(dir $@); \
|
||||
SPRINTER_PYTHON="$(PYTHON)" $(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES) || exit $$?; \
|
||||
d=$(dir $@).sprinter-cc-$(EXAMPLE); \
|
||||
if ls $$d/bank*_*.asm >/dev/null 2>&1; then $(PYTHON) $(BANK_CHECK) $$d || exit $$?; fi; \
|
||||
if ls $$d/*.map >/dev/null 2>&1; then $(PYTHON) $(W0ISR_CHECK) $$d || exit $$?; fi; \
|
||||
$(PYTHON) $(PROJ_ROOT)/toolchain/sdbg_config.py save $(SDBG_CONFIG_ARGS); \
|
||||
fi
|
||||
|
||||
$(MKEXE):
|
||||
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
|
||||
|
||||
@@ -119,11 +119,11 @@ SPRCMD.EXE 43080
|
||||
```sh
|
||||
make hdd-p19-overwrite ALLOCS=3000
|
||||
tests/run_mame_hdd.sh build/hdd/p19_overwrite.chd \
|
||||
tests/mame_p19_overwrite.lua artifacts/mame-p19/core-local 26
|
||||
tests/mame_p19_overwrite.lua artifacts/mame-p19/core-local 180
|
||||
tests/check_p19_overwrite_hdd.sh build/hdd/p19_overwrite.chd
|
||||
|
||||
make hdd-p19-policy ALLOCS=3000
|
||||
tests/run_mame_hdd.sh build/hdd/p19_policy.chd \
|
||||
tests/mame_p19_policy.lua artifacts/mame-p19/policy-local 106
|
||||
tests/mame_p19_policy.lua artifacts/mame-p19/policy-local 200
|
||||
tests/check_p19_policy_hdd.sh build/hdd/p19_policy.chd
|
||||
```
|
||||
|
||||
@@ -196,7 +196,7 @@ tests/run_mame_hdd.sh build/hdd/p5_panel_edges.chd \
|
||||
|
||||
make hdd-p5-copy-faults ALLOCS=3000
|
||||
tests/run_mame_hdd.sh build/hdd/p5_copy_faults.chd \
|
||||
tests/mame_p5_copy_faults.lua artifacts/mame-p5/faults-local 35
|
||||
tests/mame_p5_copy_faults.lua artifacts/mame-p5/faults-local 180
|
||||
tests/check_p5_copy_faults_hdd.sh build/hdd/p5_copy_faults.chd
|
||||
|
||||
make hdd-p5-viewer ALLOCS=3000
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
-- P19: запускает target-пробу overwrite и фиксирует итоговую таблицу.
|
||||
-- Запас на I/O MAME 0.287: ранний выход обрывал пробу до ALL PASS.
|
||||
|
||||
local KB = ":kbd:ms_naturl:"
|
||||
local HOLD = 0.06
|
||||
@@ -40,10 +41,10 @@ end
|
||||
|
||||
type_text(8.0, "d:\n")
|
||||
type_text(10.0, "DELRT.EXE\n")
|
||||
key(18.0, "P2.1", 0x10)
|
||||
key(90.0, "P2.1", 0x10)
|
||||
|
||||
table.sort(events, function(a, b) return a[1] < b[1] end)
|
||||
local snap_times = {15.0, 20.0}
|
||||
local snap_times = {80.0, 95.0}
|
||||
local snaps_done = {}
|
||||
local start_time = nil
|
||||
local ports = nil
|
||||
@@ -83,5 +84,5 @@ emu.register_periodic(function()
|
||||
manager.machine.video:snapshot()
|
||||
end
|
||||
end
|
||||
if elapsed >= 22.0 then manager.machine:exit() end
|
||||
if elapsed >= 100.0 then manager.machine:exit() end
|
||||
end)
|
||||
|
||||
@@ -85,8 +85,16 @@ key(90.0, "P1.6", 0x20) -- inspect target.
|
||||
key(95.0, "P2.3", 0x80) -- F10.
|
||||
key(97.0, "P2.1", 0x10)
|
||||
|
||||
-- Запас на I/O MAME 0.287: ответ readonly должен идти после двух копий.
|
||||
for _, event in ipairs(events) do
|
||||
if event[1] >= 40.0 then event[1] = event[1] + 20.0 end
|
||||
end
|
||||
|
||||
table.sort(events, function(left, right) return left[1] < right[1] end)
|
||||
local snap_times = {31.5, 38.0, 44.0, 54.0, 58.0, 70.0, 76.0, 85.0, 89.0, 92.0, 99.0}
|
||||
for i, t in ipairs(snap_times) do
|
||||
if t >= 40.0 then snap_times[i] = t + 20.0 end
|
||||
end
|
||||
local snaps_done = {}
|
||||
local start_time = nil
|
||||
local ports = nil
|
||||
@@ -127,5 +135,5 @@ emu.register_periodic(function()
|
||||
print("[p19-policy] snapshot t=" .. elapsed)
|
||||
end
|
||||
end
|
||||
if elapsed >= 101.0 then manager.machine:exit() end
|
||||
if elapsed >= 121.0 then manager.machine:exit() end
|
||||
end)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- Запас на завершение target-пробы в MAME 0.287.
|
||||
-- P5: отдельная детерминированная матрица состояний copy-job на HDD D:.
|
||||
|
||||
local KB = ":kbd:ms_naturl:"
|
||||
@@ -49,11 +50,11 @@ end
|
||||
|
||||
type_text(8.0, "d:\n")
|
||||
type_text(9.0, "CPERR.EXE\n")
|
||||
key(16.0, "P2.1", 0x10) -- Enter после итоговой таблицы.
|
||||
key(90.0, "P2.1", 0x10) -- Enter после итоговой таблицы.
|
||||
|
||||
table.sort(events, function(a, b) return a[1] < b[1] end)
|
||||
|
||||
local snap_times = {14.0, 18.0}
|
||||
local snap_times = {80.0, 95.0}
|
||||
local snaps_done = {}
|
||||
local start_time = nil
|
||||
local ports = nil
|
||||
@@ -100,7 +101,7 @@ emu.register_periodic(function()
|
||||
end
|
||||
end
|
||||
|
||||
if elapsed >= 20.0 then
|
||||
if elapsed >= 100.0 then
|
||||
print("[p5-copy-faults] exit t=" .. elapsed)
|
||||
manager.machine:exit()
|
||||
end
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
-- На медленном DSS можно растянуть расписание, сохранив всю матрицу.
|
||||
local time_scale = tonumber(os.getenv("SPRCMD_TEST_TIME_SCALE") or "1")
|
||||
assert(time_scale and time_scale >= 1, "SPRCMD_TEST_TIME_SCALE должен быть >= 1")
|
||||
if os.getenv("SPRCMD_TEST_UNTHROTTLED") == "1" then
|
||||
manager.machine.video.throttled = false
|
||||
end
|
||||
|
||||
-- P5 на HDD D: 20 copy, 100 refresh и три полных cleanup-цикла.
|
||||
|
||||
local KB = ":kbd:ms_naturl:"
|
||||
@@ -136,7 +143,7 @@ end)
|
||||
emu.register_periodic(function()
|
||||
if not start_time then return end
|
||||
if not ports then ports = manager.machine.ioport.ports end
|
||||
local elapsed = now() - start_time
|
||||
local elapsed = (now() - start_time) / time_scale
|
||||
|
||||
while index <= #events and elapsed >= events[index][1] do
|
||||
local event = events[index]
|
||||
|
||||
@@ -22,7 +22,20 @@ if pgrep -fl 'mame[^ ]* sprinter( |$)' | grep . >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
cd "$MAME_DIR"
|
||||
exec timeout "$TIMEOUT" ./mame.arm sprinter \
|
||||
# В macOS GNU timeout может отсутствовать; Python обеспечивает тот же лимит.
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set -- timeout "$TIMEOUT"
|
||||
else
|
||||
set -- python3 -c '
|
||||
import subprocess, sys
|
||||
try:
|
||||
sys.exit(subprocess.run(sys.argv[2:], timeout=float(sys.argv[1])).returncode)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("run_mame_hdd.sh: истёк лимит времени", file=sys.stderr)
|
||||
sys.exit(124)
|
||||
' "$TIMEOUT"
|
||||
fi
|
||||
exec "$@" ./mame.arm sprinter \
|
||||
-skip_gameinfo -video opengl -window -nofilter \
|
||||
-beta:wd179x:0 35hd -beta:wd179x:1 35hd \
|
||||
-flop1 ./IMG/mc.img -flop2 ./IMG/dss171u.img \
|
||||
|
||||
+65
-10
@@ -65,6 +65,8 @@
|
||||
# --max-allocs N SDCC --max-allocs-per-node (default: 100000 — smaller/
|
||||
# faster code at the cost of compile time; pass a lower
|
||||
# value, e.g. SDCC's own default 3000, to compile faster).
|
||||
# --src-debug карта C/asm для всех пользовательских модулей
|
||||
# --src-debug-file FILE карта выбранного TU (повторяемый, вместо --src-debug)
|
||||
# --debug enable runtime diagnostics — defines DEBUG_RT for both
|
||||
# sdcc (-DDEBUG_RT) and the crt0 assembly (prepended
|
||||
# `DEBUG_RT = 1`). Exposes runtime introspection symbols
|
||||
@@ -81,6 +83,18 @@
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# Можно выбрать интерпретатор без личного пути в репозитории.
|
||||
SPRINTER_PYTHON="${SPRINTER_PYTHON:-python3}"
|
||||
|
||||
# Отладочная сборка публикуется транзакционно отдельным драйвером.
|
||||
if [[ "${SPRINTER_SDBG_ACTIVE:-}" != 1 ]]; then
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == --src-debug || "$arg" == --src-debug-file ]]; then
|
||||
exec "$SPRINTER_PYTHON" "$(dirname "$0")/../toolchain/sdbg_driver.py" "$0" "$@"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ------- Locate the toolchain ------------------------------------------------
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJ_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
@@ -109,6 +123,8 @@ LOAD_ADDR=""
|
||||
ENTRY_ADDR=""
|
||||
STACK_ADDR="0xBFFE"
|
||||
STACK_SIZE="" # if set, used to compute HEAP_TOP = STACK_ADDR + 1 - STACK_SIZE
|
||||
SRC_DEBUG=0
|
||||
SRC_DEBUG_FILES=()
|
||||
DEBUG_RT=0 # if 1, prepend `DEBUG_RT = 1` to crt0 + pass -DDEBUG_RT to sdcc
|
||||
VERBOSE=0
|
||||
USER_INCS=()
|
||||
@@ -135,6 +151,7 @@ SAFE_LIBS=0 # if 1, prefer *_safe.lib variants (fallback to standard)
|
||||
# ------- Parse args ----------------------------------------------------------
|
||||
usage() {
|
||||
sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
|
||||
echo ' --src-debug / --src-debug-file FILE: карта исходников C и asm'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
@@ -166,6 +183,10 @@ while [[ $# -gt 0 ]]; do
|
||||
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
|
||||
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
|
||||
--gfx) GFX_MODE="$2"; shift 2;;
|
||||
--src-debug) SRC_DEBUG=1; shift;;
|
||||
--src-debug-file)
|
||||
[[ $# -ge 2 ]] || { echo "--src-debug-file требует FILE" >&2; exit 1; }
|
||||
SRC_DEBUG_FILES+=("$2"); shift 2;;
|
||||
--debug) DEBUG_RT=1; shift;;
|
||||
--safe) SAFE_LIBS=1; shift;;
|
||||
-v) VERBOSE=1; shift;;
|
||||
@@ -415,14 +436,44 @@ else
|
||||
fi
|
||||
run "$SDASZ80" -o "$HEAP_TOP_REL" "$HEAP_TOP_SRC"
|
||||
|
||||
# Единый путь компиляции выбранных C-модулей с картой исходников.
|
||||
compile_c() {
|
||||
local src="$1" rel="$2" selected=$SRC_DEBUG candidate
|
||||
shift 2
|
||||
for candidate in "${SRC_DEBUG_FILES[@]}"; do
|
||||
[[ "$src" -ef "$candidate" ]] && selected=1
|
||||
done
|
||||
if [[ $selected -eq 1 ]]; then
|
||||
run "$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_build.py" compile \
|
||||
--sdcc "$SDCC" --assembler "$SDASZ80" --source "$src" --output "$rel" \
|
||||
-- "${CC_FLAGS[@]}" "$@"
|
||||
elif [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then
|
||||
run "$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_build.py" compile --plain \
|
||||
--sdcc "$SDCC" --assembler "$SDASZ80" --source "$src" --output "$rel" \
|
||||
-- "${CC_FLAGS[@]}" "$@"
|
||||
else
|
||||
run "$SDCC" "${CC_FLAGS[@]}" "$@" -c -o "$rel" "$src"
|
||||
fi
|
||||
}
|
||||
|
||||
# Уникальные имена объектов в debug-пакете допускают одинаковые basename.
|
||||
object_path() {
|
||||
local prefix="$1" src="$2"
|
||||
if [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then
|
||||
"$SPRINTER_PYTHON" -c 'import hashlib, pathlib, sys; p=pathlib.Path(sys.argv[3]); print(sys.argv[1]+"/"+sys.argv[2]+p.stem+"_"+hashlib.sha256(str(p.resolve()).encode()).hexdigest()[:12]+".rel")' "$WORK" "$prefix" "$src"
|
||||
else
|
||||
echo "$WORK/$prefix$(basename "$src" .c).rel"
|
||||
fi
|
||||
}
|
||||
|
||||
# 2. user sources → .rel (HOME)
|
||||
USER_RELS=()
|
||||
CC_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size -I "$INC_DIR" -I "$BGI_INC_DIR" "${USER_INCS[@]}" "${USER_DEFS[@]}")
|
||||
[[ $DEBUG_RT -eq 1 ]] && CC_FLAGS+=(-DDEBUG_RT)
|
||||
[[ -n "$MAX_ALLOCS" ]] && CC_FLAGS+=(--max-allocs-per-node "$MAX_ALLOCS")
|
||||
for src in "${SOURCES[@]}"; do
|
||||
rel="$WORK/$(basename "$src" .c).rel"
|
||||
run "$SDCC" "${CC_FLAGS[@]}" -c -o "$rel" "$src"
|
||||
rel=$(object_path "" "$src")
|
||||
compile_c "$src" "$rel"
|
||||
USER_RELS+=("$rel")
|
||||
done
|
||||
|
||||
@@ -431,9 +482,8 @@ done
|
||||
# остаются в обычном _DATA (W2). Прямые вызовы, без трамплинов.
|
||||
if [[ ${#W3_SPECS[@]} -gt 0 ]]; then
|
||||
for src in "${W3_SPECS[@]}"; do
|
||||
rel="$WORK/w3_$(basename "$src" .c).rel"
|
||||
run "$SDCC" "${CC_FLAGS[@]}" --codeseg W3CODE --constseg W3CODE \
|
||||
-c -o "$rel" "$src"
|
||||
rel=$(object_path "w3_" "$src")
|
||||
compile_c "$src" "$rel" --codeseg W3CODE --constseg W3CODE
|
||||
W3_RELS+=("$rel")
|
||||
done
|
||||
W3_LD_FLAGS+=("-Wl-b_W3CODE=0xC000")
|
||||
@@ -469,7 +519,7 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
|
||||
for spec in "${BANK_SPECS[@]}"; do
|
||||
bank_n="${spec%%=*}"
|
||||
bank_src="${spec#*=}"
|
||||
rel="$WORK/bank${bank_n}_$(basename "$bank_src" .c).rel"
|
||||
rel=$(object_path "bank${bank_n}_" "$bank_src")
|
||||
# По умолчанию --dataseg НЕ задаём: писучие данные банкового
|
||||
# модуля (глобалы, file-static, статики функций) остаются в
|
||||
# общем _DATA, то есть в W1/W2 — они замаплены всегда и видны
|
||||
@@ -484,10 +534,9 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
|
||||
[[ "$bl" == "$bank_n" ]] && bank_local=1
|
||||
done
|
||||
[[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
|
||||
run "$SDCC" "${CC_FLAGS[@]}" \
|
||||
compile_c "$bank_src" "$rel" \
|
||||
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \
|
||||
"${bank_data_flags[@]}" \
|
||||
-c -o "$rel" "$bank_src"
|
||||
"${bank_data_flags[@]}"
|
||||
BANK_RELS+=("$rel")
|
||||
# Virtual address: bank_n in upper byte, BANK_LOW16 in low half.
|
||||
addr=$(printf "0x%X" $(( (bank_n << 16) | BANK_LOW16 )))
|
||||
@@ -500,6 +549,7 @@ fi
|
||||
IHX="$WORK/$(basename "$OUT" .exe).ihx"
|
||||
LINK_FLAGS=(-mz80 --no-std-crt0 --std-c99 --opt-code-size
|
||||
--code-loc "$CODE_LOC" --data-loc "$DATA_LOC")
|
||||
[[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]] && LINK_FLAGS+=(--debug)
|
||||
LINK_FLAGS+=("${BANK_LD_FLAGS[@]}")
|
||||
LINK_FLAGS+=("${W3_LD_FLAGS[@]}")
|
||||
for f in "${LD_EXTRA[@]}"; do LINK_FLAGS+=("$f"); done
|
||||
@@ -529,7 +579,7 @@ fi
|
||||
# Отчёт по раскладке памяти + проверка лимитов — для ЛЮБОЙ модели памяти
|
||||
# (сколько свободно в W1/W2, W3 при --w3, и в каждом банке при --bank).
|
||||
if [[ -f "${IHX%.ihx}.map" ]]; then
|
||||
python3 "$CHECK_BANKS" "${IHX%.ihx}.map" \
|
||||
"$SPRINTER_PYTHON" "$CHECK_BANKS" "${IHX%.ihx}.map" \
|
||||
--mode "$MEMORY_MODE" --heap-top "$HEAP_TOP_VAL" --stack "$STACK_ADDR" || true
|
||||
fi
|
||||
|
||||
@@ -544,4 +594,9 @@ MK_PREFIX+=("${MKEXE_EXTRA[@]}")
|
||||
MK_PREFIX+=(-L "$LOAD_ADDR" -E "$ENTRY_ADDR" -S "$STACK_ADDR" -o "$OUT")
|
||||
run "$MKEXE" "${MK_PREFIX[@]}" "$IHX"
|
||||
|
||||
if [[ "${SPRINTER_SDBG_ACTIVE:-}" == 1 ]]; then
|
||||
"$SPRINTER_PYTHON" "$PROJ_ROOT/toolchain/sdbg_driver.py" --metadata "$WORK" \
|
||||
"$OUT" "$MEMORY_MODE" "$CODE_LOC" "$DATA_LOC" "$LOAD_ADDR" "$ENTRY_ADDR" "$STACK_ADDR"
|
||||
fi
|
||||
|
||||
echo "sprinter-cc: wrote $OUT"
|
||||
|
||||
@@ -471,6 +471,21 @@ text_cursor load_cursor/get_cursor(mouse_cursor_t*) set_sensitivity
|
||||
get_sensitivity_x/y video_mode_changed`. Sensitivity = делитель
|
||||
(меньше = быстрее). Solid-C алиасы ms_* включены.
|
||||
|
||||
## <sdbg.h> — логи source debugger без кода вывода Sprinter
|
||||
|
||||
`SDBG_LOG(tag, "text={global}")` и
|
||||
`SDBG_LOGIF(tag, global_flag, "text={global}")` создают авторские
|
||||
точки журналирования в выбранном `--src-debug` TU. `tag` — уникальный
|
||||
C-идентификатор внутри TU; условие `LOGIF` пока только имя поддержанной
|
||||
global/static переменной, ненулевое значение означает вывод. Подстановки
|
||||
`{name}` читаются отладчиком при попадании; локальные и C-выражения пока
|
||||
не поддержаны. Обычная сборка превращает макросы в пустое выражение.
|
||||
Полный контракт и планы форматирования —
|
||||
[руководство по SDBG_LOG](sdbg-log-macros.md).
|
||||
Аргументы не вычисляются кодом приложения, поэтому побочные эффекты в них
|
||||
недопустимы. Сообщение видно в MAME debugger console и VS Code Debug Console
|
||||
при подключённой source-debug сессии; без неё макрос сам ничего не печатает.
|
||||
|
||||
## <sprinter.h> — платформа
|
||||
|
||||
Константы портов (PORT_PAGE_W0..W3, PORT_RGADR, PORT_RGMOD), номера
|
||||
|
||||
+20
-1
@@ -239,7 +239,6 @@ end
|
||||
Lua-REPL — удобно нащупывать поля/тайминги вживую перед скриптованием.
|
||||
- **Второй видеорежим/варианты BIOS** — при необходимости менять
|
||||
`COMMON_ARGS`.
|
||||
```
|
||||
|
||||
## 10. Тайминги MCP-моста (run_bridge.sh) — НЕ ждать дольше
|
||||
|
||||
@@ -258,3 +257,23 @@ end
|
||||
Пересобрал HDD-образ (`make hdd`) → MAME **обязан** полный рестарт
|
||||
(`chdman -f` даёт новый inode; см. memory `mame_hdd_rebuild_restart`):
|
||||
остановка через `exit` в дебаггере, затем `run_bridge.sh` заново.
|
||||
|
||||
## 11. Карта C/asm для отладки
|
||||
|
||||
`SRC_DEBUG=1` в app.mk создаёт проверенный пакет исходников и адресов без
|
||||
изменения EXE в проверенных конфигурациях. Пер-модульный вариант:
|
||||
`SRC_DEBUG_FILES=helper.c`. Команды карты и результаты живых экспериментов:
|
||||
[mame-source-debug-status.md](mame-source-debug-status.md); полный план:
|
||||
[mame-source-debug.md](mame-source-debug.md). Базовый C-attach/where/точки и
|
||||
чтение простых переменных доступны через `toolchain/sdbg_session.py`.
|
||||
`sdbg_server.py`, DAP MVP, VS Code launch и development-расширение уже есть;
|
||||
безопасный restart и MCP C-уровня пока не готовы. `-debugger none`
|
||||
автоматически продолжает CPU и не подходит для ожидания команд пошаговой
|
||||
сессии.
|
||||
|
||||
Source-debug launcher ждёт до 10-й секунды эмулируемого времени, сохраняет
|
||||
снимок готового DSS, только затем вооружает entry breakpoint и вводит команду
|
||||
EXE. Пользовательские точки создаются после проверенного `main`, когда
|
||||
`_bank_pages` уже заполнена. В обычной конфигурации это даёт 0 попаданий на
|
||||
адрес entry во время загрузки DSS. Автоматического распознавания приглашения
|
||||
`C:\\>` пока нет; `launchAt` в VS Code можно увеличить.
|
||||
|
||||
@@ -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,251 @@
|
||||
# Разделение Sprinter-CC, MAME и приложений
|
||||
|
||||
Статус: план, без переноса файлов. Дата: 2026-09-15.
|
||||
|
||||
## Цель и границы
|
||||
|
||||
Тулкит, эмулятор, примеры и реальные приложения должны разрабатываться и
|
||||
версионироваться независимо. Приложение собирается из собственного клона,
|
||||
если ему указан только путь к установленному Sprinter-CC. Для запуска в
|
||||
эмуляторе дополнительно указывается путь к подготовленной среде MAME либо
|
||||
необходимые отдельные пути. Ни соседний репозиторий Examples, ни исходники
|
||||
MAME приложению не нужны.
|
||||
|
||||
Предлагаемая раскладка на машине разработчика:
|
||||
|
||||
```text
|
||||
~/Projects/DIY/Z80/Sprinter/
|
||||
├── C-Compiler/ # Sprinter-CC
|
||||
├── MAME/ # fork MAME, сборки и локальная среда запуска
|
||||
├── Examples/ # один Git-репозиторий примеров
|
||||
├── Applications/ # каталог без собственного Git
|
||||
│ ├── SprPoP/ # самостоятельный Git-репозиторий
|
||||
│ ├── Volkov/ # самостоятельный Git-репозиторий
|
||||
│ └── PoP-Archive/ # архивный проект, если история нужна отдельно
|
||||
└── VSCode-Sprinter/ # Git-репозиторий расширения VS Code
|
||||
```
|
||||
|
||||
Имена `C-Compiler`, `Volkov` и `PoP-Archive` здесь рабочие; адреса Git remote
|
||||
новых репозиториев и окончательные имена фиксируются до миграции. Папка
|
||||
`Applications/` не становится общим репозиторием: каждый продукт имеет свою
|
||||
историю, релизы, игнорируемые ресурсы и тесты. Физическое расположение рядом
|
||||
удобно, но не входит в контракт сборки.
|
||||
|
||||
## Что принадлежит каждому проекту
|
||||
|
||||
| Проект | Что остаётся/переходит | Причина |
|
||||
|---|---|---|
|
||||
| Sprinter-CC | `bin/`, `runtime/`, `libc/`, `libbgi/`, `lib/`, общие `toolchain/`, `tests/`, `testkit/`, `app.mk`, справочник API, платформенные исследования, `release_docs/`, рецепт SDCC в `third_party/` | Это target SDK, его ABI, инструменты и собственные регрессионные тесты. libc/libbgi пока остаются вместе: их версия и ABI тесно связаны с `sprinter-cc`; дальнейшее выделение возможно после стабильного интерфейса. |
|
||||
| MAME | Нынешний `mame/sources/MAME` со своим Git, изменения драйвера/OSD, патч debugger backend, общие `mamebridge` и `mame_mcp.py`, рецепты stock/sdbg сборок | Изменения ядра и общий транспорт MCP должны проверяться и выпускаться вместе с конкретной ревизией MAME. |
|
||||
| Examples | Нынешние `examples/balls`, `mdview`, `mdview2`, `rpgwalk`, `scroll`, `space` и относящиеся к ним ресурсы/документы | Это демонстрации SDK с общей версией. Один репозиторий избегает множества мелких релизов. |
|
||||
| SprPoP | Всё `applications/SprPoP/`: исходники, конверторы, тесты, собственные ресурсы и планы | Уже почти автономное приложение; оригинальные ресурсы и дальше остаются внешними. |
|
||||
| Volkov | Всё `applications/Volkov/`: исходники, сценарии MAME, собственные тестовые носители и документы | Продукт и его проверки должны развиваться без дерева тулкита. |
|
||||
| PoP-Archive | `applications/PoP/` как архив прежних PoC/roomtest и исследований | Не смешивать прежнюю историю с активным SprPoP. Если архив не нужен как рабочий клон, сохранить Git-историю и документы в отдельном архивном репозитории. |
|
||||
| VSCode-Sprinter | Нынешний `toolchain/vscode-sprinter-debug`: extension, задачи сборки, конфигурации и тесты клиентской части | У расширения свои версии, упаковка VSIX и цикл обновления. Отладочный Python backend остаётся в SDK. |
|
||||
|
||||
`applications/DN/DosNavigator` и вложенные чужие клоны в PoP/Volkov —
|
||||
референсы, не Sprinter-приложения. Их не превращать в продуктовые репозитории
|
||||
и не коммитить вместе с портами. Локально их можно хранить в `References/`
|
||||
или получать по воспроизводимой инструкции с указанием upstream и revision.
|
||||
`docs/sources/`, `docs/extra/` и скачанный SDCC также не являются новым
|
||||
проектом: это игнорируемые справочные/загруженные материалы. Отдельно
|
||||
проверить, какие tracked файлы `third_party/16x16-RPG-characters` нужны
|
||||
`rpgwalk` и старому PoP; каждый потребитель получает собственный ресурс или
|
||||
явный рецепт его загрузки. Лицензии и происхождение сохранить.
|
||||
|
||||
## Контракт сборки и запуска приложения
|
||||
|
||||
`SPRINTER_ROOT` указывает на готовый Sprinter-CC. Это единственный внешний
|
||||
путь, обязательный для сборки `.exe`, host-тестов и собственных генераторов
|
||||
ресурсов. В `app.mk` убрать вывод MAME из `PROJ_ROOT`; оставить
|
||||
совместимый alias `PROJ_ROOT := $(SPRINTER_ROOT)` там, где это необходимо на
|
||||
переходный период. Приложения проверяют, что по пути существуют
|
||||
`bin/sprinter-cc`, `app.mk` и нужные библиотеки, и сообщают ошибку до начала
|
||||
сборки. При отсутствии `SPRINTER_ROOT` не угадывать прежнее `../..`: после
|
||||
переноса такой путь может случайно указывать на чужой каталог.
|
||||
|
||||
`MAME_HOME` — необязательный для сборки путь к подготовленной **среде запуска**
|
||||
MAME, а не к его исходникам. Стандартные пути соответствуют нынешнему
|
||||
`mame/v306` и допускают переопределение из окружения, аргументов `make` или
|
||||
локального игнорируемого файла настроек. Если `MAME_HOME` не задан,
|
||||
необходимые пути можно указать отдельно; отсутствие обоих источников
|
||||
диагностировать в цели запуска, не превращая пустое значение в `/mame.arm`:
|
||||
|
||||
```make
|
||||
MAME_BIN ?= $(MAME_HOME)/mame.arm
|
||||
MAME_ROMPATH ?= $(MAME_HOME)/roms
|
||||
MAME_DSS_IMAGE ?= $(MAME_HOME)/IMG/dss171u.img
|
||||
MAME_SYSTEM_HDD_IMAGE ?= $(MAME_HOME)/IMG/sp_hdd_sys.chd
|
||||
MAME_BIOS ?= v3.06
|
||||
```
|
||||
|
||||
`MAME_BIOS` — имя варианта для аргумента `-bios`; ROM-файлы MAME ищет в
|
||||
`MAME_ROMPATH`. `MAME_SYSTEM_HDD_IMAGE` добавлен потому, что системный CHD
|
||||
нужен существующим сценариям. Если используется CD, NeoGS или второй
|
||||
системный носитель, пути к ним сделать отдельными необязательными настройками
|
||||
профиля запуска; не прибивать их к Makefile каждого приложения. Уточнить по
|
||||
фактическим сценариям, когда нужен `MAME_DSS_IMAGE`: обычный `run`, тест и
|
||||
DAP-launch должны брать один источник настройки, даже если конкретный профиль
|
||||
не монтирует DSS-дискету.
|
||||
|
||||
`MAME_HOME/mame.arm` — стандартный установленный бинарник, а stock/sdbg
|
||||
сборки находятся отдельно и выбираются явным `MAME_BIN`. Сборка одного
|
||||
варианта не перезаписывает другой и не меняет молча активный бинарник.
|
||||
Для запуска с нетипичной установкой можно переопределить каждый путь, не
|
||||
копируя ROM/CHD в каталог приложения. Все цели запуска валидируют свои
|
||||
фактически используемые файлы и выводят проверенный путь в диагностике.
|
||||
|
||||
Собственный `.img`/`.chd`, результаты сценариев, снимки, Lua-скрипты и
|
||||
журнал приложения лежат в его `build/` (или другом локальном выходном
|
||||
каталоге). MAME получает готовый образ аргументом `-flop1`/`-hard2`. Общие
|
||||
`IMG/mc.img` и `IMG/test_hdd.chd` больше не являются выходами сборки
|
||||
приложения. `make hdd` готовит локальный образ; `make run` запускает его
|
||||
без `mame-link`. Для нескольких сценариев создавать отдельные носители и
|
||||
state-каталоги, чтобы параллельный запуск не перезаписывал чужой тест.
|
||||
Конфигурация разработчика не содержит абсолютных личных путей в Git.
|
||||
Для `.img` нужен упаковщик из SDK; для `.chd` нынешний `make_hdd.sh`
|
||||
дополнительно использует внешние `mtools` и `chdman`. Зафиксировать эти
|
||||
host-зависимости и проверять их перед упаковкой. `chdman` можно находить
|
||||
через `PATH` либо переопределяемый `CHDMAN_BIN`; путь к исходникам MAME или
|
||||
полная установка эмулятора для самой сборки `.exe` не нужны.
|
||||
|
||||
Временное состояние MAME (`cfg`, `nvram`, `diff`, `snapshot`, IPC) создаётся
|
||||
в отдельном каталоге сессии. Обычный запуск, автотест и DAP используют одну
|
||||
функцию/профиль разрешения путей, но разные режимы жизненного цикла и
|
||||
разные локальные диски. Не требовать закрывать все чужие процессы MAME по
|
||||
общему шаблону: предотвращать конфликт только за конкретный изменяемый
|
||||
носитель/IPC-сеанс. Закрытие MAME не должно терять лог и снимки теста.
|
||||
|
||||
## Граница MAME и отладки
|
||||
|
||||
В репозитории MAME держать точную базовую ревизию fork, stock сборку и
|
||||
патченный `sdbg` backend как явную ветку/патч с рецептом сборки. Локальный
|
||||
checkout уже имеет отдельный `.git` и remote
|
||||
`https://git.snark13.com/snark13/MAME.git`; переносить его как источник,
|
||||
не копировать `.git` внутрь нового репозитория тулкита. ROM, DSS, CHD,
|
||||
скачанные assets, исполняемые файлы и пользовательский state игнорировать.
|
||||
Инструкция установки создаёт стандартный `MAME_HOME`; она проверяет
|
||||
наличие/версию файлов, но не распространяет их через Git без отдельной
|
||||
проверки лицензий и источника.
|
||||
|
||||
В Sprinter-CC остаются `--src-debug`, C/asm-карта, debug package,
|
||||
`toolchain/sdbg*`, Python DAP, launcher и Sprinter-специфичный плагин
|
||||
`sdbgbridge`. Приложение знает только debug build и свои файлы. Launcher
|
||||
принимает `MAME_HOME` и все переопределяемые пути из общего профиля;
|
||||
`-pluginspath`, IPC, ожидание DSS, установка точек и `sdbg` протокол — его
|
||||
внутренняя работа. Сочетание с родным окном MAME остаётся опцией:
|
||||
`osx` на macOS, `qt`/`imgui` на Linux по возможностям сборки. Stock MAME
|
||||
можно выбрать для поддержанного штатного backend; режим `sdbg` требует
|
||||
соответствующей патченной сборки и должен проверять её до запуска.
|
||||
Native Windows launch/attach пока не обещать: текущий host transport зависит
|
||||
от Unix sockets/`fcntl` и не прошёл полный Windows сценарий.
|
||||
|
||||
Общий `mamebridge` и `mame_mcp.py` остаются с fork MAME, пока используются
|
||||
как общие инструменты MAME. Sprinter-специфичному `sdbgbridge` отдельный Git
|
||||
репозиторий сейчас не нужен: он версионируется с DAP-протоколом в SDK и
|
||||
загружается через `-pluginspath`. Текущий `toolchain/run-mame-mcp.sh` и
|
||||
`.codex/config.toml` убрать от предположения `mame/sources/MAME` внутри
|
||||
тулкита; для общего MCP использовать путь к установленному MCP-серверу
|
||||
MAME, а для Sprinter C-уровня — DAP/session server SDK. Зафиксировать версию
|
||||
протокола bridge и совместимость SDK ↔ MAME; несовпадение показывать как
|
||||
ошибку, а не как зависание отладки.
|
||||
|
||||
Репозиторий VSCode-Sprinter содержит только extension и клиентские тесты.
|
||||
По умолчанию он обнаруживает DAP-инструмент из `SPRINTER_ROOT` либо получает
|
||||
явную настройку пути; не ищет `toolchain/sdbg_dap.py` в открытом workspace.
|
||||
Build task выполняет `make SRC_DEBUG=1 SPRINTER_ROOT=...` в каталоге
|
||||
приложения, F5 передаёт debug package, MAME-профиль и собственные data файлы
|
||||
адаптеру SDK. Поиск проектов не должен зависеть только от буквального
|
||||
`include $(PROJ_ROOT)/app.mk`: определить простой стабильный признак проекта
|
||||
или явный каталог в `launch.json`. Версии VSIX, SDK и поддержанной ревизии
|
||||
MAME документируются вместе; extension не копирует Python backend.
|
||||
|
||||
## Известные связи, которые нужно заменить
|
||||
|
||||
| Сейчас | Замена и причина |
|
||||
|---|---|
|
||||
| Корневые `Makefile` и `app.mk` собирают examples/MAME и пишут общую floppy/HDD под `mame/v306` | В SDK оставить `make`/`size-check`/собственные тесты; упаковщик принимает выходной путь и MAME-профиль. Примеры собираются в Examples, MAME — в MAME. Удаление корневых целей без замены лишило бы пользователя привычного run: документировать новые команды и при необходимости оставить переходные targets с явной подсказкой. |
|
||||
| `make host-tests` SDK запускает `applications/SprPoP/tests/host` | Тесты игры вызываются из SprPoP по `SPRINTER_ROOT`; SDK тестирует только SDK. При этом общий `testkit` остаётся SDK. |
|
||||
| SprPoP `mame-link` меняет `MAME_HOME/IMG/test_hdd.chd` | `make run` передаёт `build/hdd/sprpop.chd` как `-hard2`; состояние установки MAME не меняется. |
|
||||
| Volkov `hdd-p5-viewer` собирает `examples/mdview2` | Перенести внутрь Volkov небольшой viewer fixture либо зафиксированный тестовый EXE/исходник с лицензией. Предпочтение — локальный fixture, потому что тест проверяет запуск дочерней программы, а не функциональность mdview2. Проверить, какие свойства реального viewer обязательны, перед заменой. |
|
||||
| Volkov `tests/run_mame_hdd.sh` и другие сценарии считают MAME `../../mame/v306` | Принять общие переменные MAME и локальный образ, не менять сценарные Lua-файлы без необходимости. |
|
||||
| PoP `roomtest`, `bgtest`, `poc` ищут собственные `toolchain/` и `SDLPoP` через корень SDK | Ввести `POP_ROOT`/`APP_ROOT` внутри PoP и локальные пути к референсам; общие SDK-инструменты оставить по `SPRINTER_ROOT`. Не переносить чужие источники и оригинальные игровые данные в Git. |
|
||||
| Examples Makefiles и `rpgwalk/conv_sprites.py` рассчитаны на `../..` и тулкитский `third_party/` | Подключать SDK по `SPRINTER_ROOT`, resource fixture держать рядом с примером либо получать по зафиксированному рецепту. |
|
||||
| `toolchain/mame_interactive.py`, `tests/sdbg/run_mame_probe.py`, `sdbg_launcher.py` выводят MAME из `PROJECT_ROOT` | Ввести общий профиль путей, локальные образы и явные аргументы; offline `sdbg-tests` не должен требовать MAME. |
|
||||
| `toolchain/make_release.sh` включает `examples/` в архив SDK, а README описывает общий диск | Выпускать Examples отдельно либо собирать составной release по зафиксированной версии Examples; сначала определить контракт релиза, затем изменить архив и обе документации. |
|
||||
| Исследовательские docs и код библиотек ссылаются на `applications/PoP/...` и `mame/sources/...` | Сохранять контекст исследования, но заменять рабочие инструкции и относительные ссылки ссылками на соответствующий репозиторий/revision. Архивные наблюдения не удалять. |
|
||||
|
||||
## История Git, внешние ресурсы и совместимость
|
||||
|
||||
До выделения репозиториев инвентаризировать незакоммиченные/неотслеживаемые
|
||||
файлы в корне и отдельный dirty checkout MAME. Среди новых файлов сейчас
|
||||
находятся DAP, extension, MCP-плагин, MAME patch и документы: простое
|
||||
`git subtree split` по старому HEAD их потеряет. Сначала сохранить работу
|
||||
в подходящих коммитах/ветках или проверенных патчах; не сбрасывать и не
|
||||
перезаписывать пользовательские изменения. Затем выделить историю
|
||||
`examples/`, `applications/SprPoP/`, `applications/Volkov/` и PoP через
|
||||
`git subtree split` либо `git filter-repo`, проверяя состав каждого нового
|
||||
Git дерева. Для MAME использовать его существующую историю, а не историю
|
||||
родительского SDK, где `mame/` игнорируется.
|
||||
|
||||
Новый `.gitignore` каждого проекта покрывает собственные `.exe`, объекты,
|
||||
debug packages, носители, снимки, внешние источники и секреты локального
|
||||
конфига. Не переносить автоматически вложенные сторонние `.git` как gitlinks
|
||||
или submodules. Для ресурсов, которые не входят в Git, указать источник,
|
||||
контрольную сумму/revision и способ восстановления; релиз и CI не должны
|
||||
молча зависеть от файлов конкретной машины. Если приложению нужен SDK с
|
||||
определённым ABI, фиксировать поддерживаемый релиз/commit SDK в его README
|
||||
или manifest, не пытаться угадывать его по соседнему каталогу.
|
||||
|
||||
Во время перехода обеспечить минимальный период совместимости старой
|
||||
структуры: сначала добавить переменные и альтернативные пути, проверить
|
||||
старые вызовы, затем переносить деревья. После миграции убрать fallback на
|
||||
старые относительные пути и диагностировать устаревшие команды. Это не
|
||||
должно оставлять два параллельных способа записи в общий носитель.
|
||||
|
||||
## Порядок работ и критерии готовности
|
||||
|
||||
1. **Зафиксировать текущую базу.** Список tracked/untracked файлов,
|
||||
отдельный Git MAME, лицензии/внешние ресурсы, базовые результаты сборок
|
||||
и smoke-тестов. Сохранить незавершённую отладочную и прикладную работу.
|
||||
Критерий: никакой исходник не теряется при выделении истории.
|
||||
2. **Стабилизировать контракт SDK.** В `app.mk` разделить build и run,
|
||||
реализовать `SPRINTER_ROOT`, `MAME_HOME` и переопределения, упаковку
|
||||
локального образа с проверкой `mtools`/`chdman`, общий профиль путей
|
||||
и диагностику отсутствующих файлов. Перевести автотесты/launcher без
|
||||
переноса дерева.
|
||||
Критерий: приложение собирается без MAME и запускается с нестандартным
|
||||
`MAME_BIN`/ROM/DSS/System HDD.
|
||||
3. **Подготовить MAME отдельно.** Сохранить fork с точной baseline-revision,
|
||||
stock/sdbg сборки и проверенным способом подготовки `MAME_HOME`.
|
||||
Перенести MAME patch/общий MCP к их владельцу, проверить обоих провайдеров.
|
||||
Критерий: два бинарника существуют одновременно, ROM/CHD и state не
|
||||
коммитятся, patched DAP launch проверен.
|
||||
4. **Выделить Examples.** Переписать относительные пути и источник RPG
|
||||
графики, определить формат релиза SDK+Examples. Критерий: каждый пример
|
||||
собирается из собственного клона Examples при одном `SPRINTER_ROOT`,
|
||||
локальные диски не затрагивают MAME или соседние проекты.
|
||||
5. **Выделить приложения по одному.** Сначала SprPoP как наиболее близкий
|
||||
к автономному контракту, затем Volkov с локальным viewer fixture, затем
|
||||
архив PoP с его референсами. Переписать тесты и run-скрипты, сохраняя
|
||||
их документы и историю. Критерий: каждый продукт собирается и проходит
|
||||
доступные host/MAME проверки из изолированного клона без Examples и
|
||||
других приложений.
|
||||
6. **Выделить extension.** Научить VS Code находить SDK DAP независимо от
|
||||
workspace, запускать build/run/debug приложения и показывать ошибки
|
||||
разрешения путей/версий. Проверить VSIX в отдельном workspace SprPoP
|
||||
или Volkov, а не только в `C-Compiler`. Критерий: F5 строит debug package,
|
||||
ждёт DSS, доходит до `main`, принимает breakpoint/logpoint и клавиатуру;
|
||||
опция родного окна MAME работает на macOS. Windows остаётся явно
|
||||
ограниченной до отдельной реализации host transport.
|
||||
7. **Очистить SDK и документы.** Заменить корневые цели, release-скрипт,
|
||||
README, `AGENTS.md`, инструкции автотеста/отладки и рабочие ссылки.
|
||||
Проверить `make`, `make -C libc`, `make -C libbgi`, `make size-check`,
|
||||
SDK host/sdbg tests и отдельные проекты. Критерий: в SDK нет tracked
|
||||
приложений/примеров и игнорируемого вложенного MAME; инструкции используют
|
||||
новые пути, а архивные исследования остаются доступны.
|
||||
|
||||
Каждый этап заканчивается проверкой в отдельном клоне и просмотром Git diff,
|
||||
а не одним успешным запуском в старом общем дереве. Реальные переносы,
|
||||
новые Git remote и публикация релизов выполняются отдельной задачей после
|
||||
согласования этого плана.
|
||||
@@ -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)
|
||||
{
|
||||
errno = -1;
|
||||
errno = -2;
|
||||
int16_t a0 = 0, a1 = 0;
|
||||
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