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

This commit is contained in:
2026-09-15 17:58:41 +03:00
parent 50c6e56b7b
commit e4695b8281
62 changed files with 7147 additions and 27 deletions
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Применяет воспроизводимый backend sdbg к checkout MAME 0.287.
set -eu
project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
mame_source=${1:-"$project_dir/mame/sources/MAME"}
patch_file="$project_dir/toolchain/mame-patches/0001-sdbg-debugger-backend.patch"
if git -C "$mame_source" apply --reverse --check "$patch_file" 2>/dev/null; then
echo "MAME sdbg backend уже применён: $mame_source"
elif git -C "$mame_source" apply --check "$patch_file"; then
git -C "$mame_source" apply "$patch_file"
echo "MAME sdbg backend применён: $mame_source"
else
echo "Не удалось применить sdbg patch к $mame_source" >&2
echo "Ожидается MAME 0.287, commit b0c4527c." >&2
exit 1
fi
@@ -0,0 +1,129 @@
diff --git a/scripts/src/osd/modules.lua b/scripts/src/osd/modules.lua
--- a/scripts/src/osd/modules.lua
+++ b/scripts/src/osd/modules.lua
@@ -69,6 +69,7 @@
MAME_DIR .. "src/osd/modules/debugger/debugkeyconfig.h",
MAME_DIR .. "src/osd/modules/debugger/debuggdbstub.cpp",
MAME_DIR .. "src/osd/modules/debugger/debugimgui.cpp",
+ MAME_DIR .. "src/osd/modules/debugger/sdbg.cpp",
MAME_DIR .. "src/osd/modules/debugger/debugwin.cpp",
MAME_DIR .. "src/osd/modules/debugger/none.cpp",
MAME_DIR .. "src/osd/modules/debugger/xmlconfig.cpp",
diff --git a/scripts/src/osd/mac.lua b/scripts/src/osd/mac.lua
--- a/scripts/src/osd/mac.lua
+++ b/scripts/src/osd/mac.lua
@@ -78,6 +78,7 @@
files {
MAME_DIR .. "src/osd/modules/debugger/debugosx.mm",
+ MAME_DIR .. "src/osd/modules/debugger/sdbgmac.mm",
MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.mm",
MAME_DIR .. "src/osd/modules/debugger/osx/breakpointsview.h",
MAME_DIR .. "src/osd/modules/debugger/osx/consoleview.mm",
diff --git a/src/osd/modules/debugger/sdbg.cpp b/src/osd/modules/debugger/sdbg.cpp
new file mode 100644
--- /dev/null
+++ b/src/osd/modules/debugger/sdbg.cpp
@@ -0,0 +1,72 @@
+// license:BSD-3-Clause
+// copyright-holders:Sprinter C-Compiler contributors
+//============================================================
+//
+// sdbg.cpp - headless debugger wait loop for an external DAP bridge
+//
+//============================================================
+
+#include "emu.h"
+#include "debug_module.h"
+#include "modules/lib/osdobj_common.h"
+
+#if defined(OSD_MAC)
+void sdbg_poll_mac_events();
+#endif
+
+
+namespace osd {
+
+namespace {
+
+class debug_sdbg : public osd_module, public debug_module
+{
+public:
+ debug_sdbg() : osd_module(OSD_DEBUG_PROVIDER, "sdbg"), debug_module(),
+ m_osd(nullptr), m_next_host_poll(0)
+ { }
+
+ virtual int init(osd_interface &osd, const osd_options &options) override
+ {
+ m_osd = &dynamic_cast<osd_common_t &>(osd);
+ return 0;
+ }
+ virtual void exit() override { }
+
+ virtual void init_debugger(running_machine &machine) override { }
+ virtual void wait_for_debugger(device_t &device, bool firststop) override;
+ virtual void debugger_update() override { }
+
+private:
+ osd_common_t *m_osd;
+ osd_ticks_t m_next_host_poll;
+};
+
+void debug_sdbg::wait_for_debugger(device_t &device, bool firststop)
+{
+ // debugger_cpu::wait_for_debugger вызывает Lua periodic перед каждым
+ // обращением сюда. Короткий sleep исключает busy-loop, после возврата
+ // внешний bridge получает следующую возможность обработать RPC.
+ // Без обработки событий окон macOS считает MAME зависшим, пока CPU
+ // удерживается на точке VS Code. 100 Hz достаточно для Dock/Cmd-Tab.
+ osd_ticks_t const current = osd_ticks();
+ if (current >= m_next_host_poll)
+ {
+#if defined(OSD_MAC)
+ sdbg_poll_mac_events();
+#else
+ // Текущий mame.arm собран на SDL3: pump + poll необходимы,
+ // поскольку обычный frame_update не выполняется при остановке CPU.
+ m_osd->input_update(false);
+ m_osd->process_events();
+#endif
+ m_next_host_poll = current + osd_ticks_per_second() / 100;
+ }
+ osd_sleep(osd_ticks_per_second() / 1000);
+}
+
+} // anonymous namespace
+
+} // namespace osd
+
+MODULE_DEFINITION(DEBUG_SDBG, osd::debug_sdbg)
diff --git a/src/osd/modules/debugger/sdbgmac.mm b/src/osd/modules/debugger/sdbgmac.mm
new file mode 100644
--- /dev/null
+++ b/src/osd/modules/debugger/sdbgmac.mm
@@ -0,0 +1,14 @@
+// license:BSD-3-Clause
+// copyright-holders:Sprinter C-Compiler contributors
+// Прокачка событий Cocoa в headless debugger без окна штатного debugger.
+
+#import <Cocoa/Cocoa.h>
+
+extern void MacPollInputs();
+
+void sdbg_poll_mac_events()
+{
+ NSAutoreleasePool *const pool = [[NSAutoreleasePool alloc] init];
+ MacPollInputs();
+ [pool release];
+}
diff --git a/src/osd/modules/lib/osdobj_common.cpp b/src/osd/modules/lib/osdobj_common.cpp
--- a/src/osd/modules/lib/osdobj_common.cpp
+++ b/src/osd/modules/lib/osdobj_common.cpp
@@ -282,6 +282,7 @@
REGISTER_MODULE(m_mod_man, DEBUG_QT);
REGISTER_MODULE(m_mod_man, DEBUG_IMGUI);
REGISTER_MODULE(m_mod_man, DEBUG_GDBSTUB);
+ REGISTER_MODULE(m_mod_man, DEBUG_SDBG);
REGISTER_MODULE(m_mod_man, DEBUG_NONE);
#endif
+242
View File
@@ -0,0 +1,242 @@
-- license:BSD-3-Clause
-- Изолированный backend sdbg. Один каталог/процесс-владелец на сессию.
-- Не загружать одновременно с другим мостом управления тем же CPU.
local exports = { name="sdbgbridge", version="0.1.0", description="Sprinter source debugger",
license="BSD-3-Clause", author={name="Sprinter C-Compiler contributors"} }
function exports.startplugin()
local json = require("json")
local lfs = require("lfs")
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
local generation, sequence = 0, 0
local events, owned = {}, {}
local state, pending = "initializing", nil
local invalidated=false
local session_started=false
local registers = {"PC","SP","AF","BC","DE","HL","IX","IY","AF2","BC2","DE2","HL2",
"I","R","IM","IFF1","IFF2","PG0","PG1","PG2","PG3","CNF","7FFD","1FFD"}
local function machine() return manager.machine end
local function cpu() return machine().devices[":maincpu"] end
local function now()
local t=machine().time
return t.seconds+t.attoseconds/1e18
end
local function event(kind, body)
sequence=sequence+1
events[#events+1]={seq=sequence,kind=kind,generation=generation,body=body or {}}
if #events>512 then table.remove(events,1) end
end
local function atomic(path, value)
local file=assert(io.open(path..".tmp","wb"))
file:write(json.stringify(value));file:close()
assert(os.rename(path..".tmp",path))
end
local function update()
if invalidated then return end
local observed=machine().debugger.execution_state=="stop" and "stopped" or "running"
-- step() исполняется лишь после возврата periodic callback.
if pending and pending.kind:match("^step") and now()<=pending.time then return end
if observed~=state or (pending and observed=="stopped") then
state=observed
generation=generation+1
local body={pc=cpu().state.PC.value}
if pending then body.reason=pending.kind end
event(state,body)
if state=="stopped" then pending=nil end
end
end
local function stopped(request)
assert(state=="stopped" and not pending,"CPU не остановлен")
assert(request.generation==generation,"Устаревшая generation")
end
local function number(value, limit)
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
return value
end
local invalidate
local function dispatch(request)
assert(request.session==session,"Чужая сессия")
local args=request.args or {}
local command=request.command
if invalidated and command~="hello" and command~="snapshot" and command~="events" then
error("Сессия инвалидирована reset/load")
end
if command=="hello" then
session_started=true
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
instruction_step=true,step_over=true,step_out=true,
bank_guard=true,deferred_breakpoints=true,console_print=true},state=state}
elseif command=="snapshot" then
local result={state=state,time=now(),paused=machine().paused}
result.keyboards=setmetatable({}, {__jsontype="object"})
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
result.keyboards[tag]=kbd.enabled
end
if state=="stopped" and not pending then
result.registers=setmetatable({}, {__jsontype="object"})
for _,name in ipairs(registers) do
if cpu().state[name] then result.registers[name]=cpu().state[name].value end
end
end
return result
elseif command=="key" then
-- Этот путь нужен для воспроизводимых UI-тестов; обычная клавиатура
-- хоста остаётся у MAME и не проходит через bridge.
assert(state=="running" or (state=="stopped" and args.down==false),
"Нажатие принимается при running CPU, отпускание также при stopped")
local tag=args.tag
assert(type(tag)=="string" and tag:match("^:kbd:ms_naturl:P%d+%.%d+$"),
"Разрешены только порты PC-клавиатуры Sprinter")
local mask=number(args.mask,0xffff)
assert(type(args.down)=="boolean","Нужен флаг down")
local port=machine().ioport.ports[tag]
assert(port,"Порт клавиатуры не найден")
for _,field in pairs(port.fields) do
if field.mask==mask then
field:set_value(args.down and 1 or 0)
return {accepted=true,tag=tag,mask=mask,down=args.down}
end
end
error("Поле клавиатуры не найдено")
elseif command=="events" then
local result={events={},last=sequence,first=events[1] and events[1].seq or sequence+1}
for _,item in ipairs(events) do
if item.seq>(args.after or 0) then result.events[#result.events+1]=item end
end
return result
elseif command=="console_tail" then
local log=machine().debugger.consolelog
local total=#log
local count=number(args.count or 40,200)
local lines={}
for index=math.max(1,total-count+1),total do
lines[#lines+1]=log[index]
end
return {total=total,lines=lines}
elseif command=="pause" then
if state~="stopped" then
pending={kind="pause",time=now()}
machine().debugger.execution_state="stop"
end
return {accepted=true}
elseif command=="continue" or command=="step" or command=="step_over" or
command=="step_out" then
stopped(request)
generation=generation+1
state="running"
event("running",{reason=command})
if command=="step" then
pending={kind="step",time=now()}
cpu().debug:step(1)
elseif command=="step_over" then
pending={kind="step_over",time=now()}
machine().debugger:command("over 1")
elseif command=="step_out" then
pending={kind="step_out",time=now()}
machine().debugger:command("out")
else machine().debugger.execution_state="run" end
return {accepted=true}
elseif command=="memory" then
stopped(request)
local address=number(args.address,0xffff)
assert(args.enabled==nil or type(args.enabled)=="boolean","Неверный enabled")
local length=number(args.length,4096)
assert(address+length<=0x10000,"Чтение за пределами logical memory")
local symbols=emu.symbol_table(cpu())
local bytes={}
for offset=0,length-1 do
-- Этот интерфейс отключает side effects в отличие от space:read_u8.
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true))
end
return {hex=table.concat(bytes)}
elseif command=="console_print" then
stopped(request)
local text=args.text
assert(type(text)=="string" and #text>=1 and #text<=2048,
"Нужна строка журнала до 2048 байт")
-- В debugger printf уже отформатированный текст — только данные.
-- Убираем управляющие символы и экранируем синтаксис команды/формата.
local safe=text:gsub("[%c]"," "):gsub("\\","\\\\")
:gsub("%%","%%%%"):gsub('"',"'")
machine().debugger:command('printf "'..safe..'"')
return {printed=true}
elseif command=="breakpoint" then
stopped(request)
local address=number(args.address,0xffff)
local condition=""
if args.window~=nil or args.page~=nil then
local window=number(args.window,3)
local page=number(args.page,255)
local ports={[0]=0x82,[1]=0xa2,[2]=0xc2,[3]=0xe2}
-- Дополнительные PG state entries драйвера Sprinter не входят в
-- expression table CPU. Читаем штатный page-port без side effects;
-- числа debugger expression по умолчанию шестнадцатеричные.
condition=string.format("ib@%x==%x",ports[window],page)
end
local id=cpu().debug:bpset(address,condition,"")
owned[id]={address=address,condition=condition}
if args.enabled==false then cpu().debug:bpdisable(id) end
return {id=id,condition=condition,enabled=args.enabled~=false}
elseif command=="activate_breakpoints" then
stopped(request)
local count=0
for id in pairs(owned) do
if cpu().debug:bpenable(id) then count=count+1 end
end
return {enabled=count}
elseif command=="deactivate_breakpoints" then
stopped(request)
local count=0
for id in pairs(owned) do
if cpu().debug:bpdisable(id) then count=count+1 end
end
return {disabled=count}
elseif command=="clear" then
stopped(request)
local id=number(args.id,0x7fffffff)
assert(owned[id],"Точка не принадлежит sdbg")
cpu().debug:bpclear(id);owned[id]=nil
return {cleared=id}
else error("Неподдержанная команда: "..tostring(command)) end
end
invalidate=function(reason)
if invalidated then return end
if not session_started then return end
if reason=="state_load" then
for id in pairs(owned) do cpu().debug:bpclear(id) end
end
owned={}
invalidated=true
generation=generation+1
pending=nil
state="invalidated"
event("invalidated",{reason=reason})
end
-- Subscription-объекты надо удерживать: иначе Lua GC снимет callback.
exports._subscriptions={
emu.add_machine_reset_notifier(function() invalidate("reset") end),
emu.add_machine_post_load_notifier(function() invalidate("state_load") end)
}
emu.register_periodic(function()
if not machine() or not machine().debugger then return end
update()
for name in lfs.dir(directory) do
local id=name:match("^req_(%d+)%.json$")
if id then
local path=directory.."/"..name
local file=io.open(path,"rb")
if file then
local data=file:read("*a");file:close()
local ok,request=pcall(json.parse,data)
local result
if ok and type(request)=="table" then ok,result=pcall(dispatch,request)
else result="Неверный JSON";ok=false end
atomic(directory.."/resp_"..id..".json",{ok=ok,generation=generation,
result=ok and result or nil,error=not ok and tostring(result) or nil,session=session})
os.remove(path)
end
end
end
end)
end
return exports
+1
View File
@@ -0,0 +1 @@
{"plugin":{"name":"sdbgbridge","description":"Sprinter: протокол сессии отладки","version":"0.1.0","author":"Sprinter C-Compiler contributors","type":"plugin","start":"false"}}
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Запуск MCP из любой папки проекта; локальные пути не попадают в Git.
set -eu
mame_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
if [ -f "$mame_root/.codex/mame.local.env" ]; then
. "$mame_root/.codex/mame.local.env"
fi
: "${MAME_UV:=uv}"
: "${MAME_MCP_SCRIPT:=mame/sources/MAME/src/mame_mcp.py}"
cd "$mame_root"
if ! command -v "$MAME_UV" >/dev/null 2>&1; then
echo "mame-z80: uv не найден; добавьте его в PATH или задайте MAME_UV в .codex/mame.local.env" >&2
exit 1
fi
if [ ! -f "$MAME_MCP_SCRIPT" ]; then
echo "mame-z80: сервер не найден: $MAME_MCP_SCRIPT; задайте MAME_MCP_SCRIPT в .codex/mame.local.env" >&2
exit 1
fi
exec "$MAME_UV" run --python 3.12 --no-project --with 'mcp<2' "$MAME_MCP_SCRIPT"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Оффлайновая карта исходников Sprinter; live attach появится отдельным этапом."""
import argparse
import json
import sys
from sdbg.model import DebugMap
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', required=True)
commands = parser.add_subparsers(dest='command', required=True)
for name in ('map', 'vars', 'symbols', 'verify'): commands.add_parser(name)
address = commands.add_parser('addr2line')
address.add_argument('address', type=lambda s: int(s, 0))
line = commands.add_parser('line2addr')
line.add_argument('file')
line.add_argument('line', type=int)
args = parser.parse_args()
try:
model = DebugMap(args.build)
if args.command == 'map': result = model.functions
elif args.command == 'vars': result = model.variables
elif args.command == 'symbols': result = model.symbols
elif args.command == 'addr2line': result = model.addr2line(args.address)
elif args.command == 'line2addr': result = model.line_locations(args.file,args.line)
else:
model.verify_executable()
result = {'build_id': model.manifest['build_id'], 'artifacts': 'verified',
'stale_sources': model.stale_sources, 'diagnostics': model.diagnostics,
'limitations': model.manifest['limitations']}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, OSError, KeyError) as error:
print('sdbg: ' + str(error), file=sys.stderr)
return 1
if __name__ == '__main__': sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Карта оптимизированного кода SDCC и пакет отладки Sprinter."""
+113
View File
@@ -0,0 +1,113 @@
"""Сборочные операции sdbg: уникальные debug-символы без изменения инструкций."""
from __future__ import annotations
import hashlib
import json
import re
import subprocess
from pathlib import Path
from .macros import anchor_symbols, extract
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def write_json(path: Path, data) -> None:
temporary = path.with_suffix(path.suffix + '.tmp')
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
temporary.replace(path)
def normalize(assembly: str, adb: str, module: str):
"""Меняет только имена debug-записей; сохраняет число строк .asm."""
match = re.search(r'^\s*\.module\s+(\S+)', assembly, re.M)
if not match:
raise ValueError('В asm отсутствует .module')
old = match.group(1)
# F/XF/L связывают статики, функции и локальные с единицей трансляции.
def rename(text):
text = re.sub(r'\b(X?F)' + re.escape(old) + r'(?=\$)',
lambda m: m[1] + module, text)
return re.sub(r'\bL' + re.escape(old) + r'(?=[.$])', 'L' + module, text)
# Не трогаем .ascii и инструкции: строковый литерал может содержать
# текст, похожий на debug-символ. Переименовываем только определения
# и объявления символов, сгенерированные SDCC.
symbol_line = re.compile(r'^\s*(?:\.globl\s+|(?:X?F|L)' + re.escape(old) + r'[$.])')
assembly = ''.join(rename(line) if symbol_line.match(line) else line
for line in assembly.splitlines(keepends=True))
adb = rename(adb)
assembly = re.sub(r'(?m)^(\s*\.module\s+)\S+', lambda m: m[1] + module, assembly)
adb = re.sub(r'(?m)^M:.*$', 'M:' + module, adb)
markers, active, output = {}, {}, []
for number, line in enumerate(assembly.splitlines(keepends=True), 1):
definition = re.match(r'\s*(C\$[^\s=]+)\s*=\s*\.', line)
if definition:
original = definition[1]
parts = original.split('$')
if len(parts) != 5:
raise ValueError('Неизвестная форма CDB: ' + original)
unique = f'C${module}_{len(markers)}${parts[2]}${parts[3]}${parts[4]}'
active[original] = unique
markers[unique] = {'original': original, 'file': parts[1],
'line': int(parts[2]), 'asm_line': number}
line = line.replace(original, unique)
elif re.match(r'\s*\.globl\s+C\$', line):
original = line.split()[1]
if original not in active:
raise ValueError('CDB .globl без определения: ' + original)
line = line.replace(original, active[original])
output.append(line)
return ''.join(output), adb, markers
def compile_unit(sdcc: Path, assembler: Path, source: Path, output: Path, flags, debug=True):
"""-S и те же опции ассемблера, которые SDCC 4.5 использует с --debug."""
source = source.resolve()
asm, adb = output.with_suffix('.asm'), output.with_suffix('.adb')
compile_flags = [*flags, '-DSPRINTER_SDBG_ANCHORS=1'] if debug else flags
# SDCC -E кодирует внутренний разделитель строк inline asm байтом 0x87;
# для списка #line нужны только ASCII-пути. Само описание логов извлекаем
# отдельным metadata-проходом без asm-развёртки.
preprocessed = subprocess.check_output([str(sdcc), *compile_flags, '-E', str(source)],
text=True, errors='replace')
dependencies = {str(source)}
for filename in re.findall(r'^#(?:line)?\s*\d+\s+"([^"]+)"', preprocessed, re.M):
path = Path(filename).resolve()
if path.is_file(): dependencies.add(str(path))
hashes = {name: digest(Path(name)) for name in dependencies}
if not debug:
subprocess.run([str(sdcc), *flags, '-c', '-o', str(output), str(source)], check=True)
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
raise ValueError('Исходники изменились во время компиляции')
write_json(output.with_suffix('.sdbg-input.json'), {'dependency_hashes': hashes})
return
metadata = subprocess.check_output([str(sdcc), *compile_flags,
'-DSPRINTER_SDBG_METADATA=1', '-E', str(source)], text=True)
macros = extract(metadata, dependencies)
subprocess.run([str(sdcc), *compile_flags, '--debug', '-S', '-o', str(asm), str(source)], check=True)
if any(digest(Path(name)) != sha for name, sha in hashes.items()):
raise ValueError('Исходники изменились во время компиляции')
original = asm.read_text()
# Включаем output stem: один исходник может собираться в разные банки.
module = 'sdbg_' + hashlib.sha256((str(source) + ':' + output.stem).encode()).hexdigest()[:16]
assembly, descriptions, markers = normalize(original, adb.read_text(), module)
assembly, log_macros = anchor_symbols(assembly, module, macros)
asm.write_text(assembly)
adb.write_text(descriptions)
subprocess.run([str(assembler), '-plosgffwy', str(output), str(asm)], check=True)
# Комментарии SDCC сохраняют фактические пути строк, включая заголовки.
sources = {}
for name in dependencies:
sources.setdefault(Path(name).name, set()).add(name)
for filename in re.findall(r'^;(.+?):\d+:', original, re.M):
path = Path(filename).resolve()
if path.is_file():
sources.setdefault(path.name, set()).add(str(path))
write_json(output.with_suffix('.sdbg-unit.json'), {
'module': module, 'source': str(source), 'source_sha256': digest(source),
'asm': asm.name, 'adb': adb.name, 'object': output.name,
'flags': compile_flags, 'markers': markers, 'log_macros': log_macros,
'dependency_hashes': hashes,
'sources': {k: sorted(v) for k, v in sources.items()},
})
+198
View File
@@ -0,0 +1,198 @@
"""Честный минимальный Debug Adapter Protocol поверх постоянной sdbg-сессии."""
from __future__ import annotations
from pathlib import Path
from .server import rpc_call
from .session import SessionError
class DapEngine:
def __init__(self, rpc=None):
self.rpc_path = None
self._rpc_override = rpc
self.attached = None
self.event_sequence = 0
def rpc(self, method, arguments=None, timeout=10):
if self._rpc_override is not None:
return self._rpc_override(method, arguments or {})
if not self.rpc_path:
raise SessionError('DAP ещё не подключён к session server')
return rpc_call(self.rpc_path, method, arguments, timeout)
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
events = []
if command == 'initialize':
body = {
'supportsConfigurationDoneRequest': True,
'supportsFunctionBreakpoints': True,
'supportsInstructionBreakpoints': False,
'supportsEvaluateForHovers': True,
'supportsSetVariable': False,
'supportsStepBack': False,
'supportsRestartRequest': False,
'supportsTerminateRequest': False,
'supportsDisassembleRequest': False,
'supportsSteppingGranularity': True,
}
return body, events
if command == 'attach':
if self._rpc_override is None:
path = arguments.get('socket')
if not isinstance(path, str) or not path:
raise SessionError('В attach требуется socket session server')
self.rpc_path = path
self.attached = self.rpc('status')
self.event_sequence = self.attached['event_sequence']
events.append(('initialized', {}))
return {'buildId': self.attached['build_id']}, events
if command == 'configurationDone':
location = self.rpc('where')
events.append(('stopped', {'reason': 'entry', 'threadId': 1,
'allThreadsStopped': True,
'description': self._description(location)}))
return {}, events
if command == 'disconnect':
return {}, events
if command == 'threads':
return {'threads': [{'id': 1, 'name': 'Sprinter Z80'}]}, events
if command == 'stackTrace':
location = self.rpc('where')
return {'stackFrames': [self._frame(location)], 'totalFrames': 1}, events
if command == 'scopes':
return {'scopes': [
{'name': 'Registers', 'variablesReference': 1, 'expensive': False},
{'name': 'Globals/statics', 'variablesReference': 2, 'expensive': False},
]}, events
if command == 'variables':
reference = int(arguments.get('variablesReference', 0))
if reference == 1:
values = self.rpc('registers')['registers']
variables = [{'name': name, 'value': f'{value:#x}',
'variablesReference': 0} for name, value in sorted(values.items())]
elif reference == 2:
variables = self._global_variables()
else:
variables = []
return {'variables': variables}, events
if command == 'evaluate':
expression = arguments.get('expression', '')
if not expression.isidentifier():
raise SessionError('MVP evaluate принимает только имя переменной')
value = self.rpc('read_variable', {'name': expression})
return {'result': str(value['value']), 'type': value['type'],
'variablesReference': 0, 'memoryReference': hex(value['link_address'])}, events
if command == 'setBreakpoints':
source = arguments.get('source', {})
path = source.get('path')
if not path:
raise SessionError('Для source breakpoint нужен полный path')
specifications = [{name: item[name] for name in
('line','condition','hitCondition','logMessage') if name in item}
for item in arguments.get('breakpoints', [])]
result = self.rpc('set_source_breakpoints',
{'file': path, 'breakpoints': specifications})
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
if command == 'setFunctionBreakpoints':
names = [item['name'] for item in arguments.get('breakpoints', [])]
result = self.rpc('set_function_breakpoints', {'names': names})
return {'breakpoints': [self._breakpoint(item) for item in result['breakpoints']]}, events
if command == 'continue':
self.rpc('continue')
return {'allThreadsContinued': True}, events
if command == 'pause':
self.rpc('pause')
return {}, events
if command in ('next', 'stepIn'):
if arguments.get('granularity') == 'instruction':
self.rpc('step')
else:
self.rpc('source_step', {
'kind': 'over' if command == 'next' else 'into'})
return {}, events
if command == 'stepOut':
self.rpc('source_step', {'kind': 'out'})
return {}, events
raise SessionError('DAP-команда пока не поддерживается: ' + command)
def poll_events(self, timeout=1):
previous = self.event_sequence
result = self.rpc('events', {'after': self.event_sequence, 'timeout': timeout},
timeout=timeout + 2)
self.event_sequence = result['last']
translated = []
gap = max(0, result.get('first', previous + 1) - previous - 1)
if gap:
translated.append(('output', {'category': 'stderr',
'output': f'sdbg: пропущено {gap} событий журнала; '
'уменьшите частоту logpoint\n'}))
for event in result['events']:
if event['event'] == 'stopped':
translated.append(('stopped', {'reason': event['body'].get('reason', 'breakpoint'),
'threadId': 1, 'allThreadsStopped': True,
'description': self._description(
event['body'].get('location', {}))}))
elif event['event'] == 'continued':
translated.append(('continued', {'threadId': 1, 'allThreadsContinued': True}))
elif event['event'] == 'invalidated':
translated.append(('terminated', {'restart': False}))
elif event['event'] == 'output':
translated.append(('output', {'category': event['body'].get('category','console'),
'output': event['body']['output']}))
return translated, result['closed']
@staticmethod
def _description(location):
status = location.get('status', 'unknown')
bank = location.get('function', {}).get('bank') if location.get('function') else None
suffix = '' if bank is None else f', bank {bank}'
return f'{status}{suffix}, PC={location.get("pc", 0):#x}'
@staticmethod
def _frame(location):
function = location.get('function') or {}
sources = location.get('sources') or []
source = sources[0] if sources else None
frame = {
'id': 1,
'name': function.get('name', '<unknown>') +
(' [ambiguous]' if location.get('status') == 'ambiguous' else ''),
'line': source['line'] if source else 1,
'column': 1,
'instructionPointerReference': hex(location.get('link_address', location.get('pc', 0))),
}
if source:
frame['source'] = {'name': Path(source['file']).name, 'path': source['file']}
return frame
@staticmethod
def _breakpoint(item):
locations = item.get('locations', [])
first = locations[0] if locations else {}
result = {'id': item['id'], 'verified': bool(item.get('verified', locations))}
line = item.get('line', first.get('line'))
if line is not None:
result['line'] = line
if first.get('link_address') is not None:
result['instructionReference'] = hex(first['link_address'])
if len(locations) > 1:
result['message'] = f'Разрешено в {len(locations)} адресов/банков'
return result
def _global_variables(self):
result = []
for variable in self.rpc('variables'):
label = variable['name']
if variable.get('module'):
label += '@' + variable['module']
try:
value = self.rpc('read_variable', {'name': variable['name'],
'module': variable.get('module')})
text = str(value['value'])
except SessionError as error:
text = '<unavailable: ' + str(error) + '>'
result.append({'name': label, 'value': text, 'type': variable['type'],
'variablesReference': 0,
'memoryReference': hex(variable['link_address'])})
return result
+51
View File
@@ -0,0 +1,51 @@
"""Строгий разбор Intel HEX для проверки образа, загруженного в MAME."""
from __future__ import annotations
from pathlib import Path
def read_ihx(path: Path) -> dict[int, int]:
"""Возвращает разреженный образ и отвергает битые/конфликтующие записи."""
memory: dict[int, int] = {}
base = 0
eof = False
for number, raw in enumerate(Path(path).read_text().splitlines(), 1):
if not raw.startswith(':'):
raise ValueError(f'Intel HEX, строка {number}: отсутствует двоеточие')
try:
record = bytes.fromhex(raw[1:])
except ValueError as error:
raise ValueError(f'Intel HEX, строка {number}: неверные hex-цифры') from error
if len(record) < 5 or len(record) != record[0] + 5:
raise ValueError(f'Intel HEX, строка {number}: неверная длина')
if sum(record) & 0xff:
raise ValueError(f'Intel HEX, строка {number}: неверная контрольная сумма')
length = record[0]
address = int.from_bytes(record[1:3], 'big')
kind = record[3]
data = record[4:4 + length]
if eof:
raise ValueError(f'Intel HEX, строка {number}: данные после EOF')
if kind == 0:
for offset, value in enumerate(data):
absolute = base + address + offset
if absolute in memory and memory[absolute] != value:
raise ValueError(f'Intel HEX: конфликт по адресу {absolute:#x}')
memory[absolute] = value
elif kind == 1:
if length or address:
raise ValueError(f'Intel HEX, строка {number}: неверная EOF-запись')
eof = True
elif kind == 2:
if length != 2 or address:
raise ValueError(f'Intel HEX, строка {number}: неверная segment-запись')
base = int.from_bytes(data, 'big') << 4
elif kind == 4:
if length != 2 or address:
raise ValueError(f'Intel HEX, строка {number}: неверная linear-запись')
base = int.from_bytes(data, 'big') << 16
elif kind not in (3, 5):
raise ValueError(f'Intel HEX, строка {number}: неизвестный тип {kind}')
if not eof:
raise ValueError('Intel HEX: отсутствует EOF')
return memory
+152
View File
@@ -0,0 +1,152 @@
"""Извлекает авторские логи из активного препроцессорного потока SDCC."""
from __future__ import annotations
import ast
import bisect
import re
import string
from pathlib import Path
_DIRECTIVE = re.compile(r'^#(?:line)?\s+(\d+)\s+"([^"]+)"')
_CALL = re.compile(r'\bSDBG_METADATA_(LOGIF|LOG)\s*\(')
_IDENT = re.compile(r'[A-Za-z_][A-Za-z_0-9]*\Z')
_LITERAL = re.compile(r'"(?:\\.|[^"\\])*"')
def _calls(text: str):
"""Находит metadata-вызовы вне строк/символьных литералов C."""
position = 0
while position < len(text):
char = text[position]
if char in ('"', "'"):
quote = char
position += 1
while position < len(text):
if text[position] == '\\': position += 2
elif text[position] == quote:
position += 1
break
else: position += 1
continue
match = _CALL.match(text, position)
if match:
yield match
position = match.end()
else:
position += 1
def _arguments(text: str, start: int) -> list[str]:
"""Делит аргументы вызова после `(`, не путая запятые в строках."""
args, begin, depth, quote, escape = [], start, 1, False, False
for position in range(start, len(text)):
char = text[position]
if quote:
if escape: escape = False
elif char == '\\': escape = True
elif char == '"': quote = False
continue
if char == '"': quote = True
elif char == '(': depth += 1
elif char == ')':
depth -= 1
if depth == 0:
args.append(text[begin:position].strip())
return args
elif char == ',' and depth == 1:
args.append(text[begin:position].strip())
begin = position + 1
raise ValueError('Незакрытый вызов SDBG_LOG в препроцессорном потоке')
def _message(value: str) -> str:
literals = []
position = 0
while position < len(value):
while position < len(value) and value[position].isspace(): position += 1
if position == len(value): break
match = _LITERAL.match(value, position)
if not match:
raise ValueError('SDBG_LOG: сообщение должно быть строковым литералом')
try:
decoded = ast.literal_eval(match.group())
except (ValueError, SyntaxError) as error:
raise ValueError('SDBG_LOG: неверный строковый литерал') from error
if not isinstance(decoded, str):
raise ValueError('SDBG_LOG: нужен обычный строковый литерал')
literals.append(decoded)
position = match.end()
message = ''.join(literals)
if not message or len(message) > 1024:
raise ValueError('SDBG_LOG: сообщение должно содержать 1..1024 символа')
validate_log_message(message)
return message
def validate_log_message(message: str) -> None:
"""Одна грамматика для C-макроса и DAP logMessage."""
if not isinstance(message, str) or not message or len(message) > 1024:
raise ValueError('logMessage должен содержать 1..1024 символа')
try:
fields = list(string.Formatter().parse(message))
except ValueError as error:
raise ValueError('Неверные фигурные скобки logMessage') from error
for _, name, spec, conversion in fields:
if name is not None and (not _IDENT.fullmatch(name) or spec or conversion):
raise ValueError('В logMessage разрешены только подстановки {variable}')
def extract(preprocessed: str, dependencies: set[str]) -> list[dict]:
"""Возвращает активные вызовы и проверенные исходные пути/строки."""
lines = preprocessed.splitlines(keepends=True)
offsets, indexed = [], []
offset, filename, number = 0, None, 0
for line in lines:
offsets.append(offset)
directive = _DIRECTIVE.match(line)
if directive:
filename, number = directive[2], int(directive[1])
indexed.append((None, 0))
else:
indexed.append((filename, number))
number += 1
offset += len(line)
found, tags = [], set()
for match in _calls(preprocessed):
index = bisect.bisect_right(offsets, match.start()) - 1
filename, number = indexed[index]
if not filename or number < 1:
raise ValueError('SDBG_LOG: препроцессор не сохранил позицию исходника')
source = str(Path(filename).resolve())
if source not in dependencies:
raise ValueError('SDBG_LOG: вызов вне проверенных исходников: ' + source)
args = _arguments(preprocessed, match.end())
expected = 3 if match[1] == 'LOGIF' else 2
if len(args) != expected:
raise ValueError('SDBG_LOG: неверное число аргументов')
tag = args[0]
if not _IDENT.fullmatch(tag) or tag in tags:
raise ValueError('SDBG_LOG: tag должен быть уникальным идентификатором TU: ' + tag)
tags.add(tag)
condition = args[1] if expected == 3 else None
if condition is not None and not _IDENT.fullmatch(condition):
raise ValueError('SDBG_LOGIF: пока поддержано только имя переменной')
found.append({'tag': tag, 'message': _message(args[-1]),
'condition': condition, 'source': source, 'line': number})
return found
def anchor_symbols(assembly: str, module: str, macros: list[dict]) -> tuple[str, list[dict]]:
"""Уникализирует asm-символы TU и требует ровно один якорь на macro tag."""
descriptions = []
for macro in macros:
old = '_spr_sdbg_log_' + macro['tag']
new = '_spr_sdbg_log_' + module + '_' + macro['tag']
definition = re.compile(r'(?m)^(\s*)' + re.escape(old) + r'(\s*=\s*\.\s*)$')
declaration = re.compile(r'(?m)^(\s*\.globl\s+)' + re.escape(old) + r'(\s*)$')
if len(definition.findall(assembly)) != 1 or len(declaration.findall(assembly)) != 1:
raise ValueError('SDBG_LOG: якорь отсутствует или развёрнут повторно: ' + macro['tag'])
assembly = definition.sub(lambda m: m[1] + new + m[2], assembly)
assembly = declaration.sub(lambda m: m[1] + new + m[2], assembly)
descriptions.append({**macro, 'symbol': new})
return assembly, descriptions
+193
View File
@@ -0,0 +1,193 @@
"""Проверенная оффлайновая карта SDCC; неизвестные диапазоны не угадываются."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import json
from pathlib import Path
import re
from .build import digest
@dataclass(frozen=True)
class Location:
link_address: int
logical_address: int
section: str
bank: int | None
window: int | None
class DebugMap:
def __init__(self, directory):
self.directory = Path(directory).resolve()
self.manifest = json.loads((self.directory/'manifest.json').read_text())
if self.manifest['schema_version'] != 1:
raise ValueError('Неподдержанная версия пакета')
for name, expected in self.manifest['artifacts'].items():
path = (self.directory/name).resolve()
if not path.is_relative_to(self.directory) or digest(path) != expected:
raise ValueError('Повреждённый артефакт: ' + name)
self.stale_sources = [name for name, value in self.manifest['sources'].items()
if not Path(name).is_file() or digest(Path(name)) != value['sha256']]
stem = Path(self.manifest['executable']).stem
self.symbols = {}
for row in (self.directory/(stem+'.noi')).read_text().splitlines():
match = re.fullmatch(r'DEF (\S+) (0x[0-9A-Fa-f]+)', row)
if match:
self.symbols[match[1]] = int(match[2], 16)
self.sections = []
for row in (self.directory/(stem+'.map')).read_text().splitlines():
match = re.match(r'^(\S+)\s+([0-9A-F]{8})\s+([0-9A-F]{8})\s+=', row)
if match and int(match[3],16):
item = (match[1], int(match[2],16), int(match[3],16))
if item not in self.sections: self.sections.append(item)
self.units = {u['module']: u for u in self.manifest['units']}
self.instructions = {}
self.markers = []
self.functions = []
self.variables = []
self.logpoints = []
self.diagnostics = []
self._load((self.directory/(stem+'.cdb')).read_text().splitlines())
def verify_executable(self):
path = Path(self.manifest['executable_path'])
if not path.is_file() or digest(path) != self.manifest.get('executable_sha256', self.manifest['build_id']):
raise ValueError('EXE не соответствует пакету')
def location(self, address):
sections = [s for s in self.sections if s[1] <= address < s[1]+s[2]]
if len(sections) != 1:
raise ValueError(f'Неоднозначная/неизвестная секция адреса {address:#x}')
section = sections[0][0]
match = re.fullmatch(r'_?BANK(\d+)', section)
bank = int(match[1]) if match else None
if address > 0xffff and (bank is None or address >> 16 != bank):
raise ValueError(f'Неподдержанное размещение {section}: {address:#x}')
logical = address & 0xffff
return asdict(Location(address, logical, section, bank, logical >> 14))
def _load(self, records):
addresses, declarations = {}, []
module = None
for record in records:
if record.startswith('M:'): module = record[2:]
elif record.startswith('L:'):
name, value = record[2:].rsplit(':', 1)
address = int(value, 16)
if name in addresses and addresses[name] != address:
raise ValueError('Конфликт отладочного символа: ' + name)
addresses[name] = address
elif record.startswith(('F:', 'S:')):
declarations.append((module, record))
elif record and not record.startswith('T:'):
self.diagnostics.append('Неизвестная запись: '+record)
# A$ у ассемблера использует basename файла, а не .module.
for unit in self.units.values():
asm = (self.directory/unit['asm']).read_text().splitlines()
sizes = {}
listing = (self.directory/Path(unit['asm']).with_suffix('.lst')).read_text()
for row in listing.splitlines():
match = re.match(r'^\s+[0-9A-F]{6,8}\s+(.+?)\s+\[\s*\d+\]\s+(\d+)\s', row)
if match:
sizes[int(match[2])] = len(re.findall(r'[0-9A-F]{2}', match[1]))
prefix = 'A$'+Path(unit['asm']).stem+'$'
for symbol, address in addresses.items():
if symbol.startswith(prefix):
line = int(symbol[len(prefix):])
if line in sizes and sizes[line] > 0:
self.instructions[address] = {
**self.location(address), 'size': sizes[line],
'asm': unit['asm'], 'asm_line': line, 'text': asm[line-1].strip(),
'module': unit['module'],
}
for symbol, marker in unit['markers'].items():
if symbol not in addresses:
raise ValueError('Отсутствует linked CDB-маркер: '+symbol)
paths = unit['sources'].get(Path(marker['file']).name, [])
if len(paths) > 1:
raise ValueError('Неоднозначный путь debug-записи: ' + marker['file'])
self.markers.append({**self.location(addresses[symbol]),
'line': marker['line'], 'sources': paths,
'module': unit['module']})
for module, record in declarations:
match = re.match(r'([FS]):([^($]+\$[^($]+\$[^($]+\$[^($]+)\(\{(\d+)\}(.+)\),([A-Z]),', record)
if not match: continue
kind, key, size, ctype, space = match.groups()
parts = key.split('$')
if kind == 'F':
startkey = '$'.join(parts[:2])+'$0$0'
start, last = addresses.get(startkey), addresses.get('X'+startkey)
if start is None or last not in self.instructions: continue
end = last + self.instructions[last]['size']
if end <= start: continue
function = {'name': parts[1], 'module': module, 'start': start,
'end': end, **self.location(start)}
if function not in self.functions: self.functions.append(function)
elif space == 'E' and parts[0].startswith(('G','F')) and not ctype.startswith('DF,'):
address = addresses.get(key)
if address is None and parts[0] == 'G': address = self.symbols.get('_'+parts[1])
if address is None: continue
supported = bool(re.fullmatch(r'S[ICL]:[SU]', ctype) or ctype.startswith('DG,'))
variable = {'name': parts[1], 'module': module if parts[0] != 'G' else None,
'size': int(size), 'type': ctype,
'signed': ctype.endswith(':S') and not ctype.startswith('D'),
'supported': supported, **self.location(address)}
if variable not in self.variables: self.variables.append(variable)
self.functions.sort(key=lambda f: f['start'])
self.markers.sort(key=lambda m: m['link_address'])
for unit in self.units.values():
for macro in unit.get('log_macros', []):
address = self.symbols.get(macro['symbol'])
if address is None:
raise ValueError('Связанный SDBG_LOG-якорь отсутствует: ' + macro['tag'])
try:
location = self.location(address)
verified = address in self.instructions and self.function_at(address) is not None
reason = None if verified else 'Якорь не совпал с началом исполняемой инструкции'
except ValueError as error:
location, verified, reason = {}, False, str(error)
self.logpoints.append({**macro, **location, 'module': unit['module'],
'verified': verified, 'reason': reason})
def function_at(self, address):
found = [f for f in self.functions if f['start'] <= address < f['end']]
return found[0] if len(found) == 1 else None
def addr2line(self, address):
function = self.function_at(address)
instruction = next((v for k,v in self.instructions.items()
if k <= address < k+v['size']), None)
if not function or not instruction:
return {'address': address, 'status': 'unknown', 'function': function}
markers = [m for m in self.markers if m['module'] == function['module']
and function['start'] <= m['link_address'] <= instruction['link_address']]
nearest = max((m['link_address'] for m in markers), default=None)
sources = []
for marker in markers:
if marker['link_address'] == nearest:
for source in marker['sources']:
item = {'file': source, 'line': marker['line']}
if item not in sources: sources.append(item)
return {'status': 'mapped' if len(sources) == 1 else 'ambiguous' if sources else 'unknown',
'instruction': instruction, 'function': function, 'sources': sources, 'stale_source': any(s['file'] in self.stale_sources for s in sources)}
def line_locations(self, filename, line):
exact = str(Path(filename).resolve())
known = self.manifest['sources']
candidates = [exact] if exact in known else [p for p in known if Path(p).name == filename]
if len(candidates) > 1: raise ValueError('Неоднозначный source; укажите полный путь')
result = []
for marker in self.markers:
address = marker['link_address']
if marker['line'] == line and any(p in candidates for p in marker['sources']):
if address in self.instructions and self.function_at(address):
item = {**marker, 'function': self.function_at(address)['name']}
if item not in result: result.append(item)
stale = any(p in self.stale_sources for p in candidates)
return {'status': 'stale' if stale else 'verified' if result else 'unverified',
'locations': result, 'stale_source': stale}
def source_text(self, filename, line):
source = self.manifest['sources'][filename]
text = (self.directory/source['snapshot']).read_text(errors='replace').splitlines()
return text[line-1] if 0 < line <= len(text) else None
+464
View File
@@ -0,0 +1,464 @@
"""Постоянный владелец DebugSession и локальный JSON-RPC для адаптеров."""
from __future__ import annotations
from collections import deque
import json
from pathlib import Path
import socket
import socketserver
import string
import threading
import time
from .session import DebugSession, SessionError
from .transport import BridgeError
from .macros import validate_log_message
class SessionController:
def __init__(self, session: DebugSession):
self.session = session
self.lock = threading.RLock()
self.changed = threading.Condition()
self.events = deque(maxlen=1024)
self.dropped_events = 0
self.sequence = 0
self.running = False
self.source_step = None
self.closed = False
self.source_breakpoints: dict[str, list[int]] = {}
self.function_breakpoints: list[int] = []
self.breakpoint_info: dict[int, dict] = {}
self.attached = session.attach()
self.mame_console = bool(self.attached.get('capabilities', {}).get('console_print'))
self._emit('stopped', {'reason': 'entry', 'location': self.attached['location']})
self._install_macro_logs()
self.poller = threading.Thread(target=self._poll, name='sdbg-poller', daemon=True)
self.poller.start()
def _install_macro_logs(self) -> None:
"""Авторские точки принадлежат пакету сборки, не DAP source-набору."""
created = []
try:
for macro in getattr(self.session.model, 'logpoints', []):
if not macro['verified'] or macro['source'] in self.session.model.stale_sources:
self._emit('output', {'category': 'stderr',
'output': 'sdbg: SDBG_LOG ' + macro['tag'] +
' не активирован: ' + str(macro.get('reason') or 'устаревший исходник') + '\n'})
continue
self._validate_log_message(macro['message'])
item = self.session.break_anchor(macro, enabled=False)
created.append(item['id'])
self.breakpoint_info[item['id']] = {
'kind': 'log', 'message': macro['message'],
'condition': macro['condition'], 'module': macro['module'],
'tag': macro['tag'], 'locations': item['locations'], 'hits': 0,
}
if created:
self.session.activate_breakpoints()
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try: self.session.clear_breakpoint(identifier)
except BaseException: pass
raise
def _emit(self, name: str, body: dict) -> None:
with self.changed:
if len(self.events) == self.events.maxlen:
self.dropped_events += 1
self.sequence += 1
self.events.append({'seq': self.sequence, 'event': name, 'body': body})
self.changed.notify_all()
def _poll(self) -> None:
while not self.closed:
if not self.running:
time.sleep(.02)
continue
try:
with self.lock:
snapshot = self.session.bridge.request('snapshot')
if snapshot['state'] == 'stopped':
location = self.session.where(snapshot)
if self.source_step is not None:
self._source_step_stopped(location)
elif self._handle_logpoints(location):
self.session.bridge.request('continue')
continue
else:
self.running = False
self._emit('stopped', {'reason': 'breakpoint', 'location': location})
elif snapshot['state'] == 'invalidated':
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': 'reset_or_load'})
except (BridgeError, SessionError, ValueError, OSError) as error:
self.running = False
self.source_step = None
self.closed = True
self._emit('invalidated', {'reason': str(error)})
time.sleep(.01)
def close(self) -> None:
self.closed = True
self.poller.join(timeout=1)
self.session.bridge.close()
def call(self, method: str, arguments: dict) -> object:
if method == 'events':
after = int(arguments.get('after', 0))
timeout = min(max(float(arguments.get('timeout', 0)), 0), 30)
deadline = time.monotonic() + timeout
with self.changed:
while self.sequence <= after and not self.closed and time.monotonic() < deadline:
self.changed.wait(deadline - time.monotonic())
return {'events': [event for event in self.events if event['seq'] > after],
'first': self.events[0]['seq'] if self.events else self.sequence + 1,
'last': self.sequence, 'lost': self.dropped_events,
'closed': self.closed}
with self.lock:
if self.closed:
raise SessionError('Сессия закрыта или инвалидирована')
if method == 'status':
return {**self.attached, 'running': self.running,
'event_sequence': self.sequence}
if method == 'snapshot':
return self.session.bridge.request('snapshot')
if method == 'mame_console_tail':
return self.session.bridge.request(
'console_tail', count=int(arguments.get('count', 40)))
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
return self.session.bridge.request(
'key', tag=arguments['tag'], mask=int(arguments['mask']),
down=arguments['down'])
if method == 'where':
return self.session.where()
if method == 'registers':
mapping = self.session.refresh()
return {'generation': mapping.generation, 'registers': mapping.registers,
'bank_pages': mapping.bank_pages}
if method == 'variables':
return self.session.model.variables
if method == 'read_variable':
return self.session.read_variable(arguments['name'], arguments.get('module'))
if method == 'break_line':
return self.session.break_line(arguments['file'], int(arguments['line']))
if method == 'break_function':
return self.session.break_function(arguments['name'])
if method == 'clear_breakpoint':
return self.session.clear_breakpoint(int(arguments['id']))
if method == 'set_source_breakpoints':
specifications = arguments.get('breakpoints')
if specifications is None:
specifications = [{'line': line} for line in arguments.get('lines', [])]
return self._set_source_breakpoints(arguments['file'], specifications)
if method == 'set_function_breakpoints':
return self._set_function_breakpoints(arguments.get('names', []))
if method == 'continue':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
result = self.session.bridge.request('continue')
self.running = True
self._emit('continued', {})
return result
if method == 'pause':
self.source_step = None
self.session.bridge.request('pause')
location = self.session.where(self.session.bridge.wait_stopped())
self.running = False
self._emit('stopped', {'reason': 'pause', 'location': location})
return location
if method == 'step':
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
self.session.bridge.request('step')
self.running = True
self._emit('continued', {'reason': 'step'})
location = self.session.where(self.session.bridge.wait_stopped())
self.running = False
self._emit('stopped', {'reason': 'step', 'location': location})
return location
if method == 'source_step':
return self._source_step(arguments.get('kind', 'into'))
raise SessionError('Неизвестный RPC-метод: ' + method)
@staticmethod
def _source_identity(location: dict) -> frozenset[tuple[str, int]]:
return frozenset((item['file'], int(item['line']))
for item in location.get('sources', []))
def _has_stop_breakpoint(self, location: dict) -> bool:
address = location.get('link_address')
return any(info['kind'] == 'stop' and
any(item.get('link_address') == address for item in info['locations'])
for info in self.breakpoint_info.values())
def _source_step(self, kind: str) -> dict:
commands = {'into': 'step', 'over': 'step_over', 'out': 'step_out'}
if kind not in commands:
raise SessionError('Неизвестный вид source step')
if self.running:
raise SessionError('CPU уже выполняется; сначала Pause')
start = self.session.where()
if start.get('stale_source'):
raise SessionError('Исходник изменён после сборки; пересоберите программу')
initial = self._source_identity(start)
if not initial:
raise SessionError('Текущий PC не имеет проверенной C-позиции')
self.source_step = {
'initial': initial, 'kind': kind, 'command': commands[kind],
'instructions': 1, 'location': start,
}
try:
self.session.bridge.request(commands[kind])
except BaseException:
self.source_step = None
raise
if kind == 'out':
# Первый out выходит из машинного frame. У банкового вызова
# дальше идём через over до первой позиции C вызывающей функции.
self.source_step['command'] = 'step_over'
self.running = True
self._emit('continued', {'reason': 'step'})
return {'accepted': True}
def _source_step_stopped(self, location: dict) -> None:
step = self.source_step
self._handle_logpoints(location)
reason = None
if self._has_stop_breakpoint(location):
reason = 'breakpoint'
elif self._source_identity(location) and self._source_identity(location) != step['initial']:
reason = 'step'
elif step['instructions'] >= 512:
reason = 'step'
self._emit('output', {'category': 'stderr',
'output': 'sdbg: source step достиг лимита; CPU остановлен\n'})
if reason is not None:
self.source_step = None
self.running = False
self._emit('stopped', {'reason': reason, 'location': location})
return
# Машинный over может ждать клавишу сколь угодно долго. Пока CPU
# выполняется, этот автомат не занимает session lock и не ставит
# таймер; пользователь может направить ввод в MAME или нажать Pause.
self.session.bridge.request(step['command'])
step['instructions'] += 1
step['location'] = location
def _set_source_breakpoints(self, filename: str, specifications) -> dict:
filename = str(Path(filename).resolve())
requested = []
for value in specifications:
if not isinstance(value, dict):
raise SessionError('Описание breakpoint должно быть объектом')
line = int(value['line'])
if line <= 0 or any(item['line'] == line for item in requested):
raise SessionError('Номер строки должен быть положительным и уникальным')
if value.get('condition') or value.get('hitCondition'):
raise SessionError('Условия и hitCondition пока не поддержаны')
message = value.get('logMessage')
if message is not None:
self._validate_log_message(message)
requested.append({'line': line, 'logMessage': message})
created = []
results = []
try:
for specification in requested:
line = specification['line']
item = self.session.break_line(filename, line, enabled=False)
created.append(item['id'])
kind = 'log' if specification['logMessage'] is not None else 'stop'
self.breakpoint_info[item['id']] = {
'kind': kind, 'message': specification['logMessage'],
'locations': item['locations'], 'hits': 0,
}
results.append({'line': line, 'verified': True,
'logMessage': specification['logMessage'], **item})
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try:
self.session.clear_breakpoint(identifier)
except BaseException:
pass
raise
previous = self.source_breakpoints.get(filename, [])
for identifier in previous:
self.breakpoint_info.pop(identifier, None)
self.session.clear_breakpoint(identifier)
self.source_breakpoints[filename] = created
self.session.activate_breakpoints()
return {'file': filename, 'breakpoints': results}
def _set_function_breakpoints(self, names) -> dict:
requested = []
for value in names:
name = str(value)
if not name or name in requested:
raise SessionError('Имя функции должно быть непустым и уникальным')
requested.append(name)
created = []
results = []
try:
for name in requested:
item = self.session.break_function(name, enabled=False)
created.append(item['id'])
self.breakpoint_info[item['id']] = {
'kind': 'stop', 'message': None, 'locations': item['locations'], 'hits': 0}
results.append({'name': name, 'verified': True, **item})
except BaseException:
for identifier in created:
self.breakpoint_info.pop(identifier, None)
try:
self.session.clear_breakpoint(identifier)
except BaseException:
pass
raise
for identifier in self.function_breakpoints:
self.breakpoint_info.pop(identifier, None)
self.session.clear_breakpoint(identifier)
self.function_breakpoints = created
self.session.activate_breakpoints()
return {'breakpoints': results}
@staticmethod
def _validate_log_message(message: str) -> None:
try:
validate_log_message(message)
except ValueError as error:
raise SessionError(str(error)) from error
def _read_log_variable(self, name: str, module: str | None) -> dict:
if module is None:
return self.session.read_variable(name)
candidates = [item for item in self.session.model.variables
if item['name'] == name and item['module'] in (None, module)]
if len(candidates) != 1:
raise SessionError('Переменная не найдена или имя неоднозначно')
return self.session.read_variable(name, candidates[0]['module'])
def _render_log_message(self, message: str, module: str | None = None) -> str:
output = []
for literal, name, _, _ in string.Formatter().parse(message):
output.append(literal)
if name is not None:
try:
output.append(str(self._read_log_variable(name, module)['value']))
except SessionError as error:
output.append('<unavailable: ' + str(error) + '>')
return ''.join(output)
def _handle_logpoints(self, location: dict) -> bool:
address = location.get('link_address')
matched_logs = []
matched_any_log = False
matched_stop = False
for info in self.breakpoint_info.values():
if not any(item.get('link_address') == address for item in info['locations']):
continue
if info['kind'] == 'stop':
matched_stop = True
else:
matched_any_log = True
condition = info.get('condition')
if condition is not None:
try:
if not self._read_log_variable(condition, info.get('module'))['value']:
continue
except SessionError as error:
if not info.get('condition_warned'):
info['condition_warned'] = True
self._emit('output', {'category': 'stderr',
'output': 'sdbg: SDBG_LOGIF ' + info.get('tag', '') +
': ' + str(error) + '\n'})
continue
info['hits'] += 1
matched_logs.append(info)
for info in matched_logs:
rendered = self._render_log_message(info['message'], info.get('module'))
self._emit('output', {'category': 'console',
'output': rendered + '\n',
'location': location, 'hit': info['hits'],
'tag': info.get('tag')})
if self.mame_console:
try:
self.session.bridge.request('console_print', text=rendered)
except (BridgeError, OSError) as error:
if not info.get('console_warned'):
info['console_warned'] = True
self._emit('output', {'category': 'stderr',
'output': 'sdbg: MAME console: ' + str(error) + '\n'})
return matched_any_log and not matched_stop
class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
class SessionRpcServer:
def __init__(self, path, controller: SessionController):
self.path = Path(path)
self.controller = controller
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
try:
with socket.socket(socket.AF_UNIX) as probe:
probe.connect(str(self.path))
except OSError:
self.path.unlink()
else:
raise OSError('RPC socket уже занят: ' + str(self.path))
controller_ref = controller
class Handler(socketserver.StreamRequestHandler):
def handle(self):
raw = self.rfile.readline(1_048_577)
response = {'id': None, 'ok': False}
try:
if len(raw) > 1_048_576:
raise ValueError('RPC-запрос слишком велик')
request = json.loads(raw)
if not isinstance(request, dict) or not isinstance(request.get('method'), str):
raise ValueError('Неверная структура RPC-запроса')
arguments = request.get('arguments', {})
if not isinstance(arguments, dict):
raise ValueError('RPC arguments должен быть объектом')
response = {'id': request.get('id'), 'ok': True,
'result': controller_ref.call(request['method'], arguments)}
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
response.update(error=str(error))
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
self.server = _ThreadedUnixServer(str(self.path), Handler)
self.path.chmod(0o600)
def serve_forever(self):
try:
self.server.serve_forever(poll_interval=.1)
finally:
self.close()
def close(self):
self.server.server_close()
self.path.unlink(missing_ok=True)
def rpc_call(path, method: str, arguments=None, timeout=10):
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(timeout)
client.connect(str(path))
client.sendall((json.dumps(request, ensure_ascii=False) + '\n').encode())
file = client.makefile('rb')
raw = file.readline(1_048_577)
if not raw:
raise SessionError('RPC server закрыл соединение без ответа')
response = json.loads(raw)
if not response.get('ok'):
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
return response['result']
+276
View File
@@ -0,0 +1,276 @@
"""Высокоуровневая source-debug сессия поверх проверенной карты и IPC."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .image import read_ihx
from .model import DebugMap
from .transport import FileBridge
class SessionError(RuntimeError):
pass
@dataclass(frozen=True)
class MappingSnapshot:
generation: int
registers: dict[str, int]
bank_pages: dict[int, int]
class DebugSession:
"""Связывает build identity, состояние CPU и логические операции IDE."""
def __init__(self, model: DebugMap, bridge: FileBridge):
self.model = model
self.bridge = bridge
self.mapping: MappingSnapshot | None = None
self.resident_pages: dict[int, int] = {}
self.breakpoints: dict[int, list[int]] = {}
self._next_breakpoint = 1
stem = Path(model.manifest['executable']).stem
self.image = read_ihx(model.directory / (stem + '.ihx'))
def attach(self) -> dict:
"""Проверяет пакет, остановку CPU, резидентный код и bank mapping."""
try:
self.model.verify_executable()
hello = self.bridge.handshake()
snapshot = self.bridge.wait_stopped()
mapping = self._mapping(snapshot)
if mapping.bank_pages and not hello.get('capabilities', {}).get('bank_guard'):
raise SessionError('Backend не поддерживает безопасные банковские точки')
self._verify_loaded_code(mapping)
except (ValueError, OSError) as error:
raise SessionError(str(error)) from error
self.mapping = mapping
self._remember_resident_pages(mapping)
return {
'build_id': self.model.manifest['build_id'],
'state': snapshot['state'],
'protocol': hello['protocol'],
'capabilities': hello.get('capabilities', {}),
'generation': mapping.generation,
'pc': mapping.registers['PC'],
'bank_pages': mapping.bank_pages,
'stale_sources': self.model.stale_sources,
'location': self.where(snapshot),
}
def refresh(self) -> MappingSnapshot:
snapshot = self.bridge.wait_stopped()
self.mapping = self._mapping(snapshot)
self._remember_resident_pages(self.mapping)
self._verify_loaded_code(self.mapping)
return self.mapping
def _remember_resident_pages(self, mapping: MappingSnapshot) -> None:
bank_values = set(mapping.bank_pages.values())
windows = {instruction['window'] for instruction in self.model.instructions.values()
if instruction['bank'] is not None}
for window in windows:
value = self._page(mapping, window)
if value is not None and value not in bank_values:
self.resident_pages[window] = value
@staticmethod
def _page(mapping: MappingSnapshot, window: int) -> int | None:
"""Sprinter state PG может включать старшие флаги; port хранит byte."""
value = mapping.registers.get(f'PG{window}')
return None if value is None else value & 0xff
def _mapping(self, snapshot: dict) -> MappingSnapshot:
registers = snapshot.get('registers')
if snapshot.get('state') != 'stopped' or not isinstance(registers, dict):
raise SessionError('Backend не предоставил регистры остановленного CPU')
if 'PC' not in registers:
raise SessionError('Backend не предоставил PC')
maximum = max((location['bank'] or 0 for location in
[*self.model.functions, *self.model.variables,
*self.model.instructions.values()]), default=0)
pages: dict[int, int] = {}
table = self.model.symbols.get('_bank_pages')
if maximum:
if table is None:
raise SessionError('В банковской сборке отсутствует _bank_pages')
data = bytes.fromhex(self.bridge.request(
'memory', address=table, length=maximum + 1)['hex'])
pages = {bank: data[bank] for bank in range(1, maximum + 1)}
if any(value == 0 for value in pages.values()) or \
len(set(pages.values())) != len(pages):
raise SessionError('_bank_pages ещё не готова или содержит нули/дубликаты')
windows = {location['window'] for location in
[*self.model.functions, *self.model.variables]
if location['bank'] is not None}
missing = [window for window in windows if f'PG{window}' not in registers]
if missing:
raise SessionError('Backend не предоставил PG для банковских окон')
generation = self.bridge.generation
if generation is None:
raise SessionError('Backend не предоставил generation')
return MappingSnapshot(generation, dict(registers), pages)
def _expected_ranges(self, mapping: MappingSnapshot):
expected: dict[int, int] = {}
active_windows = {instruction['window'] for instruction in self.model.instructions.values()
if instruction['bank'] is not None and
self._page(mapping, instruction['window']) ==
mapping.bank_pages[instruction['bank']]}
for instruction in self.model.instructions.values():
bank = instruction['bank']
if bank is not None:
if self._page(mapping, instruction['window']) != mapping.bank_pages[bank]:
continue
elif instruction['window'] in active_windows:
# Физическая страница банка закрыла весь resident window.
continue
start = instruction['link_address']
logical = instruction['logical_address']
for offset in range(instruction['size']):
if start + offset not in self.image:
raise SessionError(f'В IHX нет инструкции по адресу {start + offset:#x}')
value = self.image[start + offset]
if logical + offset in expected and expected[logical + offset] != value:
raise SessionError(f'Неоднозначный ожидаемый байт {logical + offset:#x}')
expected[logical + offset] = value
return expected
def _verify_loaded_code(self, mapping: MappingSnapshot) -> None:
expected = self._expected_ranges(mapping)
addresses = sorted(expected)
ranges: list[tuple[int, int]] = []
for address in addresses:
if not ranges or address != ranges[-1][1] or address - ranges[-1][0] >= 4096:
ranges.append((address, address + 1))
else:
ranges[-1] = (ranges[-1][0], address + 1)
for start, end in ranges:
actual = bytes.fromhex(self.bridge.request(
'memory', address=start, length=end - start)['hex'])
wanted = bytes(expected[address] for address in range(start, end))
if actual != wanted:
mismatch = next(i for i, pair in enumerate(zip(actual, wanted))
if pair[0] != pair[1])
raise SessionError(
f'Образ в MAME не соответствует build по адресу {start + mismatch:#x}')
def _link_address(self, pc: int, mapping: MappingSnapshot) -> int:
candidates = []
for instruction in self.model.instructions.values():
if instruction['bank'] is None:
continue
if not (instruction['logical_address'] <= pc <
instruction['logical_address'] + instruction['size']):
continue
if self._page(mapping, instruction['window']) == mapping.bank_pages[instruction['bank']]:
candidates.append(instruction['link_address'] +
pc - instruction['logical_address'])
candidates = sorted(set(candidates))
if len(candidates) > 1:
raise SessionError(f'Неоднозначное банковское отображение PC={pc:#x}')
return candidates[0] if candidates else pc
def where(self, snapshot: dict | None = None) -> dict:
mapping = self._mapping(snapshot) if snapshot is not None else self.refresh()
if snapshot is not None:
self._verify_loaded_code(mapping)
pc = mapping.registers['PC']
link = self._link_address(pc, mapping)
return {'pc': pc, 'link_address': link, **self.model.addr2line(link)}
def _guard(self, location: dict, mapping: MappingSnapshot) -> dict:
bank = location['bank']
window = location['window']
banked_window = any(instruction['bank'] is not None and
instruction['window'] == window
for instruction in self.model.instructions.values())
if bank is not None:
return {'window': window, 'page': mapping.bank_pages[bank]}
if not banked_window:
return {}
if window not in self.resident_pages:
raise SessionError('Физическая страница resident window ещё не установлена')
return {'window': window, 'page': self.resident_pages[window]}
def _install(self, locations: list[dict], enabled: bool = True) -> dict:
if not locations:
raise SessionError('Для точки остановки нет исполняемых адресов')
mapping = self.refresh()
ids = []
conditions = []
try:
for location in locations:
arguments = self._guard(location, mapping)
if not enabled:
arguments['enabled'] = False
result = self.bridge.request(
'breakpoint', address=location['logical_address'],
**arguments)
ids.append(result['id'])
conditions.append(result.get('condition', ''))
except BaseException:
for identifier in ids:
try:
self.bridge.request('clear', id=identifier)
except BaseException:
pass
raise
logical = self._next_breakpoint
self._next_breakpoint += 1
self.breakpoints[logical] = ids
return {'id': logical, 'backend_ids': ids, 'conditions': conditions,
'locations': locations}
def break_line(self, filename: str, line: int, enabled: bool = True) -> dict:
resolved = self.model.line_locations(filename, line)
if resolved['stale_source']:
raise SessionError('Исходник изменён после сборки; пересоберите программу')
return self._install(resolved['locations'], enabled)
def break_function(self, name: str, enabled: bool = True) -> dict:
locations = [function for function in self.model.functions
if function['name'] == name]
return self._install(locations, enabled)
def break_anchor(self, location: dict, enabled: bool = True) -> dict:
if not location.get('verified') or location.get('source') in self.model.stale_sources:
raise SessionError('SDBG_LOG-якорь не проверен или исходник устарел')
return self._install([location], enabled)
def activate_breakpoints(self) -> dict:
return self.bridge.request('activate_breakpoints')
def deactivate_breakpoints(self) -> dict:
return self.bridge.request('deactivate_breakpoints')
def clear_breakpoint(self, identifier: int) -> dict:
ids = self.breakpoints.pop(identifier, None)
if ids is None:
raise SessionError('Неизвестная логическая точка остановки')
for backend_id in ids:
self.bridge.request('clear', id=backend_id)
return {'cleared': identifier, 'backend_ids': ids}
def read_variable(self, name: str, module: str | None = None) -> dict:
matches = [variable for variable in self.model.variables
if variable['name'] == name and
(module is None or variable['module'] == module)]
if len(matches) != 1:
raise SessionError('Переменная не найдена или имя неоднозначно')
variable = matches[0]
if not variable['supported'] or variable['size'] not in (1, 2, 4):
raise SessionError('Тип переменной пока не поддерживается')
mapping = self.refresh()
if variable['bank'] is not None:
if self._page(mapping, variable['window']) != mapping.bank_pages[variable['bank']]:
raise SessionError('Банк переменной сейчас не отображён')
else:
bank_pages = set(mapping.bank_pages.values())
if self._page(mapping, variable['window']) in bank_pages:
raise SessionError('Resident-страница переменной сейчас закрыта банком')
data = bytes.fromhex(self.bridge.request(
'memory', address=variable['logical_address'], length=variable['size'])['hex'])
value = int.from_bytes(data, 'little', signed=variable['signed'])
return {**variable, 'value': value, 'hex': data.hex()}
+84
View File
@@ -0,0 +1,84 @@
"""Один владелец файлового backend; timeout инвалидирует канал mutations."""
from __future__ import annotations
import fcntl
import json
from pathlib import Path
import threading
import time
import uuid
from .build import write_json
class BridgeError(RuntimeError):
pass
class FileBridge:
def __init__(self, directory, session, timeout=5):
self.directory = Path(directory)
self.session = session
self.timeout = timeout
self.generation = None
self.invalid = False
self._mutex = threading.Lock()
self._owner = (self.directory/'owner.lock').open('a')
try:
fcntl.flock(self._owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self._owner.close()
raise BridgeError('У backend уже есть управляющая сессия')
def close(self):
with self._mutex:
self._owner.close()
def request(self, command, **args):
with self._mutex:
if self._owner.closed:
raise BridgeError('Канал закрыт')
if self.invalid:
raise BridgeError('Канал инвалидирован после timeout; требуется новая сессия')
identity = str(uuid.uuid4().int)
request = self.directory/f'req_{identity}.json'
response = self.directory/f'resp_{identity}.json'
write_json(request, {'session': self.session, 'generation': self.generation,
'command': command, 'args': args})
deadline = time.monotonic()+self.timeout
while time.monotonic() < deadline:
if response.exists():
try:
value = json.loads(response.read_text())
if not isinstance(value, dict) or not {'session', 'generation', 'ok'} <= value.keys():
raise ValueError('Неверная структура ответа')
except (OSError, ValueError) as error:
self.invalid = True
raise BridgeError('Повреждённый ответ backend: ' + str(error)) from error
response.unlink()
if value['session'] != self.session:
self.invalid = True
raise BridgeError('Ответ от другой сессии')
self.generation = value['generation']
if not value['ok']:
raise BridgeError(value['error'])
return value['result']
time.sleep(.005)
self.invalid = True
request.unlink(missing_ok=True)
raise BridgeError('Timeout: результат команды неизвестен; автоматический повтор запрещён')
def wait_stopped(self, timeout=5):
deadline = time.monotonic()+timeout
while time.monotonic() < deadline:
snapshot = self.request('snapshot')
if snapshot['state']=='stopped':
return snapshot
if snapshot['state']=='invalidated':
raise BridgeError('Сессия MAME инвалидирована reset/load')
time.sleep(.005)
raise BridgeError('CPU не остановился за отведённое время')
def handshake(self):
response = self.request('hello')
if response['protocol'] != 1:
raise BridgeError('Неподдержанная версия протокола')
return response
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Внутренний сборочный интерфейс sprinter-cc с краткой диагностикой."""
import argparse
import subprocess
import sys
from pathlib import Path
from sdbg.build import compile_unit
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('command', choices=['compile'])
p.add_argument('--plain', action='store_true')
p.add_argument('--sdcc', type=Path, required=True)
p.add_argument('--assembler', type=Path, required=True)
p.add_argument('--source', type=Path, required=True)
p.add_argument('--output', type=Path, required=True)
args = sys.argv[1:]
separator = args.index('--') if '--' in args else len(args)
a = p.parse_args(args[:separator])
try:
compile_unit(a.sdcc, a.assembler, a.source, a.output,
args[separator + 1:], debug=not a.plain)
except subprocess.CalledProcessError as error:
if error.output:
output = error.output.decode(errors='replace') if isinstance(error.output, bytes) else error.output
print(output, file=sys.stderr, end='' if output.endswith('\n') else '\n')
print(f'sdbg: компиляция {a.source.name} не прошла (код {error.returncode})',
file=sys.stderr)
sys.exit(1)
except (ValueError, OSError) as error:
print('sdbg: ' + str(error), file=sys.stderr)
sys.exit(1)
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""CLI-клиент постоянной sdbg-сессии."""
import argparse
import json
import sys
from sdbg.server import rpc_call
from sdbg.session import SessionError
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--socket', required=True)
parser.add_argument('method')
parser.add_argument('arguments', nargs='?', default='{}',
help='JSON-объект аргументов')
args = parser.parse_args()
try:
arguments = json.loads(args.arguments)
if not isinstance(arguments, dict):
raise ValueError('arguments должен быть JSON-объектом')
result = rpc_call(args.socket, args.method, arguments,
timeout=35 if args.method == 'events' else 10)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (SessionError, ValueError, OSError) as error:
print('sdbg-client: ' + str(error), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Обновляет stamp только при смене команды или содержимого зависимостей."""
import hashlib
import json
from pathlib import Path
import sys
mode = sys.argv[1]
if mode not in ('check', 'save'):
raise SystemExit('Ожидается check или save')
stamp = Path(sys.argv[2])
args = sys.argv[3:]
files = set()
for arg in args:
path = Path(arg.split('=', 1)[-1] if '=' in arg else arg)
if path.is_file(): files.add(path.resolve())
# Manifest предыдущей debug-сборки содержит все препроцессорные зависимости.
for name in list(files):
if name.name == 'manifest.json':
files.update(Path(p) for p in json.loads(name.read_text()).get('sources', {}))
files.discard(name)
content = json.dumps({'args': args, 'files': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
if p.is_file() else None for p in sorted(files)}}, sort_keys=True)
changed = not stamp.is_file() or stamp.read_text() != content
if mode == 'check':
print('changed' if changed else 'same')
elif changed:
stamp.parent.mkdir(parents=True, exist_ok=True)
temporary = stamp.with_suffix('.tmp')
temporary.write_text(content)
temporary.replace(stamp)
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""stdio DAP-адаптер Sprinter MAME; stdout содержит только DAP frames."""
from __future__ import annotations
import json
from pathlib import Path
import select
import subprocess
import sys
import tempfile
import threading
import uuid
from sdbg.dap import DapEngine
from sdbg.session import SessionError
class Protocol:
def __init__(self):
self.engine = DapEngine()
self.sequence = 1
self.write_lock = threading.Lock()
self.stopping = threading.Event()
self.poller = None
self.launcher = None
self.launcher_log = None
def send(self, message):
with self.write_lock:
message['seq'] = self.sequence
self.sequence += 1
data = json.dumps(message, ensure_ascii=False, separators=(',', ':')).encode()
sys.stdout.buffer.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
sys.stdout.buffer.flush()
def event(self, name, body):
self.send({'type': 'event', 'event': name, 'body': body})
def _poll(self):
while not self.stopping.is_set():
try:
events, closed = self.engine.poll_events(1)
for name, body in events:
self.event(name, body)
if closed:
return
except (SessionError, OSError, ValueError) as error:
self.event('output', {'category': 'stderr', 'output': 'sdbg: ' + str(error) + '\n'})
self.event('terminated', {'restart': False})
return
def request(self, request):
response = {'type': 'response', 'request_seq': request.get('seq', 0),
'command': request.get('command', ''), 'success': True}
events = []
try:
if request['command'] == 'launch':
body, events = self.launch(request.get('arguments') or {})
else:
body, events = self.engine.handle(request['command'], request.get('arguments') or {})
response['body'] = body
except (SessionError, OSError, ValueError, KeyError, TypeError) as error:
response.update(success=False, message=str(error))
self.send(response)
for name, body in events:
self.event(name, body)
if request.get('command') in ('attach','launch') and response['success'] and self.poller is None:
self.poller = threading.Thread(target=self._poll, name='dap-events', daemon=True)
self.poller.start()
if request.get('command') == 'disconnect':
self.stopping.set()
self.stop_launcher()
def stop_launcher(self):
if self.launcher is not None and self.launcher.poll() is None:
self.launcher.terminate()
if self.launcher_log is not None:
self.launcher_log.close()
self.launcher_log = None
def launcher_diagnostics(self):
if self.launcher_log is None:
return ''
self.launcher_log.flush()
self.launcher_log.seek(0)
data = self.launcher_log.read()[-4000:].strip()
return '\n' + data if data else ''
def launch(self, arguments):
build = arguments.get('build')
if not isinstance(build, str) or not build:
raise SessionError('В launch требуется build с debug-пакетом')
socket_path = arguments.get('socket') or '/tmp/sprinter-sdbg-' + uuid.uuid4().hex + '.sock'
command = [sys.executable, str(Path(__file__).with_name('sdbg_launcher.py')),
'--build', build, '--socket', socket_path]
if arguments.get('mame'):
command.extend(['--mame', arguments['mame']])
if arguments.get('debugger'):
command.extend(['--debugger', arguments['debugger']])
if arguments.get('launchAt') is not None:
command.extend(['--launch-at', str(arguments['launchAt'])])
if arguments.get('dssTimeout') is not None:
command.extend(['--dss-timeout', str(arguments['dssTimeout'])])
for filename in arguments.get('data', []):
command.extend(['--data', filename])
self.launcher_log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
try:
self.launcher = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=self.launcher_log, text=True)
except OSError:
self.launcher_log.close()
self.launcher_log = None
raise
try:
readable, _, _ = select.select([self.launcher.stdout], [], [], 75)
if not readable:
raise SessionError('Launcher не дошёл до main за 75 секунд' +
self.launcher_diagnostics())
line = self.launcher.stdout.readline()
if not line:
code = self.launcher.wait(timeout=5)
raise SessionError(f'Launcher завершился до готовности, rc={code}' +
self.launcher_diagnostics())
ready = json.loads(line)
if not ready.get('ready'):
raise SessionError('Launcher вернул неверный ready')
body, events = self.engine.handle('attach', {'socket': ready['socket']})
body['entry'] = ready['entry']
body['mamePid'] = ready['pid']
body['socket'] = ready['socket']
return body, events
except BaseException:
self.stop_launcher()
raise
def run(self):
stream = sys.stdin.buffer
while not self.stopping.is_set():
headers = {}
while True:
line = stream.readline()
if not line:
self.stopping.set()
return
if line in (b'\r\n', b'\n'):
break
name, value = line.decode('ascii').split(':', 1)
headers[name.lower()] = value.strip()
length = int(headers['content-length'])
if length > 1_048_576:
raise ValueError('DAP frame слишком велик')
request = json.loads(stream.read(length))
if request.get('type') == 'request':
self.request(request)
if __name__ == '__main__':
try:
Protocol().run()
except (ValueError, OSError, KeyError) as error:
print('sdbg-dap: ' + str(error), file=sys.stderr)
sys.exit(1)
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Транзакционная debug-сборка: ошибочная линковка не публикует старый ihx."""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from sdbg.build import digest, write_json
ROOT = Path(__file__).resolve().parents[1]
def metadata(args):
work, exe = map(Path, args[:2])
layout = dict(zip(('mode', 'code', 'data', 'load', 'entry', 'stack'), args[2:]))
units = [json.loads(p.read_text()) for p in sorted(work.glob('*.sdbg-unit.json'))]
if not units:
raise ValueError('Нет отладочных единиц трансляции')
inputs = {}
snapshots = work / 'sources'
snapshots.mkdir()
dependencies = {}
for unit in units + [json.loads(p.read_text()) for p in work.glob('*.sdbg-input.json')]:
for name, expected in unit['dependency_hashes'].items():
if name in dependencies and dependencies[name] != expected:
raise ValueError('Зависимость изменилась между TU: ' + name)
dependencies[name] = expected
for name, expected in dependencies.items():
source = Path(name)
sha = digest(source)
if sha != expected:
raise ValueError('Зависимость изменилась после компиляции: ' + name)
snapshot = 'sources/' + sha + source.suffix
shutil.copyfile(source, work / snapshot)
inputs[name] = {'sha256': sha, 'snapshot': snapshot}
artifacts = {str(p.relative_to(work)): digest(p) for p in work.rglob('*') if p.is_file()}
write_json(work / 'manifest.json', {
'schema_version': 1, 'build_id': digest(exe), 'executable_sha256': digest(exe),
'executable': exe.name, 'layout': layout, 'units': units,
'sources': inputs, 'artifacts': artifacts,
'sdcc': subprocess.check_output([str(ROOT/'third_party/sdcc/bin/sdcc'), '--version'], text=True).strip(),
'limitations': ['Нет карты библиотек', 'Нет location ranges локальных',
'Только оффлайновая карта; образ в MAME не проверен'],
})
def run(wrapper, args):
if '-o' not in args or args.index('-o') + 1 >= len(args):
raise ValueError('Требуется -o FILE')
if '--src-debug' in args and '--src-debug-file' in args:
raise ValueError('--src-debug и --src-debug-file взаимоисключающие')
# Валидируем выбранные TU до запуска compiler. Значения иных опций не TU.
sources, selected = [], []
i = 0
valued = {'-o', '-I', '-L', '-E', '-S', '--code-loc', '--data-loc',
'--memory', '--memory-manual', '--stack-size', '-Wl', '--mkexe',
'--max-allocs', '--gfx'}
while i < len(args):
arg = args[i]
if arg in valued | {'--bank', '--w3', '--src-debug-file'}:
if i + 1 == len(args):
raise ValueError(arg + ' требует аргумент')
value = args[i+1]
if arg == '--bank': sources.append(Path(value.split('=', 1)[-1]).resolve())
if arg == '--w3': sources.append(Path(value).resolve())
if arg == '--src-debug-file': selected.append(Path(value).resolve())
i += 2
elif arg == '--bank-data' and i+1 < len(args) and args[i+1].isdigit():
i += 2
else:
if not arg.startswith('-'): sources.append(Path(arg).resolve())
i += 1
if any(p not in sources or not p.is_file() for p in selected):
raise ValueError('--src-debug-file должен указывать на входной TU')
output = Path(args[args.index('-o')+1]).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
final_work = output.parent / ('.sprinter-cc-' + output.stem)
locks = output.parent / '.resource-stamps'
locks.mkdir(exist_ok=True)
with (locks / (final_work.name + '.lock')).open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
with tempfile.TemporaryDirectory(prefix='.sdbg-build-', dir=output.parent) as temporary:
stage = Path(temporary)
new_exe = stage / output.name
command = list(args)
command[command.index('-o')+1] = str(new_exe)
env = dict(os.environ, SPRINTER_SDBG_ACTIVE='1', SPRINTER_PYTHON=sys.executable)
subprocess.run([str(Path(wrapper).resolve()), *command], env=env, check=True)
work = stage / final_work.name
manifest = json.loads((work/'manifest.json').read_text())
manifest['command'] = [str(Path(wrapper).resolve()), *args]
manifest['working_directory'] = str(Path.cwd())
manifest['selected_sources'] = [str(p) for p in selected or sources]
manifest['input_sources'] = [str(p) for p in sources]
manifest['executable_path'] = str(output)
identity = {'executable': manifest['executable_sha256'],
'sources': {p: v['sha256'] for p,v in manifest['sources'].items()},
'command': manifest['command'], 'sdcc': manifest['sdcc']}
manifest['build_id'] = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
write_json(work/'manifest.json', manifest)
# Проверяем пакет до публикации. Старые exe/work переживают любой
# сбой compiler/linker/парсера; manifest не создаётся из stale ihx.
from sdbg.model import DebugMap
model = DebugMap(work)
index = work/(output.stem+'.sdbg.json')
write_json(index, {'schema_version': 1, 'build_id': manifest['build_id'],
'functions': model.functions, 'variables': model.variables,
'instructions': list(model.instructions.values()),
'markers': model.markers, 'logpoints': model.logpoints})
manifest['artifacts'][index.name] = digest(index)
write_json(work/'manifest.json', manifest)
backup = stage/'previous-work'
if final_work.exists(): final_work.rename(backup)
try:
work.rename(final_work)
os.replace(new_exe, output)
except BaseException:
if final_work.exists(): shutil.rmtree(final_work)
if backup.exists(): backup.rename(final_work)
raise
print('sdbg: пакет ' + str(final_work))
if __name__ == '__main__':
try:
if sys.argv[1:2] == ['--metadata']: metadata(sys.argv[2:])
else: run(sys.argv[1], sys.argv[2:])
except subprocess.CalledProcessError as error:
print(f'sdbg: сборка не прошла (код {error.returncode}); предыдущий пакет сохранён',
file=sys.stderr)
sys.exit(1)
except (ValueError, OSError) as error:
print('sdbg: ' + str(error), file=sys.stderr)
sys.exit(1)
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Изолированный DSS→EXE→main launcher для DAP/session server."""
from __future__ import annotations
import argparse
from contextlib import redirect_stdout
import importlib.util
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import uuid
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'toolchain'))
from mame_interactive import basename_83, build_events
from sdbg.image import read_ihx
from sdbg.model import DebugMap
def disk_entries(exe: Path, data) -> list[tuple[str, str]]:
paths = [Path(exe), *(Path(name).resolve() for name in data)]
if any(not path.is_file() for path in paths):
missing = next(path for path in paths if not path.is_file())
raise ValueError('Не найден файл для debug-дискеты: ' + str(missing))
entries = [(basename_83(path.name), str(path)) for path in paths]
names = [name.replace(' ', '') for name, _ in entries]
if len(set(names)) != len(names):
raise ValueError('Коллизия имён 8.3 на debug-дискете')
clusters = sum((path.stat().st_size + 511) // 512 for path in paths)
if len(paths) > 224 or clusters > 2847:
raise ValueError('Файлы не помещаются на FAT12 debug-дискету')
return entries
def write_keyboard_config(cfg_directory: Path) -> None:
"""Включить обе клавиатуры Sprinter для физического ввода в DSS."""
(cfg_directory/'sprinter.cfg').write_text(
'<?xml version="1.0"?>\n'
'<mameconfig version="10">\n'
' <system name="sprinter">\n'
' <input>\n'
' <keyboard tag=":" enabled="1" />\n'
' <keyboard tag=":kbd:ms_naturl" enabled="1" />\n'
' </input>\n'
' </system>\n'
'</mameconfig>\n', encoding='utf-8')
def lua_script(path, ready, main_address, signature, events, launch_at,
dss_timeout=30):
rows = ',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}'
for t,tag,mask,value in events)
text = '''if _G.sdbg_launcher_loaded then return end
_G.sdbg_launcher_loaded=true
local machine=manager.machine
local cpu=machine.devices[":maincpu"]
local debug=machine.debugger
local events={EVENTS}
local signature={SIGNATURE}
local index=1
local phase="booting"
local active=true
local bp=nil
local false_hits=0
local launch_started=nil
local prompt_since=nil
local prompt_row=nil
local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end
local vram=nil
for tag,share in pairs(machine.memory.shares) do
if tag:find("vram",1,true) then vram=share;break end
end
local function dss_prompt_row()
if not vram then return nil end
local rgmod=cpu.state.RGMOD.value & 1
local function char_at(row,col)
return vram:read_u8((1+col+0x80*rgmod)*1024+0x300+row*4+1)
end
for row=0,31 do
local drive=char_at(row,0)
if ((drive>=65 and drive<=90) or (drive>=97 and drive<=122)) and
char_at(row,1)==58 then
for col=2,38 do
local value=char_at(row,col)
if value==62 then
local clean=true
for tail=col+1,math.min(col+8,79) do
local after=char_at(row,tail)
if after~=0 and after~=32 and after~=95 then clean=false;break end
end
if clean then return row end
elseif value<32 then
break
end
end
end
end
return nil
end
_G.sdbg_launcher_reset_subscription=emu.add_machine_reset_notifier(function()
if phase~="booting" then active=false end
end)
debug.execution_state="run"
emu.register_periodic(function()
if not active then return end
local current=now()
if phase=="booting" then
local row=nil
if current>=LAUNCH_NOT_BEFORE then row=dss_prompt_row() end
if row then
if prompt_row~=row then prompt_row=row;prompt_since=current end
if current-prompt_since>=0.25 then
machine.video:snapshot()
bp=cpu.debug:bpset(MAIN,"","")
launch_started=current
phase="loading"
end
else
prompt_row=nil;prompt_since=nil
end
end
while phase=="loading" and index<=#events and current>=launch_started+events[index][1] do
local e=events[index]
for _,field in pairs(machine.ioport.ports[e[2]].fields) do
if field.mask==e[3] then field:set_value(e[4]);break end
end
index=index+1
end
if phase=="loading" and debug.execution_state=="stop" then
local pc=cpu.state.PC.value
local match=pc==MAIN
for i,b in ipairs(signature) do
if cpu.spaces.program:read_u8(0x10000+MAIN+i-1)~=b then match=false end
end
if not match then false_hits=false_hits+1;debug.execution_state="run";return end
cpu.debug:bpclear(bp)
local file=assert(io.open(READY..".tmp","wb"))
file:write('{"pc":'..pc..',"false_hits":'..false_hits..
',"dss_ready_time":'..launch_started..',"prompt_row":'..prompt_row..'}')
file:close();assert(os.rename(READY..".tmp",READY))
phase="attached"
end
if phase=="booting" and current>DSS_TIMEOUT then
local file=io.open(READY..".error","wb")
if file then file:write("DSS prompt timeout");file:close() end
machine:exit()
elseif phase=="loading" and current>launch_started+30 then
local file=io.open(READY..".error","wb")
if file then file:write("main timeout");file:close() end
machine:exit()
end
end)
'''
text = (text.replace('EVENTS',rows).replace('SIGNATURE',','.join(map(str,signature)))
.replace('LAUNCH_NOT_BEFORE',str(launch_at)).replace('MAIN',str(main_address))
.replace('READY',json.dumps(str(ready))).replace('DSS_TIMEOUT',str(dss_timeout)))
path.write_text(text)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',required=True)
parser.add_argument('--socket',required=True)
parser.add_argument('--data',action='append',default=[])
parser.add_argument('--launch-at',type=float,default=0,
help='не начинать ввод раньше этой секунды эмуляции')
parser.add_argument('--dss-timeout',type=float,default=30,
help='таймаут появления стабильного prompt DSS')
parser.add_argument('--mame',default=str(ROOT/'mame/v306/mame.arm'))
parser.add_argument('--debugger',default='sdbg',
help='OSD debugger provider (по умолчанию sdbg из project patch)')
args=parser.parse_args()
model=DebugMap(args.build)
model.verify_executable()
exe=Path(model.manifest['executable_path'])
functions=[item for item in model.functions if item['name']=='main']
function=functions[0] if len(functions)==1 else None
if not function or function['bank'] is not None:
raise ValueError('Нужна единственная resident-функция main')
image=read_ihx(model.directory/(exe.stem+'.ihx'))
signature=bytes(image[address] for address in range(
function['start'],min(function['end'],function['start']+24)))
if not signature:
raise ValueError('У main нет проверяемой сигнатуры')
mame=Path(args.mame).resolve()
mame_dir=mame.parent
stopping=False
def stop(*_):
nonlocal stopping
stopping=True
signal.signal(signal.SIGTERM,stop)
signal.signal(signal.SIGINT,stop)
with tempfile.TemporaryDirectory(prefix='sprinter-sdbg-launch-') as temporary:
state=Path(temporary)
ipc=state/'ipc';ipc.mkdir()
for name in ('nvram','cfg','diff','snapshot'): (state/name).mkdir()
write_keyboard_config(state/'cfg')
system=state/'system.chd'
shutil.copyfile(mame_dir/'IMG/sp_hdd_sys.chd',system)
disk_path=state/'debug.img'
spec=importlib.util.spec_from_file_location('make_disk',mame_dir/'make_disk.py')
disk=importlib.util.module_from_spec(spec);spec.loader.exec_module(disk)
files=disk_entries(exe,args.data)
with redirect_stdout(sys.stderr):
if not disk.create_floppy_image(str(disk_path),files):
raise RuntimeError('Не удалось создать debug-дискету')
command_text='a:\\'+basename_83(exe.name).replace(' ','')+'\n'
events=build_events([(0,command_text)])
ready=state/'main.json'
lua=state/'launch.lua'
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
args.dss_timeout)
session_id=uuid.uuid4().hex
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id)
command=[str(mame),'sprinter','-noreadconfig','-rompath',str(mame_dir/'roms'),
'-bios','v3.06','-kbd','ms_naturl,bios=sp2k','-video','soft','-window',
'-sound','none','-skip_gameinfo','-beta:wd179x:0','35hd',
'-flop1',str(disk_path),'-hard1',str(system),'-debug','-debugger',args.debugger,
'-plugin','sdbgbridge','-pluginspath',str(ROOT/'toolchain/mcp')+';'+str(ROOT/'mame/sources/MAME/plugins'),
'-autoboot_delay','0','-autoboot_script',str(lua)]
for name in ('nvram','cfg','diff','snapshot'):
command.extend(['-'+name+'_directory',str(state/name)])
log=(state/'mame.log').open('w')
mame_process=subprocess.Popen(command,cwd=state,env=environment,
stdout=log,stderr=subprocess.STDOUT)
server=None
def mame_diagnostics():
log.flush()
output=(state/'mame.log').read_text(errors='replace')[-4000:].strip()
return ('\nMAME log:\n'+output) if output else ''
try:
deadline=time.monotonic()+args.dss_timeout+45
while not ready.exists():
if mame_process.poll() is not None:
raise RuntimeError('MAME завершился до main; лог: '+
str(state/'mame.log')+mame_diagnostics())
if (state/'main.json.error').exists() or time.monotonic()>deadline:
raise RuntimeError('Таймаут ожидания main; лог: '+
str(state/'mame.log')+mame_diagnostics())
time.sleep(.05)
entry=json.loads(ready.read_text())
server=subprocess.Popen([sys.executable,str(ROOT/'toolchain/sdbg_server.py'),
'--build',str(model.directory),'--ipc',str(ipc),'--session',session_id,
'--socket',args.socket],stdout=subprocess.PIPE,stderr=sys.stderr,text=True)
line=server.stdout.readline()
if not line:
raise RuntimeError('Session server не запустился')
server_ready=json.loads(line)
print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid,
'entry':entry,'build_id':server_ready['build_id']},
ensure_ascii=False),flush=True)
while not stopping and mame_process.poll() is None and server.poll() is None:
if os.getppid()==1: break
time.sleep(.1)
finally:
if server is not None and server.poll() is None:
server.terminate()
try: server.wait(timeout=3)
except subprocess.TimeoutExpired: server.kill();server.wait()
if mame_process.poll() is None:
mame_process.terminate()
try: mame_process.wait(timeout=5)
except subprocess.TimeoutExpired: mame_process.kill();mame_process.wait()
log.close()
return 0
if __name__=='__main__':
try:
sys.exit(main())
except (ValueError,OSError,RuntimeError,KeyError,subprocess.SubprocessError) as error:
print('sdbg-launcher: '+str(error),file=sys.stderr)
sys.exit(1)
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Постоянная sdbg-сессия: единственный владелец MAME для CLI/MCP/DAP."""
import argparse
import json
import signal
import sys
import threading
from sdbg.model import DebugMap
from sdbg.server import SessionController, SessionRpcServer
from sdbg.session import DebugSession, SessionError
from sdbg.transport import BridgeError, FileBridge
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', required=True)
parser.add_argument('--ipc', required=True)
parser.add_argument('--session', required=True)
parser.add_argument('--socket', required=True)
parser.add_argument('--timeout', type=float, default=5)
args = parser.parse_args()
bridge = controller = server = None
try:
bridge = FileBridge(args.ipc, args.session, args.timeout)
controller = SessionController(DebugSession(DebugMap(args.build), bridge))
server = SessionRpcServer(args.socket, controller)
stop = lambda *_: threading.Thread(target=server.server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(json.dumps({'ready': True, 'socket': args.socket,
'build_id': controller.attached['build_id']}, ensure_ascii=False), flush=True)
server.serve_forever()
return 0
except (BridgeError, SessionError, ValueError, OSError, KeyError) as error:
print('sdbg-server: ' + str(error), file=sys.stderr)
return 1
finally:
if server is not None:
server.close()
if controller is not None:
controller.close()
elif bridge is not None:
bridge.close()
if __name__ == '__main__':
sys.exit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Одна проверенная live-операция source debugger через sdbgbridge."""
import argparse
import json
import sys
from sdbg.model import DebugMap
from sdbg.session import DebugSession, SessionError
from sdbg.transport import BridgeError, FileBridge
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', required=True, help='каталог .sprinter-cc-NAME')
parser.add_argument('--ipc', required=True, help='SDBG_IPC_DIR процесса MAME')
parser.add_argument('--session', required=True, help='SDBG_SESSION_ID процесса MAME')
parser.add_argument('--timeout', type=float, default=5)
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('attach')
commands.add_parser('where')
step = commands.add_parser('step')
step.add_argument('--wait', type=float, default=5)
commands.add_parser('continue')
commands.add_parser('activate-breakpoints')
commands.add_parser('deactivate-breakpoints')
line = commands.add_parser('break-line')
line.add_argument('file')
line.add_argument('line', type=int)
line.add_argument('--disabled', action='store_true')
function = commands.add_parser('break-function')
function.add_argument('name')
function.add_argument('--disabled', action='store_true')
variable = commands.add_parser('read-variable')
variable.add_argument('name')
variable.add_argument('--module')
args = parser.parse_args()
bridge = None
try:
bridge = FileBridge(args.ipc, args.session, args.timeout)
session = DebugSession(DebugMap(args.build), bridge)
attached = session.attach()
if args.command == 'attach': result = attached
elif args.command == 'where': result = session.where()
elif args.command == 'break-line':
result = session.break_line(args.file, args.line, not args.disabled)
elif args.command == 'break-function':
result = session.break_function(args.name, not args.disabled)
elif args.command == 'activate-breakpoints': result = session.activate_breakpoints()
elif args.command == 'deactivate-breakpoints': result = session.deactivate_breakpoints()
elif args.command == 'read-variable': result = session.read_variable(args.name, args.module)
elif args.command == 'step':
bridge.request('step')
result = session.where(bridge.wait_stopped(args.wait))
else:
result = bridge.request('continue')
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (BridgeError, SessionError, ValueError, OSError, KeyError) as error:
print('sdbg-session: ' + str(error), file=sys.stderr)
return 1
finally:
if bridge is not None:
bridge.close()
if __name__ == '__main__':
sys.exit(main())
+81
View File
@@ -0,0 +1,81 @@
# Sprinter MAME Debug
VS Code-клиент сборки и отладки для `toolchain/sdbg_dap.py`. Режим `launch` сам
поднимает изолированные MAME и session server; перед `attach` их запускают
вручную.
> [!WARNING]
> Native Windows пока не поддерживает полный launch/attach: `windows`
> выбирает только debugger provider MAME, а host-часть всё ещё зависит от
> `fcntl`, Unix domain sockets и Unix launcher.
Для разработки расширение можно открыть отдельным окном VS Code и запустить
Extension Development Host. Конфигурация проекта:
```sh
code --extensionDevelopmentPath="$PWD/toolchain/vscode-sprinter-debug" "$PWD"
```
Python выбирается без зависимости от `PATH` GUI: при наличии local
`.python-version` используется `~/.pyenv/shims/python`; для внешнего workspace
без неё расширение ищет установленный `~/.pyenv/versions/3.12*/bin/python`.
Версия local передаётся задаче через `PYENV_VERSION`, поэтому сборка работает
и если `project` лежит вне workspace. При необходимости задайте абсолютный путь через
`sprinterDebugger.pythonCommand`. Выбранные пути печатаются в канале Output
`Sprinter MAME Debug`.
```json
{
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "${workspaceFolder}/tests/hello/.sprinter-cc-hello"
}
```
Перед F5 расширение по умолчанию находит Makefile проекта по пути `build` и
выполняет задачу `make SRC_DEBUG=1` в каталоге проекта через local Python 3.12.
Ошибки SDCC с файлом и строкой попадают в Problems. При ненулевом коде
сборки debug launch отменяется до старта MAME. Для нестандартной раскладки
укажите `"project": "${workspaceFolder}/path/to/app"`; для уже собранного
пакета можно задать `"autoBuild": false`. Явный `preLaunchTask` остаётся под
контролем стандартного механизма VS Code и отключает автоматическую задачу
расширения.
Команда палитры `Sprinter: Build Active Project` строит проект открытого
C-файла. В `Tasks: Run Task` доступны задачи `Sprinter: Build ...` для
Makefile, включающих `app.mk`. Сборка не заменяет исходный `make` и не пишет
в общий образ дискеты MAME.
По умолчанию patched backend `sdbg` не открывает отдельное окно debugger MAME.
Чтобы пользоваться им одновременно с VS Code, добавьте в launch:
```json
"debugger": "osx"
```
Без project patch можно задать `auto`; явные native provider: `osx` на macOS,
`windows` в Windows, `qt` или `imgui` в Linux. Host-инструменты sdbg сейчас
рассчитаны на macOS/Linux (`fcntl`, Unix sockets и Unix launcher); native
Windows transport и end-to-end тесты ещё требуются.
Готовность DSS определяется по стабильному prompt в VRAM. `dssTimeout`
задаёт таймаут (по умолчанию 30 секунд), а `launchAt` — необязательное самое
раннее время ввода команды.
Ручной attach:
```json
{
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "/tmp/sprinter-sdbg.sock"
}
```
Сейчас доступны точки по исходнику/функции, `logMessage` с безопасными
подстановками `{variable}`, один проверенный frame, регистры, поддержанные
global/static, continue/pause и instruction step. Source step поддержан для
F10/F11/Shift+F11; банковский step-over и step-out проходят
служебные trampoline до следующей C-позиции.
+53
View File
@@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
function absolutePath(value, workspace) {
if (!value || !workspace) return null;
const expanded = value.replace(/\$\{workspaceFolder\}/g, workspace);
return path.resolve(workspace, expanded);
}
function projectForLaunch(configuration, workspace) {
const explicit = absolutePath(configuration.project, workspace);
if (explicit) {
if (!fs.existsSync(path.join(explicit, 'Makefile'))) {
throw new Error(`Нет Makefile в каталоге проекта: ${explicit}`);
}
return explicit;
}
const build = absolutePath(configuration.build, workspace);
if (!build) return null;
let directory = path.dirname(build);
while (directory !== path.dirname(directory)) {
if (fs.existsSync(path.join(directory, 'Makefile'))) {
return directory;
}
if (directory === workspace) break;
directory = path.dirname(directory);
}
return null;
}
function isSprinterMakefile(filename) {
try {
return /include\s+\$\(PROJ_ROOT\)\/app\.mk/.test(
fs.readFileSync(filename, 'utf8'));
} catch (_) {
return false;
}
}
function taskLabel(project, workspace) {
const relative = path.relative(workspace, project);
return `Sprinter: Build ${relative || '.'}`;
}
function makeCommand() {
if (process.platform === 'win32') {
throw new Error('Native Windows build/debug пока не поддерживается');
}
return fs.existsSync('/usr/bin/make') ? '/usr/bin/make' : 'make';
}
module.exports = {absolutePath, projectForLaunch, isSprinterMakefile,
taskLabel, makeCommand};
@@ -0,0 +1,40 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {projectForLaunch, isSprinterMakefile, taskLabel} =
require('./build');
const manifest = require('./package.json');
const workspace = path.resolve(__dirname, '..', '..');
const hello = path.join(workspace, 'tests', 'hello');
test('launch находит Makefile рядом с debug-пакетом', () => {
assert.equal(projectForLaunch({
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
}, workspace), hello);
assert.equal(taskLabel(hello, workspace), 'Sprinter: Build tests/hello');
assert.equal(isSprinterMakefile(path.join(hello, 'Makefile')), true);
});
test('пакет в build/ находит Makefile приложения уровнем выше', () => {
const project = path.join(workspace, 'applications', 'SprPoP');
assert.equal(projectForLaunch({
build: '${workspaceFolder}/applications/SprPoP/build/.sprinter-cc-sprpop',
}, workspace), project);
});
test('явный project без Makefile даёт ошибку вместо stale запуска', () => {
assert.throws(() => projectForLaunch({
project: 'tests/sdbg/fixtures', build: 'tests/hello/.sprinter-cc-hello',
}, workspace), /Нет Makefile/);
});
test('matcher разрешает относительную ошибку SDCC внутри проекта', () => {
const matcher = manifest.contributes.problemMatchers[0];
const line = 'hello.c:62: error 20: Undefined identifier \'missing_name\'';
const match = new RegExp(matcher.pattern.regexp).exec(line);
assert.deepEqual(match?.slice(1),
['hello.c', '62', 'error', '20',
"Undefined identifier 'missing_name'"]);
assert.equal(matcher.fileLocation, 'relative');
});
@@ -0,0 +1,195 @@
const path = require('path');
const vscode = require('vscode');
const {resolvePython, pyenvEnvironment} = require('./runtime');
const {projectForLaunch, isSprinterMakefile, taskLabel, makeCommand} =
require('./build');
class SprinterAdapterFactory {
constructor(output) {
this.output = output;
}
createDebugAdapterDescriptor(session) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const folder = session.workspaceFolder || vscode.workspace.workspaceFolders?.[0];
const configured = session.configuration.adapterPath;
if (!configured && !folder) {
throw new Error('Откройте workspace C-Compiler или задайте adapterPath');
}
const adapter = configured || path.join(folder.uri.fsPath, 'toolchain', 'sdbg_dap.py');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder?.uri.fsPath,
});
const cwd = folder?.uri.fsPath || path.dirname(adapter);
this.output.appendLine(`Python: ${runtime.command}`);
this.output.appendLine(`DAP: ${adapter}`);
const options = {cwd};
const env = pyenvEnvironment(runtime.command, cwd);
if (env) options.env = env;
return new vscode.DebugAdapterExecutable(
runtime.command, [...runtime.args, adapter], options);
}
}
class SprinterTaskProvider {
createTask(folder, project, definitionOverride) {
const settings = vscode.workspace.getConfiguration('sprinterDebugger');
const runtime = resolvePython({
command: settings.get('pythonCommand', 'auto'),
args: settings.get('pythonArguments', []),
workspace: folder.uri.fsPath,
});
const relative = path.relative(folder.uri.fsPath, project);
const definition = definitionOverride ||
{type: 'sprinter', project: relative || '.'};
const options = {cwd: project};
const env = pyenvEnvironment(runtime.command, project) ||
pyenvEnvironment(runtime.command, folder.uri.fsPath);
if (env) options.env = env;
const task = new vscode.Task(
definition, folder, taskLabel(project, folder.uri.fsPath), 'sprinter',
new vscode.ProcessExecution(makeCommand(), [
'SRC_DEBUG=1', `PYTHON=${runtime.command}`,
], options), ['$sprinter-sdcc']);
task.group = vscode.TaskGroup.Build;
return task;
}
async provideTasks() {
const folders = vscode.workspace.workspaceFolders || [];
const results = [];
for (const folder of folders) {
const files = await vscode.workspace.findFiles(
new vscode.RelativePattern(folder, '**/Makefile'),
'**/{third_party,mame,libc,libbgi,toolchain}/**');
for (const file of files) {
if (isSprinterMakefile(file.fsPath)) {
results.push(this.createTask(folder, path.dirname(file.fsPath)));
}
}
}
return results;
}
resolveTask(task) {
const folder = task.scope?.uri ? task.scope :
vscode.workspace.workspaceFolders?.[0];
const project = task.definition.project;
if (!folder || typeof project !== 'string') return undefined;
const directory = path.resolve(folder.uri.fsPath, project);
if (!isSprinterMakefile(path.join(directory, 'Makefile'))) return undefined;
return this.createTask(folder, directory, task.definition);
}
}
async function runBuildTask(task) {
const early = [], ended = [];
let execution, resolveResult, completed = false;
const result = new Promise(resolve => {resolveResult = resolve;});
function finish(code) {
if (!completed) {
completed = true;
resolveResult(code);
}
}
const processListener = vscode.tasks.onDidEndTaskProcess(event => {
early.push(event);
if (execution && event.execution === execution) finish(event.exitCode);
});
const endListener = vscode.tasks.onDidEndTask(event => {
ended.push(event);
if (execution && event.execution === execution) {
// При отмене задачи process exit event может не появиться.
setTimeout(() => finish(undefined), 100);
}
});
try {
execution = await vscode.tasks.executeTask(task);
const finished = early.find(event => event.execution === execution);
if (finished) finish(finished.exitCode);
if (ended.some(event => event.execution === execution)) {
setTimeout(() => finish(undefined), 100);
}
return await result;
} finally {
processListener.dispose();
endListener.dispose();
}
}
class SprinterConfigurationProvider {
constructor(tasks, output) {
this.tasks = tasks;
this.output = output;
}
async resolveDebugConfiguration(folder, configuration) {
if (configuration.type !== 'sprinter-mame' ||
configuration.request !== 'launch' ||
configuration.autoBuild === false || configuration.preLaunchTask) {
return configuration;
}
const workspace = folder?.uri.fsPath ||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspace) return configuration;
try {
const project = projectForLaunch(configuration, workspace);
if (project && isSprinterMakefile(path.join(project, 'Makefile'))) {
const scope = folder || vscode.workspace.workspaceFolders[0];
const task = this.tasks.createTask(scope, project);
this.output.appendLine(`Build: ${project}`);
const code = await runBuildTask(task);
if (code !== 0) {
vscode.window.showErrorMessage(
`Сборка Sprinter не прошла (код ${code ?? 'отмена'}); MAME не запущен`);
return undefined;
}
}
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
return undefined;
}
return configuration;
}
}
function activate(context) {
const output = vscode.window.createOutputChannel('Sprinter MAME Debug');
context.subscriptions.push(output);
context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory(
'sprinter-mame', new SprinterAdapterFactory(output)));
const tasks = new SprinterTaskProvider();
context.subscriptions.push(vscode.tasks.registerTaskProvider('sprinter', tasks));
context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(
'sprinter-mame', new SprinterConfigurationProvider(tasks, output)));
context.subscriptions.push(vscode.commands.registerCommand('sprinter.buildActive', async () => {
const editor = vscode.window.activeTextEditor;
const folder = editor && vscode.workspace.getWorkspaceFolder(editor.document.uri);
if (!editor || !folder) {
vscode.window.showErrorMessage('Откройте C-файл проекта Sprinter');
return;
}
let directory = path.dirname(editor.document.uri.fsPath);
const root = folder.uri.fsPath;
while (directory !== path.dirname(directory)) {
if (isSprinterMakefile(path.join(directory, 'Makefile'))) {
try {
await vscode.tasks.executeTask(tasks.createTask(folder, directory));
} catch (error) {
vscode.window.showErrorMessage(`Sprinter Build: ${error.message}`);
}
return;
}
if (directory === root) break;
directory = path.dirname(directory);
}
vscode.window.showErrorMessage('Не найден проект Sprinter с app.mk');
}));
}
function deactivate() {}
module.exports = {activate, deactivate, SprinterTaskProvider,
SprinterConfigurationProvider, runBuildTask};
@@ -0,0 +1,134 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
test('F5 выполняет TaskProvider build и не запускает MAME после ошибки', async () => {
let factory, configurationProvider, taskProvider;
let onProcess, onEnd, exitCode = 0;
const errors = [];
const output = {appendLine() {}, dispose() {}};
class DebugAdapterExecutable {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class ProcessExecution {
constructor(command, args, options) {
Object.assign(this, {command, args, options});
}
}
class Task {
constructor(definition, scope, name, source, execution, problemMatchers) {
Object.assign(this, {definition, scope, name, source,
execution, problemMatchers});
}
}
class RelativePattern {
constructor(folder, pattern) {Object.assign(this, {folder, pattern});}
}
const vscode = {
workspace: {
workspaceFolders: [],
getConfiguration: () => ({get: (key, fallback) => fallback}),
findFiles: async () => [],
},
window: {createOutputChannel: () => output,
showErrorMessage: message => errors.push(message)},
debug: {
registerDebugAdapterDescriptorFactory: (type, value) => {
assert.equal(type, 'sprinter-mame');
factory = value;
return {dispose() {}};
},
registerDebugConfigurationProvider: (type, value) => {
assert.equal(type, 'sprinter-mame');
configurationProvider = value;
return {dispose() {}};
},
},
tasks: {
registerTaskProvider: (type, value) => {
assert.equal(type, 'sprinter');
taskProvider = value;
return {dispose() {}};
},
onDidEndTaskProcess: listener => {
onProcess = listener;
return {dispose() {onProcess = null;}};
},
onDidEndTask: listener => {
onEnd = listener;
return {dispose() {onEnd = null;}};
},
executeTask: async task => {
const execution = {task};
onProcess({execution, exitCode});
onEnd({execution});
return execution;
},
},
commands: {registerCommand: (name) => {
assert.equal(name, 'sprinter.buildActive');
return {dispose() {}};
}},
DebugAdapterExecutable,
ProcessExecution, Task, RelativePattern,
TaskGroup: {Build: 'build'},
};
const source = fs.readFileSync(path.join(__dirname, 'extension.js'), 'utf8');
const module = {exports: {}};
vm.runInNewContext(source, {
require: name => name === 'vscode' ? vscode : require(name),
module, setTimeout,
}, {filename: 'extension.js'});
const context = {subscriptions: []};
module.exports.activate(context);
assert.equal(context.subscriptions.length, 5);
const workspace = path.resolve(__dirname, '..', '..');
const session = {
workspaceFolder: {uri: {fsPath: workspace}},
configuration: {},
};
const descriptor = factory.createDebugAdapterDescriptor(session);
assert.equal(descriptor.command,
path.join(require('node:os').homedir(), '.pyenv', 'shims', 'python'));
assert.equal(descriptor.args.at(-1),
path.join(workspace, 'toolchain', 'sdbg_dap.py'));
assert.equal(descriptor.options.cwd, workspace);
assert.equal(descriptor.options.env.PYENV_VERSION, '3.12');
const configuration = {
type: 'sprinter-mame', request: 'launch',
build: '${workspaceFolder}/tests/hello/.sprinter-cc-hello',
};
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, configuration), configuration, errors.join('\n'));
const task = taskProvider.createTask(session.workspaceFolder,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.name, 'Sprinter: Build tests/hello');
assert.equal(task.execution.command, '/usr/bin/make');
assert.equal(task.execution.options.cwd,
path.join(workspace, 'tests', 'hello'));
assert.equal(task.execution.options.env.PYENV_VERSION, '3.12');
assert.deepEqual(Array.from(task.execution.args),
['SRC_DEBUG=1', `PYTHON=${descriptor.command}`]);
assert.equal(task.problemMatchers[0], '$sprinter-sdcc');
vscode.workspace.workspaceFolders = [session.workspaceFolder];
vscode.workspace.findFiles = async () => [
{fsPath: path.join(workspace, 'tests', 'hello', 'Makefile')},
{fsPath: path.join(workspace, 'Makefile')},
];
const discovered = await taskProvider.provideTasks();
assert.equal(discovered.length, 1);
assert.equal(discovered[0].name, task.name);
const unresolved = {scope: session.workspaceFolder,
definition: {type: 'sprinter', project: 'tests/hello'}};
assert.equal(taskProvider.resolveTask(unresolved).definition,
unresolved.definition);
exitCode = 1;
assert.equal(await configurationProvider.resolveDebugConfiguration(
session.workspaceFolder, {...configuration}), undefined);
assert.match(errors.at(-1), /MAME не запущен/);
});
@@ -0,0 +1,170 @@
{
"name": "sprinter-mame-debug",
"displayName": "Sprinter MAME Debug",
"description": "Сборка и DAP-отладка C-приложений Sprinter в MAME",
"version": "0.2.0",
"publisher": "sprinter-c-compiler",
"engines": {"vscode": "^1.85.0"},
"categories": ["Debuggers"],
"main": "./extension.js",
"activationEvents": ["onDebug", "onTaskType:sprinter", "onCommand:sprinter.buildActive"],
"contributes": {
"commands": [
{"command": "sprinter.buildActive", "title": "Sprinter: Build Active Project"}
],
"taskDefinitions": [
{
"type": "sprinter",
"required": ["project"],
"properties": {
"project": {
"type": "string",
"description": "Путь к каталогу с Makefile приложения относительно workspace"
}
}
}
],
"problemMatchers": [
{
"name": "sprinter-sdcc",
"owner": "sprinter-sdcc",
"fileLocation": "relative",
"pattern": {
"regexp": "^(.+?):(\\d+):\\s+(error|warning)\\s+(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"severity": 3,
"code": 4,
"message": 5
}
}
],
"configuration": {
"title": "Sprinter MAME Debug",
"properties": {
"sprinterDebugger.pythonCommand": {
"type": "string",
"default": "auto",
"description": "Python 3.12 для DAP; auto выбирает local pyenv shim без зависимости от PATH GUI"
},
"sprinterDebugger.pythonArguments": {
"type": "array",
"items": {"type": "string"},
"default": [],
"description": "Аргументы пользовательской Python-команды перед путём DAP-адаптера"
}
}
},
"debuggers": [
{
"type": "sprinter-mame",
"label": "Sprinter MAME",
"languages": ["c"],
"configurationAttributes": {
"launch": {
"required": ["build"],
"properties": {
"build": {
"type": "string",
"description": "Путь к каталогу .sprinter-cc-NAME"
},
"project": {
"type": "string",
"description": "Каталог с Makefile для сборки перед запуском; определяется из build, если не задан"
},
"autoBuild": {
"type": "boolean",
"default": true,
"description": "Добавить Sprinter build task перед F5, когда найден app.mk"
},
"data": {
"type": "array",
"items": {"type": "string"},
"description": "Дополнительные файлы на debug-дискету"
},
"mame": {
"type": "string",
"description": "Путь к mame.arm"
},
"debugger": {
"type": "string",
"default": "sdbg",
"enum": ["sdbg", "auto", "osx", "windows", "qt", "imgui"],
"enumDescriptions": [
"Только окно Sprinter и управление из VS Code",
"Выбрать доступный штатный backend MAME",
"VS Code вместе с Cocoa debugger MAME на macOS",
"VS Code вместе с native debugger MAME на Windows",
"Qt debugger MAME, если сборка включает USE_QTDEBUG",
"Debugger MAME внутри основного графического окна"
],
"description": "OSD debugger provider; sdbg не открывает Cocoa debugger"
},
"launchAt": {
"type": "number",
"default": 0,
"description": "Не начинать ввод раньше этой секунды эмуляции"
},
"dssTimeout": {
"type": "number",
"default": 30,
"description": "Таймаут появления стабильного prompt DSS"
}
}
},
"attach": {
"required": ["socket"],
"properties": {
"socket": {
"type": "string",
"description": "Unix socket запущенного sdbg_server.py"
},
"adapterPath": {
"type": "string",
"description": "Путь к toolchain/sdbg_dap.py; по умолчанию из workspace"
}
}
}
},
"configurationSnippets": [
{
"label": "Sprinter MAME: Launch",
"description": "Запустить изолированный MAME и остановиться на main",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\""
}
},
{
"label": "Sprinter MAME: Launch + native debugger",
"description": "Запустить VS Code debugger вместе с Cocoa debugger MAME",
"body": {
"type": "sprinter-mame",
"request": "launch",
"name": "Sprinter MAME: Launch + native debugger",
"build": "^\"${workspaceFolder}/tests/hello/.sprinter-cc-hello\"",
"debugger": "osx"
}
},
{
"label": "Sprinter MAME: Attach",
"description": "Подключиться к проверенной sdbg-сессии",
"body": {
"type": "sprinter-mame",
"request": "attach",
"name": "Sprinter MAME: Attach",
"socket": "^\"/tmp/sprinter-sdbg.sock\""
}
}
]
}
]
},
"scripts": {
"check": "node --check extension.js && node --check build.js && node --test runtime.test.js build.test.js extension.test.js"
},
"files": ["extension.js", "runtime.js", "build.js", "README.md"],
"license": "BSD-3-Clause"
}
@@ -0,0 +1,84 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
function executable(filename) {
try {
fs.accessSync(filename, fs.constants.X_OK);
return true;
} catch (_) {
return false;
}
}
function localPythonVersion(workspace) {
if (!workspace) return null;
let directory = path.resolve(workspace);
while (directory !== path.dirname(directory)) {
try {
const version = fs.readFileSync(path.join(directory, '.python-version'),
'utf8').trim().split(/\s+/)[0];
return /^[A-Za-z0-9._-]+$/.test(version) ? version : null;
} catch (_) {}
directory = path.dirname(directory);
}
return null;
}
function installedPyenvPythons(home) {
try {
const versions = fs.readdirSync(path.join(home, '.pyenv', 'versions'));
return versions.filter(version => /^3\.12(?:\.\d+)?$/.test(version))
.sort((a, b) => Number(b.split('.')[2] || 0) -
Number(a.split('.')[2] || 0))
.map(version => path.join(home, '.pyenv', 'versions', version,
'bin', 'python'));
} catch (_) {
return [];
}
}
function resolvePython(options = {}) {
const command = String(options.command || 'auto').trim();
const args = Array.isArray(options.args) ? options.args : [];
if (command !== 'auto') {
return {command, args};
}
const home = options.home || os.homedir();
const workspace = options.workspace;
const isExecutable = options.isExecutable || executable;
const candidates = [];
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = options.localVersion === undefined ?
localPythonVersion(workspace) : options.localVersion;
const installed = options.installedPyenv === undefined ?
installedPyenvPythons(home) : options.installedPyenv;
if (version) candidates.push(shim);
candidates.push(...installed);
if (!version) candidates.push(shim);
if (workspace) {
candidates.push(path.join(workspace, '.venv', 'bin', 'python'));
}
candidates.push(
'/opt/homebrew/bin/python3.12',
'/usr/local/bin/python3.12',
'/usr/bin/python3.12',
);
const found = candidates.find(isExecutable);
if (!found) {
throw new Error(
'Не найден Python 3.12. Задайте абсолютный путь в ' +
'sprinterDebugger.pythonCommand.');
}
return {command: found, args: []};
}
function pyenvEnvironment(command, workspace, home = os.homedir()) {
const shim = path.join(home, '.pyenv', 'shims', 'python');
const version = command === shim ? localPythonVersion(workspace) : null;
return version ? {PYENV_VERSION: version} : undefined;
}
module.exports = {resolvePython, localPythonVersion, installedPyenvPythons,
pyenvEnvironment};
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {resolvePython, localPythonVersion, pyenvEnvironment} = require('./runtime');
test('auto использует pyenv shim без PATH GUI', () => {
const home = path.join(path.sep, 'Users', 'tester');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const result = resolvePython({
command: 'auto',
workspace: path.join(home, 'project'),
home,
isExecutable: filename => filename === shim,
});
assert.deepEqual(result, {command: shim, args: []});
});
test('local pyenv имеет приоритет над случайным .venv', () => {
const home = path.join(path.sep, 'Users', 'tester');
const workspace = path.join(home, 'project');
const shim = path.join(home, '.pyenv', 'shims', 'python');
const venv = path.join(workspace, '.venv', 'bin', 'python');
const result = resolvePython({
command: 'auto', workspace, home,
isExecutable: filename => filename === shim || filename === venv,
});
assert.equal(result.command, shim);
});
test('явная команда и аргументы сохраняются', () => {
const result = resolvePython({
command: '/python/custom',
args: ['-I'],
isExecutable: () => false,
});
assert.deepEqual(result, {command: '/python/custom', args: ['-I']});
});
test('auto сообщает понятную ошибку без Python 3.12', () => {
assert.throws(
() => resolvePython({command: 'auto', isExecutable: () => false}),
/Задайте абсолютный путь/,
);
});
test('внешний workspace без .python-version выбирает установленный pyenv 3.12', () => {
const home = path.join(path.sep, 'Users', 'tester');
const installed = path.join(home, '.pyenv', 'versions', '3.12.14',
'bin', 'python');
const result = resolvePython({
command: 'auto', workspace: '/tmp/external-project', home,
localVersion: null, installedPyenv: [installed],
isExecutable: filename => filename === installed ||
filename === path.join(home, '.pyenv', 'shims', 'python'),
});
assert.equal(result.command, installed);
});
test('shim получает версию workspace при сборке за пределами его дерева', () => {
const workspace = path.resolve(__dirname, '..', '..');
const version = localPythonVersion(workspace);
assert.match(version, /^3\.12/);
const shim = path.join(require('node:os').homedir(), '.pyenv',
'shims', 'python');
assert.deepEqual(pyenvEnvironment(shim, workspace),
{PYENV_VERSION: version});
});