-- 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 -- Удаление точки безопасно и при 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