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