483 lines
24 KiB
Python
483 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Изолированный MAME-репро: загрузка EXE, проверка main и async step.
|
|
|
|
Использует копию системного HDD и собственную дискету/каталоги состояния.
|
|
Ничего не меняет в общих media или уже запущенном MAME.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
|
|
# Запуск через системный python3 тоже подхватывает local pyenv проекта.
|
|
if sys.version_info < (3, 12):
|
|
os.execvp('pyenv', ['pyenv', 'exec', 'python', __file__, *sys.argv[1:]])
|
|
|
|
ROOT=Path(__file__).resolve().parents[2]
|
|
os.environ['SPRINTER_PYTHON'] = sys.executable
|
|
sys.path.insert(0,str(ROOT/'toolchain'))
|
|
from sdbg.model import DebugMap
|
|
from sdbg.image import read_ihx
|
|
from sdbg.session import DebugSession, SessionError
|
|
from mame_interactive import build_events
|
|
from make_disk import create_floppy_image
|
|
from mame_profile import MameProfile, write_keyboard_config
|
|
|
|
|
|
def wait_dap_stop(engine, timeout=15):
|
|
deadline=time.monotonic()+timeout
|
|
seen=[]
|
|
while time.monotonic()<deadline:
|
|
events,closed=engine.poll_events(min(.5,deadline-time.monotonic()))
|
|
seen.extend(events)
|
|
if any(name=='stopped' for name,_ in events): return seen
|
|
if closed: break
|
|
raise RuntimeError('Исходный шаг не дал stopped: '+str(seen))
|
|
|
|
|
|
def main():
|
|
banked_mode = '--banked' in sys.argv
|
|
if '--lifecycle-exit' in sys.argv and '--lifecycle-load' in sys.argv:
|
|
raise ValueError('Выберите один lifecycle-сценарий')
|
|
lifecycle = 'exit' if '--lifecycle-exit' in sys.argv else (
|
|
'load' if '--lifecycle-load' in sys.argv else None)
|
|
if lifecycle and not {'--banked', '--bridge', '--server'} <= set(sys.argv):
|
|
raise ValueError('Lifecycle-пробник требует --banked --bridge --server')
|
|
work=ROOT/'build/sdbg-live'
|
|
work.mkdir(parents=True,exist_ok=True)
|
|
source=work/'probe.c'
|
|
source.write_text('''volatile int total;
|
|
int main(void) {
|
|
total = 42;
|
|
while (total) {
|
|
total++;
|
|
}
|
|
return 0;
|
|
}
|
|
''')
|
|
bank_source=work/'probe_bank.c'
|
|
if banked_mode:
|
|
source.write_text('''volatile int total;
|
|
void worker(void) __banked;
|
|
int main(void) {
|
|
total = 1;
|
|
worker();
|
|
while (total) { }
|
|
return 0;
|
|
}
|
|
''')
|
|
bank_source.write_text('''volatile int bank_value;
|
|
void worker(void) __banked {
|
|
bank_value = 77;
|
|
}
|
|
''')
|
|
exe=work/'probe.exe'
|
|
build=[str(ROOT/'bin/sprinter-cc'),'--src-debug','-o',str(exe)]
|
|
if banked_mode:
|
|
build.extend(['--memory','huge','--bank','1='+str(bank_source)])
|
|
build.append(str(source))
|
|
subprocess.run(build,check=True)
|
|
model=DebugMap(work/'.sprinter-cc-probe')
|
|
function=next(f for f in model.functions if f['name']=='main')
|
|
memory=read_ihx(model.directory/'probe.ihx')
|
|
signature=[memory[a] for a in range(function['start'],min(function['end'],function['start']+24))]
|
|
main_address=function['start']
|
|
if '--launcher' in sys.argv:
|
|
assert banked_mode,'--launcher probe пока требует --banked'
|
|
from sdbg.dap import DapEngine
|
|
socket_path=Path('/tmp')/('sprinter-launch-'+uuid.uuid4().hex+'.sock')
|
|
launcher_command=[sys.executable,str(ROOT/'toolchain/sdbg_launcher.py'),
|
|
'--build',str(model.directory),'--socket',str(socket_path)]
|
|
if '--native-debugger' in sys.argv:
|
|
launcher_command.extend(['--debugger','osx'])
|
|
launcher=subprocess.Popen(launcher_command,
|
|
stdout=subprocess.PIPE,stderr=sys.stderr,text=True)
|
|
try:
|
|
readable,_,_=select.select([launcher.stdout],[],[],75)
|
|
assert readable,'Launcher не сообщил ready'
|
|
ready=json.loads(launcher.stdout.readline())
|
|
assert ready['entry']['false_hits']==0,ready
|
|
engine=DapEngine()
|
|
engine.handle('attach',{'socket':ready['socket']})
|
|
if '--source-step' in sys.argv:
|
|
entry_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
engine.handle('stepIn',{})
|
|
wait_dap_stop(engine)
|
|
into_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
engine.handle('next',{})
|
|
wait_dap_stop(engine)
|
|
over_frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
assert entry_frame['name'].startswith('main'),entry_frame
|
|
assert into_frame['name'].startswith('main'),into_frame
|
|
assert over_frame['name'].startswith('main'),over_frame
|
|
assert into_frame['line'] != entry_frame['line'],(
|
|
entry_frame,into_frame)
|
|
assert over_frame['line'] != into_frame['line'],(
|
|
into_frame,over_frame)
|
|
print(json.dumps({'event':'source_step_verified',
|
|
'entry':entry_frame,'into':into_frame,
|
|
'over':over_frame,
|
|
'dss_ready_time':ready['entry']['dss_ready_time']}))
|
|
return 0
|
|
engine.handle('setBreakpoints',{'source':{'path':str(bank_source)},
|
|
'breakpoints':[{'line':3,'logMessage':'bank_value={bank_value}'}]})
|
|
engine.handle('setFunctionBreakpoints',{'breakpoints':[{'name':'worker'}]})
|
|
engine.handle('continue',{})
|
|
deadline=time.monotonic()+5
|
|
stopped=False
|
|
dap_events=[]
|
|
while time.monotonic()<deadline:
|
|
events,closed=engine.poll_events(.5)
|
|
dap_events.extend(events)
|
|
if any(name=='stopped' for name,_ in events): stopped=True;break
|
|
assert stopped
|
|
assert any(name=='output' and body['output']=='bank_value=0\n'
|
|
for name,body in dap_events),dap_events
|
|
frame=engine.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
assert frame['name'].startswith('worker'),frame
|
|
if '--step-out' in sys.argv:
|
|
engine.handle('stepOut',{})
|
|
wait_dap_stop(engine)
|
|
caller=engine.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
assert caller['name'].startswith('main'),caller
|
|
print(json.dumps({'event':'step_out_verified',
|
|
'worker':frame,'caller':caller,
|
|
'dss_ready_time':ready['entry']['dss_ready_time']}))
|
|
return 0
|
|
print(json.dumps({'event':'launcher_verified','entry':ready['entry'],
|
|
'build_id':ready['build_id'],'frame':frame['name'],
|
|
'logpoint':'bank_value=0'}))
|
|
return 0
|
|
finally:
|
|
if launcher.poll() is None:
|
|
launcher.terminate()
|
|
try: launcher.wait(timeout=8)
|
|
except subprocess.TimeoutExpired: launcher.kill();launcher.wait()
|
|
profile=MameProfile.resolve()
|
|
profile.validate(dss=True)
|
|
create_floppy_image(str(work/'probe.img'),[('PROBE.EXE',str(exe))])
|
|
system=work/'system.chd'
|
|
if not system.exists(): shutil.copyfile(profile.system_hdd_image,system)
|
|
events=build_events([(10,'a:\\probe.exe\n')])
|
|
ev=',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events)
|
|
report=work/'result.jsonl'
|
|
report.write_text('')
|
|
control=work/('control-'+uuid.uuid4().hex)
|
|
state_file=work/('state-'+uuid.uuid4().hex+'.sta')
|
|
lua=work/'probe.lua'
|
|
lua.write_text('''if _G.sdbg_probe_loaded then return end
|
|
_G.sdbg_probe_loaded=true
|
|
local machine=manager.machine
|
|
local cpu=machine.devices[":maincpu"]
|
|
local debug=machine.debugger
|
|
local report=io.open(REPORT,"a")
|
|
local function emit(text) report:write(text .. "\\n");report:flush() end
|
|
local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end
|
|
local events={EVENTS}
|
|
local index=1
|
|
local signature={SIGNATURE}
|
|
local phase="booting"
|
|
local last_time=nil
|
|
local ticks=0
|
|
local bp=nil
|
|
local false_hits=0
|
|
local launch_reported=false
|
|
local active=true
|
|
local state_loaded=false
|
|
local state_load_reported=false
|
|
_G.sdbg_probe_reset_subscription=emu.add_machine_reset_notifier(function()
|
|
-- Первый notifier относится к начальному machine start. После arm это
|
|
-- уже настоящий reset сессии, и старый debugger object трогать нельзя.
|
|
if phase~="booting" then active=false end
|
|
end)
|
|
_G.sdbg_probe_load_subscription=emu.add_machine_post_load_notifier(function()
|
|
state_loaded=true
|
|
end)
|
|
debug.execution_state="run"
|
|
emu.register_periodic(function()
|
|
if not active then return end
|
|
if state_loaded and not state_load_reported then
|
|
local count=0
|
|
for _ in pairs(cpu.debug:bplist()) do count=count+1 end
|
|
emit('{"event":"state_loaded","breakpoints":' .. count .. '}')
|
|
state_load_reported=true
|
|
end
|
|
if phase=="bridge_wait" then
|
|
local control=io.open(CONTROL,"rb")
|
|
if control then
|
|
local action=control:read("*a");control:close();os.remove(CONTROL)
|
|
emit('{"event":"control","action":"' .. action .. '"}')
|
|
if action=="exit" then machine:exit()
|
|
-- save/load не входят в scheduled_event_pending(): остановленный
|
|
-- debugger надо отпустить, иначе операция не дойдёт до timeslice.
|
|
elseif action=="save" then machine:save(STATE);debug.execution_state="run"
|
|
elseif action=="load" then machine:load(STATE);debug.execution_state="run"
|
|
else error("Unknown lifecycle action: " .. action) end
|
|
end
|
|
return
|
|
end
|
|
ticks=ticks+1
|
|
-- До окна запуска приложения DSS работает вообще без наших точек.
|
|
-- Первая клавиша launch sequence придёт через полсекунды после arm.
|
|
if phase=="booting" and now()>=events[1][1]-0.5 then
|
|
bp=cpu.debug:bpset(MAIN,"", "")
|
|
phase="loading"
|
|
machine.video:snapshot()
|
|
emit('{"event":"dss_wait_complete","time":' .. now() .. '}')
|
|
emit('{"event":"entry_armed","time":' .. now() .. '}')
|
|
end
|
|
if not launch_reported and now()>=events[1][1] then
|
|
launch_reported=true
|
|
emit('{"event":"launch_input_started","time":' .. now() .. '}')
|
|
end
|
|
while index<=#events and now()>=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 debug.execution_state=="stop" then
|
|
local pc=cpu.state.PC.value
|
|
if phase=="loading" then
|
|
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
|
|
emit('{"event":"main","pc":' .. pc .. ',"ticks":' .. ticks ..
|
|
',"false_hits":' .. false_hits .. '}')
|
|
cpu.debug:bpclear(bp)
|
|
if os.getenv("SDBG_SESSION_ID") then phase="bridge_wait";return end
|
|
phase="stepping"
|
|
last_time=now()
|
|
cpu.debug:step(1)
|
|
emit('{"event":"step_requested","pc":' .. cpu.state.PC.value .. '}')
|
|
elseif phase=="stepping" and now()>last_time then
|
|
emit('{"event":"step_completed","pc":' .. pc .. ',"ticks":' .. ticks .. '}')
|
|
report:close()
|
|
machine:exit()
|
|
end
|
|
end
|
|
if now()>35 then emit('{"event":"timeout"}');report:close();machine:exit() end
|
|
end)
|
|
'''.replace('REPORT',json.dumps(str(report))).replace('EVENTS',ev)
|
|
.replace('SIGNATURE',','.join(map(str,signature))).replace('MAIN',str(main_address))
|
|
.replace('CONTROL',json.dumps(str(control))).replace('STATE',json.dumps(str(state_file))))
|
|
command=[str(profile.binary),'sprinter','-noreadconfig',
|
|
'-rompath',str(profile.rompath),'-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k',
|
|
'-video','none','-sound','none','-nothrottle','-skip_gameinfo',
|
|
'-beta:wd179x:0','35hd','-beta:wd179x:1','35hd',
|
|
'-flop1',str(work/'probe.img'),
|
|
'-flop2',str(profile.dss_image),
|
|
'-hard1',str(system),'-debug','-debugger','none',
|
|
'-autoboot_delay','0','-autoboot_script',str(lua)]
|
|
for key in ['nvram','cfg','diff','snapshot']:
|
|
(work/key).mkdir(exist_ok=True)
|
|
command.extend(['-'+key+'_directory',str(work/key)])
|
|
write_keyboard_config(work/'cfg')
|
|
bridge_mode = '--bridge' in sys.argv
|
|
server_mode = '--server' in sys.argv
|
|
env = dict(os.environ)
|
|
if bridge_mode:
|
|
command[command.index('-debugger')+1] = (
|
|
'osx' if '--native-debugger' in sys.argv else 'sdbg')
|
|
command[command.index('-video')+1]='soft'
|
|
command.append('-window')
|
|
ipc=work/('ipc-'+uuid.uuid4().hex)
|
|
ipc.mkdir()
|
|
env.update(SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=uuid.uuid4().hex)
|
|
plugin_paths = [ROOT / 'toolchain/mcp']
|
|
if profile.home:
|
|
installed = profile.home.parent / 'plugins'
|
|
if installed.is_dir():
|
|
plugin_paths.append(installed)
|
|
command.extend(['-verbose','-plugin','sdbgbridge','-pluginspath',
|
|
';'.join(map(str, plugin_paths))])
|
|
with (work/'mame.log').open('w') as log:
|
|
if not bridge_mode:
|
|
result=subprocess.run(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT,timeout=55)
|
|
else:
|
|
from sdbg.transport import FileBridge, BridgeError
|
|
process=subprocess.Popen(command,cwd=work,env=env,stdout=log,stderr=subprocess.STDOUT)
|
|
bridge=None
|
|
server_process=None
|
|
try:
|
|
deadline=time.monotonic()+50
|
|
while '"event":"main"' not in report.read_text():
|
|
if process.poll() is not None or time.monotonic()>deadline:
|
|
raise RuntimeError('Нет остановки main; см. '+str(work/'mame.log'))
|
|
time.sleep(.05)
|
|
if server_mode:
|
|
assert banked_mode,'--server live probe пока требует --banked'
|
|
from sdbg.dap import DapEngine
|
|
socket_path=Path('/tmp')/('sprinter-sdbg-'+uuid.uuid4().hex+'.sock')
|
|
server_process=subprocess.Popen([
|
|
sys.executable,str(ROOT/'toolchain/sdbg_server.py'),
|
|
'--build',str(model.directory),'--ipc',str(ipc),
|
|
'--session',env['SDBG_SESSION_ID'],'--socket',str(socket_path)],
|
|
stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
|
|
ready=server_process.stdout.readline()
|
|
assert ready and json.loads(ready)['ready'],server_process.stderr.read()
|
|
try:
|
|
FileBridge(ipc,env['SDBG_SESSION_ID'])
|
|
raise AssertionError('Session server не удержал owner lock')
|
|
except BridgeError: pass
|
|
dap=DapEngine()
|
|
attached,_=dap.handle('attach',{'socket':str(socket_path)})
|
|
assert attached['buildId']==model.manifest['build_id']
|
|
if lifecycle:
|
|
def send_control(action):
|
|
pending=control.with_suffix('.tmp')
|
|
pending.write_text(action)
|
|
pending.replace(control)
|
|
if lifecycle=='load':
|
|
send_control('save')
|
|
deadline=time.monotonic()+20
|
|
while (not state_file.is_file() or state_file.stat().st_size==0):
|
|
if process.poll() is not None or time.monotonic()>deadline:
|
|
raise RuntimeError('MAME не сохранил state; лог: '+str(work/'mame.log'))
|
|
time.sleep(.05)
|
|
time.sleep(.25)
|
|
dap.handle('pause',{})
|
|
points=dap.rpc('set_function_breakpoints',
|
|
{'names':['worker']})['breakpoints']
|
|
assert points[0]['verified'] and points[0]['backend_ids'],points
|
|
send_control(lifecycle)
|
|
deadline=time.monotonic()+20
|
|
dap_events=[]
|
|
while time.monotonic()<deadline:
|
|
found,closed=dap.poll_events(.5)
|
|
dap_events.extend(found)
|
|
if closed: break
|
|
assert closed and any(name=='terminated' for name,_ in dap_events),dap_events
|
|
try:
|
|
dap.rpc('status')
|
|
raise AssertionError('Старая DAP-сессия осталась доступной')
|
|
except SessionError as error:
|
|
assert 'закрыта' in str(error) or 'инвалидирована' in str(error),error
|
|
if lifecycle=='exit':
|
|
assert process.wait(timeout=5)==0
|
|
else:
|
|
deadline=time.monotonic()+5
|
|
while '"event":"state_loaded"' not in report.read_text():
|
|
if process.poll() is not None or time.monotonic()>deadline:
|
|
raise RuntimeError('Нет post-load события; лог: '+str(work/'mame.log'))
|
|
time.sleep(.05)
|
|
loaded=next(json.loads(line) for line in report.read_text().splitlines()
|
|
if '"event":"state_loaded"' in line)
|
|
assert loaded['breakpoints']==0,loaded
|
|
with report.open('a') as file:
|
|
file.write(json.dumps({'event':'lifecycle_verified',
|
|
'action':lifecycle,'terminated':True,
|
|
'backend_breakpoints':points[0]['backend_ids']})+'\n')
|
|
print(json.dumps({'event':'lifecycle_verified',
|
|
'action':lifecycle,'dap_terminated':True,
|
|
'state_breakpoints':0 if lifecycle=='load' else None}))
|
|
state_file.unlink(missing_ok=True)
|
|
return 0
|
|
started=time.monotonic()
|
|
points,_=dap.handle('setFunctionBreakpoints',
|
|
{'breakpoints':[{'name':'worker'}]})
|
|
assert points['breakpoints'][0]['verified']
|
|
dap.handle('continue',{})
|
|
deadline=time.monotonic()+5
|
|
dap_events=[]
|
|
while time.monotonic()<deadline:
|
|
found,closed=dap.poll_events(.5)
|
|
dap_events.extend(found)
|
|
if any(name=='stopped' for name,_ in dap_events): break
|
|
assert any(name=='stopped' for name,_ in dap_events),dap_events
|
|
frame=dap.handle('stackTrace',{})[0]['stackFrames'][0]
|
|
assert frame['name'].startswith('worker'),frame
|
|
location=dap.rpc('where')
|
|
assert location['function']['bank']==1
|
|
debug_wall_ms=round((time.monotonic()-started)*1000,2)
|
|
with report.open('a') as file:
|
|
file.write(json.dumps({'event':'server_dap_verified',
|
|
'build_id':attached['buildId'],'frame':frame['name'],
|
|
'line_break_pc':location['pc'],'bank':1,
|
|
'debug_wall_ms':debug_wall_ms})+'\n')
|
|
else:
|
|
bridge=FileBridge(ipc,env['SDBG_SESSION_ID'])
|
|
assert bridge.handshake()['protocol']==1
|
|
try:
|
|
FileBridge(ipc,env['SDBG_SESSION_ID'])
|
|
raise AssertionError('Допущен второй управляющий клиент')
|
|
except BridgeError: pass
|
|
session=DebugSession(model,bridge)
|
|
attached=session.attach()
|
|
assert attached['pc']==main_address
|
|
assert session.read_variable('total')['value']==0
|
|
bridge.generation-=1
|
|
try:
|
|
bridge.request('memory',address=main_address,length=1)
|
|
raise AssertionError('Принята устаревшая generation')
|
|
except BridgeError as error:
|
|
assert 'generation' in str(error)
|
|
try:
|
|
bridge.request('clear',id=999999)
|
|
raise AssertionError('Удалена чужая точка')
|
|
except BridgeError: pass
|
|
started=time.monotonic()
|
|
if banked_mode:
|
|
breakpoint=session.break_function('worker',enabled=False)
|
|
expected='ib@e2=='+format(attached['bank_pages'][1],'x')
|
|
assert breakpoint['conditions']==[expected]
|
|
assert session.activate_breakpoints()['enabled']>=1
|
|
bridge.request('continue')
|
|
stopped=bridge.wait_stopped()
|
|
location=session.where(stopped)
|
|
assert location['status'] in ('mapped','ambiguous'), (location,stopped,attached)
|
|
assert location['function']['name']=='worker'
|
|
assert location['function']['bank']==1
|
|
session.clear_breakpoint(breakpoint['id'])
|
|
after={'pc':stopped['registers']['PC']}
|
|
else:
|
|
bridge.request('step')
|
|
after=session.where(bridge.wait_stopped())
|
|
assert after['pc']==main_address+3
|
|
bridge.request('step')
|
|
session.where(bridge.wait_stopped())
|
|
assert session.read_variable('total')['value']==42
|
|
breakpoint=session.break_line(str(source),5)
|
|
bridge.request('continue')
|
|
stopped=bridge.wait_stopped()
|
|
location=session.where(stopped)
|
|
assert location['status']=='mapped'
|
|
assert location['sources']==[{'file':str(source.resolve()),'line':5}]
|
|
session.clear_breakpoint(breakpoint['id'])
|
|
events=bridge.request('events',after=0)
|
|
assert any(event['kind']=='stopped' for event in events['events'])
|
|
final_total=session.read_variable('total')['value'] if not banked_mode else None
|
|
debug_wall_ms=round((time.monotonic()-started)*1000,2)
|
|
with report.open('a') as file:
|
|
file.write(json.dumps({'event':'bridge_verified','pc_before':main_address,
|
|
'pc_after_step':after['pc'],
|
|
'line_break_pc':stopped['registers']['PC'],
|
|
'total':final_total,
|
|
'bank':location['function'].get('bank'),
|
|
'step_wall_ms':debug_wall_ms,
|
|
'generation':bridge.generation})+'\n')
|
|
finally:
|
|
if bridge: bridge.close()
|
|
if server_process and server_process.poll() is None:
|
|
server_process.terminate()
|
|
try: server_process.wait(timeout=3)
|
|
except subprocess.TimeoutExpired: server_process.kill();server_process.wait()
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try: process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired: process.kill();process.wait()
|
|
print(report.read_text())
|
|
if not bridge_mode and (result.returncode or 'step_completed' not in report.read_text()):
|
|
print((work/'mame.log').read_text());return 1
|
|
return 0
|
|
|
|
if __name__=='__main__':sys.exit(main())
|