Files
Sprinter-SDCC/tests/sdbg/run_mame_probe.py
T
2026-09-16 10:01:43 +03:00

398 lines
19 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
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
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('')
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
_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)
debug.execution_state="run"
emu.register_periodic(function()
if not active then 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)))
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','-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']
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())