diff --git a/toolchain/mame_interactive.py b/toolchain/mame_interactive.py new file mode 100644 index 0000000..ed35ad7 --- /dev/null +++ b/toolchain/mame_interactive.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +mame_interactive.py — скриптовый ИНТЕРАКТИВНЫЙ ввод в DSS через MAME. + +В отличие от mame_auto_test.py (который запускает .exe через AUTORUN.BAT +chainload и НЕ умеет вводить с клавиатуры), этот инструмент печатает +произвольный текст в командную строку DSS, эмулируя нажатия клавиш. + +КАК это работает (важное открытие 2026-07-08, см. +memory/mame_autotest_autorun): +у Sprinter в MAME ДВЕ клавиатуры. `IO_LINE0..7` — легаси ZX-матрица +(порт 0xFE), которую DSS для командной строки НЕ читает — именно в неё +безрезультатно били natkeyboard/-autoboot_command/set_value раньше. +Настоящая клавиатура 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. Проверено: `dir` выполняется. + +Тонкости, на которых уже наступали: +- `attotime.seconds` — ЦЕЛОЕ (отбрасывает дробь); для субсекундного + тайминга берём `seconds + attoseconds/1e18`. +- клавишу держать коротко (~0.06с) — иначе срабатывает автоповтор + (typematic) и вместо `d` получается `dddddd`. +- между символами ~0.14с, чтобы scancode'ы не сливались. +- дискета БЕЗ AUTORUN.BAT → загрузка встаёт на приглашении `C:\>` + (с AUTORUN.BAT сразу ушла бы в автозапуск .exe). +- перед запуском проверяем, что нет висящих копий MAME. + +Использование: + python3 toolchain/mame_interactive.py [exe] [--data f ...] \\ + --step "T:TEXT" [--step "T:TEXT" ...] \\ + [--snap t1,t2,...] [--timeout N] + + exe / --data — файлы, кладущиеся на дискету A: (опционально; + чтобы можно было напечатать, напр., a:\\name.exe). + --step "T:TEXT" — в момент T секунд (эмуляции) напечатать TEXT. + Можно указывать несколько раз (диалог с программой). + В TEXT поддержаны \\n (Enter) и \\t (Tab); символы + с Shift (заглавные, ! @ : \\ и т.п.) — автоматически. + --snap — секунды для скриншотов (по умолчанию — авто). + --timeout — секунд эмуляции до выхода. + +Пример (набрать DIR на приглашении и снять экран): + python3 toolchain/mame_interactive.py --step "8:dir\\n" \\ + --snap 10,11 --timeout 12 + +Пример (запустить .exe вводом пути и дождаться вывода): + python3 toolchain/mame_interactive.py tests/rt_test/rt_test.exe \\ + --step "8:a:\\rt_test.exe\\n" --snap 12,14 --timeout 16 +""" + +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 (см. _probe_kbd.lua). +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 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(): + out = subprocess.run(["pgrep", "-fl", "mame"], 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("--step", action="append", type=parse_step, required=True, + 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() + + events = build_events(args.step) + last_ev_t = events[-1][0] if events else max(t for t, _ in args.step) + + if args.snap: + snap_times = [float(x) for x in args.snap.split(",")] + else: + snap_times = [last_ev_t + 1.5, last_ev_t + 3.0] + timeout = args.timeout if args.timeout is not None else (max(snap_times) + 1.5) + + gen_lua(events, snap_times, timeout) + + # Дискета БЕЗ AUTORUN.BAT → приглашение C:\> + disk_files = [] + if args.exe: + p = os.path.abspath(args.exe) + if not os.path.isfile(p): + print(f"ОШИБКА: не найден {p}") + sys.exit(1) + disk_files.append(p) + disk_files += [os.path.abspath(p) for p in args.data] + + if os.path.exists(MC_IMG): + shutil.copy(MC_IMG, MC_IMG + ".bak") + if disk_files: + subprocess.run([sys.executable, os.path.join(MAME_DIR, "make_disk.py"), MC_IMG] + + disk_files, check=True, cwd=MAME_DIR) + else: + # пустая дискета без AUTORUN.BAT + subprocess.run([sys.executable, os.path.join(MAME_DIR, "make_disk.py"), MC_IMG], + 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("Ввод по шагам:", [(t, repr(txt)) for t, txt in args.step]) + print("Запуск:", " ".join(cmd)) + 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()