#!/usr/bin/env python3 r""" mame_interactive.py — автотест .exe в MAME без участия человека. Полный справочник по автотестированию в MAME: docs/mame-autotest.md. Инструмент запускает Sprinter в MAME, вводит команды в DSS эмуляцией нажатий клавиш, снимает скриншоты в заданные моменты и завершает сессию по таймауту. Ввод идёт в НАСТОЯЩУЮ клавиатуру DSS — AT/PS-2 устройство `:kbd:ms_naturl` (подключено последовательно к SIO Z84C015): через Lua `ioport.ports[":kbd:ms_naturl:PX.Y"].fields[...]:set_value(1/0)` жмём/ отпускаем поля, at_keyboard сам генерит scancode'ы → SIO → DSS. (ZX-матрица `IO_LINE0..7` и natkeyboard/-autoboot_command командную строку DSS НЕ достают — не использовать.) Тонкости (детали — в docs/mame-autotest.md): - attotime.seconds — ЦЕЛОЕ; субсекундный тайминг = seconds + attoseconds/1e18. - клавишу держать коротко (~0.06с), между символами ~0.14с — иначе автоповтор (typematic) и слипание scancode'ов. - загрузка должна встать на приглашении C:\> (system.bat не должен автозапускать Flex Navigator/приложение). - перед запуском проверяем, что нет висящих копий MAME. Использование: python3 toolchain/mame_interactive.py [exe] [--data f ...] \ [--launch-at T] [--step "T:TEXT" ...] [--snap t1,t2,...] [--timeout N] exe — .exe, который кладётся на дискету A: и АВТОМАТИЧЕСКИ запускается (печатается a:\ + Enter в момент --launch-at). Без exe работаем на голой строке. --data f ... — доп. файлы на дискету A:. --launch-at T — секунда, когда печатается запуск exe (по умолч. 8). --step "T:TEXT" — в момент T сек напечатать TEXT (можно много раз — диалог с уже запущенной программой). В TEXT: \n = Enter, \t = Tab; заглавные/символы с Shift — авто. --snap — секунды для скриншотов (по умолчанию — авто). --timeout — секунд эмуляции до выхода. Примеры: # запустить тест и снять экран python3 toolchain/mame_interactive.py tests/rt_test/rt_test.exe \ --snap 12,14 --timeout 16 # набрать команду на голой командной строке python3 toolchain/mame_interactive.py --step "8:dir\n" \ --snap 10,11 --timeout 12 # запустить программу и ответить на её ввод python3 toolchain/mame_interactive.py tests/menu/menu.exe \ --step "13:2\n" --snap 15 --timeout 17 """ import argparse import os import shutil import subprocess import sys PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MAME_DIR = os.path.join(PROJECT_ROOT, "mame", "v306") IMG_DIR = os.path.join(MAME_DIR, "IMG") MC_IMG = os.path.join(IMG_DIR, "mc.img") SNAP_DIR = os.path.join(MAME_DIR, "snap_auto") LUA_SCRIPT = os.path.join(MAME_DIR, "_interactive_gen.lua") COMMON_ARGS = [ "-skip_gameinfo", "-video", "opengl", "-window", "-nofilter", "-beta:wd179x:0", "35hd", "-beta:wd179x:1", "35hd", "-flop1", MC_IMG, "-flop2", os.path.join(IMG_DIR, "dss171u.img"), "-isa0", "zxbus_adapter", "-isa0:zxbus_adapter:card", "neogs", "-hard1", os.path.join(IMG_DIR, "sp_hdd_sys.chd"), "-hard2", os.path.join(IMG_DIR, "sp_hdd_media.chd"), "-ata2:0", "cdrom", "-cdrom", os.path.join(IMG_DIR, "SprinterCD.iso"), "-bios", "v3.06", ] # --- Раскладка AT-клавиатуры :kbd:ms_naturl (tag, mask) --- # Снято дампом ioport-полей в MAME (см. docs/mame-autotest.md). KB = ":kbd:ms_naturl:" PHYS = { "a": (KB+"P1.6", 0x04), "b": (KB+"P1.5", 0x10), "c": (KB+"P1.4", 0x08), "d": (KB+"P1.4", 0x04), "e": (KB+"P1.4", 0x20), "f": (KB+"P1.5", 0x02), "g": (KB+"P1.5", 0x04), "h": (KB+"P2.0", 0x02), "i": (KB+"P1.4", 0x01), "j": (KB+"P2.0", 0x04), "k": (KB+"P1.4", 0x02), "l": (KB+"P2.2", 0x08), "m": (KB+"P2.0", 0x10), "n": (KB+"P2.0", 0x08), "o": (KB+"P2.2", 0x02), "p": (KB+"P2.3", 0x20), "q": (KB+"P1.6", 0x02), "r": (KB+"P1.5", 0x01), "s": (KB+"P1.2", 0x08), "t": (KB+"P1.5", 0x20), "u": (KB+"P2.0", 0x20), "v": (KB+"P1.5", 0x08), "w": (KB+"P1.2", 0x04), "x": (KB+"P1.2", 0x10), "y": (KB+"P2.0", 0x01), "z": (KB+"P1.6", 0x10), "1": (KB+"P1.6", 0x01), "2": (KB+"P1.2", 0x02), "3": (KB+"P1.4", 0x40), "4": (KB+"P1.5", 0x40), "5": (KB+"P1.5", 0x80), "6": (KB+"P2.0", 0x40), "7": (KB+"P2.0", 0x80), "8": (KB+"P1.4", 0x80), "9": (KB+"P2.2", 0x01), "0": (KB+"P2.3", 0x01), "-": (KB+"P2.2", 0x20), "=": (KB+"P2.1", 0x01), "[": (KB+"P2.2", 0x04), "]": (KB+"P2.1", 0x02), "\\": (KB+"P2.1", 0x04), ";": (KB+"P2.3", 0x02), "'": (KB+"P2.3", 0x04), ",": (KB+"P1.4", 0x10), ".": (KB+"P2.2", 0x10), "/": (KB+"P2.3", 0x08), "`": (KB+"P1.6", 0x80), " ": (KB+"P2.4", 0x80), "\n": (KB+"P2.1", 0x10), "\t": (KB+"P1.6", 0x20), } # Символы, набираемые с Shift (значение → базовая физ. клавиша). SHIFTED = { "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", ")": "0", "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", ":": ";", '"': "'", "<": ",", ">": ".", "?": "/", "~": "`", } SHIFT_KEY = (KB+"P1.7", 0x02) # Left Shift # Тайминг набора (сек эмуляции). CADENCE = 0.14 # шаг между символами HOLD = 0.06 # длительность удержания клавиши def basename_83(name): """Обрезать имя файла до формата 8.3, как в FAT12 корневом каталоге.""" name = name.upper() if '.' in name: base, ext = name.rsplit('.', 1) base = base[:8].ljust(8, ' ') ext = ext[:3].ljust(3, ' ') return f"{base}.{ext}" return name[:8].ljust(8, ' ') + '.EXE' def resolve(ch): """char → (tag, mask, need_shift). Заглавные буквы = shift+строчная.""" if ch in PHYS: return PHYS[ch][0], PHYS[ch][1], False if ch.isalpha() and ch.lower() in PHYS: # заглавные A-Z t, m = PHYS[ch.lower()] return t, m, True if ch in SHIFTED: t, m = PHYS[SHIFTED[ch]] return t, m, True raise ValueError(f"нет раскладки для символа {ch!r}") def build_events(steps): """steps: список (t0, text) → плоский список событий (t, tag, mask, val).""" ev = [] for t0, text in steps: ct = t0 for ch in text: tag, mask, sh = resolve(ch) if sh: ev.append((ct, SHIFT_KEY[0], SHIFT_KEY[1], 1)) ev.append((ct + 0.02, tag, mask, 1)) ev.append((ct + HOLD, tag, mask, 0)) ev.append((ct + HOLD+0.02, SHIFT_KEY[0], SHIFT_KEY[1], 0)) else: ev.append((ct, tag, mask, 1)) ev.append((ct + HOLD, tag, mask, 0)) ct += CADENCE ev.sort(key=lambda e: e[0]) return ev def gen_lua(events, snap_times, timeout): ev_lua = ",\n".join( f' {{{t:.4f},"{tag}",{mask},{val}}}' for (t, tag, mask, val) in events ) snaps = ",".join(f"{s:.4f}" for s in snap_times) lua = f"""-- СГЕНЕРИРОВАНО mame_interactive.py — не редактировать вручную. local function now() local t = manager.machine.time return t.seconds + t.attoseconds / 1e18 end local start_time = nil emu.add_machine_reset_notifier(function() start_time = now() end) local events = {{ {ev_lua} }} local snap_times = {{{snaps}}} local snaps_done = {{}} local ports, idx = nil, 1 local fcache = {{}} local function getf(tag, mask) local key = tag .. "/" .. mask local f = fcache[key] if f then return f end for _, fld in pairs(ports[tag].fields) do if fld.mask == mask then fcache[key] = fld return fld end end error("нет поля " .. key) end emu.register_periodic(function() if not start_time then return end if not ports then ports = manager.machine.ioport.ports end local e = now() - start_time while idx <= #events and e >= events[idx][1] do local ev = events[idx] getf(ev[2], ev[3]):set_value(ev[4]) idx = idx + 1 end for _, st in ipairs(snap_times) do if not snaps_done[st] and e >= st then snaps_done[st] = true manager.machine.video:snapshot() print("[interactive] snapshot t=" .. e) end end if e >= {timeout} then print("[interactive] exit t=" .. e) manager.machine:exit() end end) """ with open(LUA_SCRIPT, "w") as f: f.write(lua) def check_no_stray_mame(): # Ловим только запущенный ЭМУЛЯТОР (mame/mame.arm/mame.exe c машиной # sprinter), а не всё, где в командной строке встречается «mame»: # голый паттерн ложно срабатывал на параллельную сборку MAME из # исходников (clang/make с путями .../src/mame/...). out = subprocess.run(["pgrep", "-fl", r"mame[^ ]* sprinter( |$)"], capture_output=True, text=True).stdout if out.strip(): print("ОШИБКА: есть запущенные копии MAME — закройте их:") print(out) sys.exit(1) def parse_step(s): if ":" not in s: raise argparse.ArgumentTypeError("шаг должен быть 'T:TEXT'") t_str, text = s.split(":", 1) text = text.replace("\\n", "\n").replace("\\t", "\t") return float(t_str), text def main(): ap = argparse.ArgumentParser() ap.add_argument("exe", nargs="?", help=".exe на дискету A: (авто-запуск)") ap.add_argument("--data", nargs="*", default=[], help="доп. файлы на дискету") ap.add_argument("--launch-at", type=float, default=8.0, help="сек, когда печатается запуск exe (по умолч. 8)") ap.add_argument("--step", action="append", default=[], type=parse_step, help="'T:TEXT' — в момент T сек напечатать TEXT (можно много)") ap.add_argument("--snap", default=None, help="секунды для скриншотов, через запятую") ap.add_argument("--timeout", type=float, default=None, help="сек эмуляции до выхода") args = ap.parse_args() check_no_stray_mame() # Собираем шаги ввода: авто-запуск exe (печать пути) + пользовательские. steps = list(args.step) exe_path = None if args.exe: exe_path = os.path.abspath(args.exe) if not os.path.isfile(exe_path): print(f"ОШИБКА: не найден {exe_path}") sys.exit(1) exe_name = basename_83(os.path.basename(exe_path)).upper() steps.append((args.launch_at, f"a:\\{exe_name}\n")) if not steps: print("ОШИБКА: укажите exe для запуска или хотя бы один --step") sys.exit(1) events = build_events(steps) last_ev_t = events[-1][0] if events else max(t for t, _ in steps) if args.snap: snap_times = [float(x) for x in args.snap.split(",")] else: # exe: даём программе доработать (снимаем через 4 и 6 с после ввода); # чистый ввод: сразу после последнего шага. pad = 4.0 if exe_path else 1.5 snap_times = [last_ev_t + pad, last_ev_t + pad + 2.0] timeout = args.timeout if args.timeout is not None else (max(snap_times) + 1.5) gen_lua(events, snap_times, timeout) # Дискета БЕЗ авто-запускаемого bat → приглашение C:\>. disk_files = [] if exe_path: disk_files.append(exe_path) disk_files += [os.path.abspath(p) for p in args.data] if os.path.exists(MC_IMG): shutil.copy(MC_IMG, MC_IMG + ".bak") make_disk_cmd = [sys.executable, os.path.join(MAME_DIR, "make_disk.py"), MC_IMG] subprocess.run(make_disk_cmd + disk_files, check=True, cwd=MAME_DIR) snap_sub = os.path.join(SNAP_DIR, "sprinter") if os.path.isdir(snap_sub): shutil.rmtree(snap_sub) cmd = ["timeout", str(int(timeout) + 8), "./mame.arm", "sprinter"] + COMMON_ARGS + [ "-snapshot_directory", SNAP_DIR, "-autoboot_script", LUA_SCRIPT, ] print("Ввод по шагам:", [(round(t, 2), repr(txt)) for t, txt in steps]) print("Скриншоты (сек):", snap_times, " таймаут:", timeout) result = subprocess.run(cmd, cwd=MAME_DIR, capture_output=True, text=True) for line in result.stdout.splitlines(): if "[interactive]" in line: print(line) if result.returncode not in (0, 124): print("STDERR:", result.stderr[-2000:]) if os.path.isdir(snap_sub): print("Скриншоты:") for s in sorted(os.listdir(snap_sub)): print(" ", os.path.join(snap_sub, s)) else: print("Скриншотов не создано.") if __name__ == "__main__": main()