Files
Sprinter-SDCC/toolchain/mcp/sdbgbridge/init.lua
T
2026-09-17 23:32:46 +03:00

372 lines
17 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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 snapdir = os.getenv("SDBG_SNAP_DIR")
local snapshot_sequence = 0
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 function first_screen()
for _,screen in pairs(machine().screens) do return screen end
error("Экран MAME не найден")
end
local function share_info()
local result={shares={},regions={}}
for tag,share in pairs(machine().memory.shares) do
result.shares[#result.shares+1]={tag=tag,size=share.size}
end
for tag,region in pairs(machine().memory.regions) do
result.regions[#result.regions+1]={tag=tag,size=region.size}
end
table.sort(result.shares,function(a,b) return a.tag<b.tag end)
table.sort(result.regions,function(a,b) return a.tag<b.tag end)
return result
end
local function read_share(tag,address,length)
assert(type(tag)=="string" and #tag>0 and #tag<=128,"Нужен точный tag share")
local share=machine().memory.shares[tag]
assert(share,"Share не найден: "..tag)
local size=number(share.size,0x10000000)
address=number(address,size)
length=number(length,4096)
assert(length>=1 and address+length<=size,"Чтение за пределами share")
local bytes={}
for offset=0,length-1 do
bytes[#bytes+1]=string.format("%02x",share:read_u8(address+offset))
end
return {tag=tag,address=address,length=length,size=size,hex=table.concat(bytes),
generation=generation}
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,
shares=true,screen_pixels=true,screen_snapshot=snapdir~=nil,
program_memory=true,list_ports=true,disassemble_logical=true},state=state}
elseif command=="snapshot" then
local result={state=state,time=now(),paused=machine().paused}
local screen=first_screen()
result.frame=screen:frame_number()
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
local count=number(args.count or 1,64)
assert(count>=1,"step count должен быть 1..64")
pending={kind="step",time=now()}
cpu().debug:step(count)
elseif command=="step_over" then
local count=number(args.count or 1,64)
assert(count>=1,"over count должен быть 1..64")
pending={kind="step_over",time=now()}
-- В консоли MAME числа без префикса шестнадцатеричные.
machine().debugger:command("over #"..count)
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=="program_memory" then
stopped(request)
local address=number(args.address,0x3ffff)
local length=number(args.length,4096)
assert(length>=1 and address+length<=0x40000,
"Чтение за пределами raw program space")
local symbols=emu.symbol_table(cpu())
local bytes={}
for offset=0,length-1 do
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(
":maincpu","p",address+offset,1,true))
end
return {space="program",address=address,length=length,
hex=table.concat(bytes),generation=generation}
elseif command=="disassemble_logical" then
stopped(request)
local address=number(args.address,0xffff)
local length=number(args.length,256)
assert(length>=1 and address+length<=0x10000,
"Дизассемблирование за пределами logical Z80")
local path=directory.."/_disasm.txt"
os.remove(path) -- Не возвращать файл от прежней команды при ошибке MAME.
machine().debugger:command(string.format("dasm %s,0x%X,#%d",path,address,length))
local size=lfs.attributes(path,"size")
assert(size and size>0 and size<=65536,"MAME не создал ограниченный disasm-файл")
local file=assert(io.open(path,"rb"))
local output=file:read("*a")
file:close()
os.remove(path)
return {space="logical_z80",address=address,length=length,
text=output,generation=generation}
elseif command=="list_ports" then
local result={ports={},truncated=false}
local fields_seen=0
for tag,port in pairs(machine().ioport.ports) do
if #result.ports>=512 then result.truncated=true;break end
local item={tag=tag,fields={}}
for name,field in pairs(port.fields) do
if fields_seen>=4096 then result.truncated=true;break end
item.fields[#item.fields+1]={name=tostring(name),mask=field.mask}
fields_seen=fields_seen+1
end
table.sort(item.fields,function(a,b) return a.mask<b.mask end)
result.ports[#result.ports+1]=item
end
table.sort(result.ports,function(a,b) return a.tag<b.tag end)
return result
elseif command=="list_shares" then
return share_info()
elseif command=="read_share" then
return read_share(args.tag,args.address,args.length)
elseif command=="read_vram" then
local matches={}
for tag in pairs(machine().memory.shares) do
if tag:find("vram",1,true) then matches[#matches+1]=tag end
end
assert(#matches==1,"Нужен ровно один VRAM share; используйте list_shares")
return read_share(matches[1],args.address,args.length)
elseif command=="screen_pixels" then
local x=number(args.x,4095)
local y=number(args.y,4095)
local width=number(args.width,512)
local height=number(args.height,512)
assert(width>=1 and height>=1 and width*height<=8192,
"Прямоугольник должен содержать 1..8192 пикселей")
local screen=first_screen()
local values={}
for dy=0,height-1 do
for dx=0,width-1 do
values[#values+1]=string.format("%04X",screen:pixel(x+dx,y+dy)&0xffff)
end
end
return {x=x,y=y,width=width,height=height,format="pen16be-hex",
hex=table.concat(values),frame=screen:frame_number(),
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
elseif command=="screen_snapshot" then
assert(snapdir and #snapdir>0,"Каталог снимков не настроен")
snapshot_sequence=snapshot_sequence+1
local screen=first_screen()
local path=string.format("%s/sdbg_%06d.png",snapdir,snapshot_sequence)
local err=screen:snapshot(path)
assert(err==nil,"Снимок не создан: "..tostring(err))
local size=lfs.attributes(path,"size")
assert(size and size>0 and size<=8388608,"Неверный размер снимка")
return {path=path,size=size,format="png",frame=screen:frame_number(),
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
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
-- Удаление точки безопасно и при running: callback исполняется на
-- потоке MAME, а MCP должен очищать свои точки при закрытии stdio.
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