-- 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.tag0 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=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