369 lines
17 KiB
Lua
369 lines
17 KiB
Lua
-- 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
|
||
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=="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
|