#!/usr/bin/env python3 """Изолированный DSS→EXE→main launcher для DAP/session server.""" from __future__ import annotations import argparse from contextlib import redirect_stdout import json import os from pathlib import Path import re import shutil import signal import subprocess import sys import tempfile import time import uuid ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT/'toolchain')) from mame_interactive import basename_83, build_events from make_disk import create_floppy_image from mame_profile import (MameProfile, add_arguments, from_arguments, write_keyboard_config) from sdbg.image import read_ihx from sdbg.model import DebugMap def disk_entries(exe: Path, data) -> list[tuple[str, str]]: paths = [Path(exe), *(Path(name).resolve() for name in data)] if any(not path.is_file() for path in paths): missing = next(path for path in paths if not path.is_file()) raise ValueError('Не найден файл для debug-дискеты: ' + str(missing)) entries = [(basename_83(path.name), str(path)) for path in paths] names = [name.replace(' ', '') for name, _ in entries] if len(set(names)) != len(names): raise ValueError('Коллизия имён 8.3 на debug-дискете') clusters = sum((path.stat().st_size + 511) // 512 for path in paths) if len(paths) > 224 or clusters > 2847: raise ValueError('Файлы не помещаются на FAT12 debug-дискету') return entries def lua_script(path, ready, main_address, signature, events, launch_at, dss_timeout=30): rows = ',\n'.join('{'+f'{t},"{tag}",{mask},{value}'+'}' for t,tag,mask,value in events) text = '''if _G.sdbg_launcher_loaded then return end _G.sdbg_launcher_loaded=true local machine=manager.machine local cpu=machine.devices[":maincpu"] local debug=machine.debugger local events={EVENTS} local signature={SIGNATURE} local index=1 local phase="booting" local active=true local bp=nil local false_hits=0 local launch_started=nil local prompt_since=nil local prompt_row=nil local function now() local t=machine.time;return t.seconds+t.attoseconds/1e18 end local vram=nil for tag,share in pairs(machine.memory.shares) do if tag:find("vram",1,true) then vram=share;break end end local function dss_prompt_row() if not vram then return nil end local rgmod=cpu.state.RGMOD.value & 1 local function char_at(row,col) return vram:read_u8((1+col+0x80*rgmod)*1024+0x300+row*4+1) end for row=0,31 do local drive=char_at(row,0) if ((drive>=65 and drive<=90) or (drive>=97 and drive<=122)) and char_at(row,1)==58 then for col=2,38 do local value=char_at(row,col) if value==62 then local clean=true for tail=col+1,math.min(col+8,79) do local after=char_at(row,tail) if after~=0 and after~=32 and after~=95 then clean=false;break end end if clean then return row end elseif value<32 then break end end end end return nil end _G.sdbg_launcher_reset_subscription=emu.add_machine_reset_notifier(function() if phase~="booting" then active=false end end) debug.execution_state="run" emu.register_periodic(function() if not active then return end local current=now() if phase=="booting" then local row=nil if current>=LAUNCH_NOT_BEFORE then row=dss_prompt_row() end if row then if prompt_row~=row then prompt_row=row;prompt_since=current end if current-prompt_since>=0.25 then machine.video:snapshot() bp=cpu.debug:bpset(MAIN,"","") launch_started=current phase="loading" end else prompt_row=nil;prompt_since=nil end end while phase=="loading" and index<=#events and current>=launch_started+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 phase=="loading" and debug.execution_state=="stop" then local pc=cpu.state.PC.value 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 cpu.debug:bpclear(bp) local file=assert(io.open(READY..".tmp","wb")) file:write('{"pc":'..pc..',"false_hits":'..false_hits.. ',"dss_ready_time":'..launch_started..',"prompt_row":'..prompt_row..'}') file:close();assert(os.rename(READY..".tmp",READY)) phase="attached" end if phase=="booting" and current>DSS_TIMEOUT then local file=io.open(READY..".error","wb") if file then file:write("DSS prompt timeout");file:close() end machine:exit() elseif phase=="loading" and current>launch_started+30 then local file=io.open(READY..".error","wb") if file then file:write("main timeout");file:close() end machine:exit() end end) ''' text = (text.replace('EVENTS',rows).replace('SIGNATURE',','.join(map(str,signature))) .replace('LAUNCH_NOT_BEFORE',str(launch_at)).replace('MAIN',str(main_address)) .replace('READY',json.dumps(str(ready))).replace('DSS_TIMEOUT',str(dss_timeout))) path.write_text(text) def main(): parser=argparse.ArgumentParser(description=__doc__) add_arguments(parser) parser.add_argument('--build',required=True) parser.add_argument('--socket',required=True) parser.add_argument('--data',action='append',default=[]) parser.add_argument('--app-hdd',default=None, help='локальный CHD приложения для -hard2') parser.add_argument('--launch-path',default=None, help='путь к EXE в DSS, например d:\\games\\sprpop\\sprpop.exe') parser.add_argument('--launch-at',type=float,default=0, help='не начинать ввод раньше этой секунды эмуляции') parser.add_argument('--dss-timeout',type=float,default=30, help='таймаут появления стабильного prompt DSS') parser.add_argument('--mame',default=None, help='совместимый alias для --mame-bin') parser.add_argument('--debugger',default='sdbg', help='OSD debugger provider (по умолчанию sdbg из project patch)') args=parser.parse_args() model=DebugMap(args.build) model.verify_executable() exe=Path(model.manifest['executable_path']) functions=[item for item in model.functions if item['name']=='main'] function=functions[0] if len(functions)==1 else None if not function or function['bank'] is not None: raise ValueError('Нужна единственная resident-функция main') image=read_ihx(model.directory/(exe.stem+'.ihx')) signature=bytes(image[address] for address in range( function['start'],min(function['end'],function['start']+24))) if not signature: raise ValueError('У main нет проверяемой сигнатуры') if args.mame and not args.mame_bin: args.mame_bin=args.mame profile=from_arguments(args) profile.validate(dss=True) mame=profile.binary stopping=False def stop(*_): nonlocal stopping stopping=True signal.signal(signal.SIGTERM,stop) signal.signal(signal.SIGINT,stop) with tempfile.TemporaryDirectory(prefix='sprinter-sdbg-launch-') as temporary: state=Path(temporary) ipc=state/'ipc';ipc.mkdir() for name in ('nvram','cfg','diff','snapshot'): (state/name).mkdir() write_keyboard_config(state/'cfg') system=state/'system.chd' shutil.copyfile(profile.system_hdd_image,system) disk_path=state/'debug.img' files=disk_entries(exe,args.data) with redirect_stdout(sys.stderr): if not create_floppy_image(str(disk_path),files): raise RuntimeError('Не удалось создать debug-дискету') if args.launch_path: launch_path=args.launch_path.replace('/', '\\') if not re.fullmatch(r'[A-Za-z]:\\[A-Za-z0-9_.\\-]+',launch_path): raise ValueError('launch-path: ожидается путь DOS вида d:\\dir\\app.exe') if Path(launch_path.split('\\')[-1]).name.upper() != exe.name.upper(): raise ValueError('launch-path должен заканчиваться именем debug EXE: '+exe.name) command_text=launch_path+'\n' else: command_text='a:\\'+basename_83(exe.name).replace(' ','')+'\n' events=build_events([(0,command_text)]) ready=state/'main.json' lua=state/'launch.lua' lua_script(lua,ready,function['start'],signature,events,args.launch_at, args.dss_timeout) session_id=uuid.uuid4().hex environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id, SDBG_SNAP_DIR=str(state/'snapshot')) # SDL3 превращает SIGTERM в SDL_EVENT_QUIT, который MAME SDL3 OSD # не обрабатывает. Оставляем стандартное действие сигнала, чтобы # DAP Stop не ждал принудительного kill остановленного MAME. environment.setdefault('SDL_NO_SIGNAL_HANDLERS','1') plugin_paths=[str(ROOT/'toolchain/mcp')] installed_plugins=profile.home.parent/'plugins' if profile.home else None if installed_plugins and installed_plugins.is_dir(): plugin_paths.append(str(installed_plugins)) command=[str(mame),'sprinter','-noreadconfig','-rompath',str(profile.rompath), '-bios',profile.bios,'-kbd','ms_naturl,bios=sp2k','-video','soft','-window', '-sound','none','-skip_gameinfo','-beta:wd179x:0','35hd', '-beta:wd179x:1','35hd','-flop1',str(disk_path), '-flop2',str(profile.dss_image),'-hard1',str(system), '-debug','-debugger',args.debugger, '-plugin','sdbgbridge','-pluginspath',';'.join(plugin_paths), '-autoboot_delay','0','-autoboot_script',str(lua)] if args.app_hdd: app_hdd=Path(args.app_hdd).resolve() if not app_hdd.is_file(): raise ValueError('Не найден CHD приложения: '+str(app_hdd)) mounted_app_hdd=state/'application.chd' shutil.copyfile(app_hdd,mounted_app_hdd) command.extend(['-hard2',str(mounted_app_hdd)]) for name in ('nvram','cfg','diff','snapshot'): command.extend(['-'+name+'_directory',str(state/name)]) log=(state/'mame.log').open('w') mame_process=subprocess.Popen(command,cwd=state,env=environment, stdout=log,stderr=subprocess.STDOUT) server=None socket_identity=None def mame_diagnostics(): log.flush() output=(state/'mame.log').read_text(errors='replace')[-4000:].strip() return ('\nMAME log:\n'+output) if output else '' try: deadline=time.monotonic()+args.dss_timeout+45 while not ready.exists(): if mame_process.poll() is not None: raise RuntimeError('MAME завершился до main; лог: '+ str(state/'mame.log')+mame_diagnostics()) error_file=state/'main.json.error' if error_file.exists(): raise RuntimeError('MAME не дошёл до main: '+ error_file.read_text(errors='replace').strip()+ '; лог: '+str(state/'mame.log')+mame_diagnostics()) if time.monotonic()>deadline: raise RuntimeError('Таймаут ожидания main без ответа Lua; лог: '+ str(state/'mame.log')+mame_diagnostics()) time.sleep(.05) entry=json.loads(ready.read_text()) server=subprocess.Popen([sys.executable,str(ROOT/'toolchain/sdbg_server.py'), '--build',str(model.directory),'--ipc',str(ipc),'--session',session_id, '--socket',args.socket],stdout=subprocess.PIPE,stderr=sys.stderr,text=True) line=server.stdout.readline() if not line: raise RuntimeError('Session server не запустился'+mame_diagnostics()) server_ready=json.loads(line) socket_state=Path(args.socket).lstat() socket_identity=(socket_state.st_dev,socket_state.st_ino) print(json.dumps({'ready':True,'socket':args.socket,'pid':mame_process.pid, 'entry':entry,'build_id':server_ready['build_id']}, ensure_ascii=False),flush=True) while not stopping and mame_process.poll() is None and server.poll() is None: if os.getppid()==1: break time.sleep(.1) finally: if server is not None and server.poll() is None: server.terminate() try: server.wait(timeout=3) except subprocess.TimeoutExpired: server.kill();server.wait() if socket_identity is not None: socket_path=Path(args.socket) try: current=socket_path.lstat() if (current.st_dev,current.st_ino)==socket_identity: socket_path.unlink() except FileNotFoundError: pass if mame_process.poll() is None: mame_process.terminate() try: mame_process.wait(timeout=5) except subprocess.TimeoutExpired: mame_process.kill();mame_process.wait() log.close() return 0 if __name__=='__main__': try: sys.exit(main()) except (ValueError,OSError,RuntimeError,KeyError,subprocess.SubprocessError) as error: print('sdbg-launcher: '+str(error),file=sys.stderr) sys.exit(1)