Files
Sprinter-SDCC/toolchain/mame_interactive.py
T

330 lines
15 KiB
Python

#!/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:\<EXE> + 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
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid
from mame_profile import MameProfile, add_arguments, from_arguments, write_keyboard_config
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# --- Раскладка 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)
"""
return lua
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()
add_arguments(ap)
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="сек эмуляции до выхода")
ap.add_argument("--snapshot-dir", help="каталог кадров, по умолчанию build/mame-autotest")
args = ap.parse_args()
profile = from_arguments(args)
if profile.binary is None and (Path(PROJECT_ROOT)/"mame/v306/mame.arm").is_file():
profile = MameProfile.resolve({"MAME_HOME":str(Path(PROJECT_ROOT)/"mame/v306")})
try:
profile.validate(dss=True)
except ValueError as error:
ap.error(str(error))
# Собираем шаги ввода: авто-запуск 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)
lua_source = 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]
snapshot_base = Path(args.snapshot_dir or Path.cwd()/"build/mame-autotest")
snapshot_root = snapshot_base.resolve()/uuid.uuid4().hex[:10]
snapshot_root.mkdir(parents=True)
with tempfile.TemporaryDirectory(prefix="sprinter-mame-autotest-") as directory:
state = Path(directory)
for name in ("cfg", "nvram", "diff"):
(state/name).mkdir()
write_keyboard_config(state/"cfg")
disk = state/"test.img"
subprocess.run([sys.executable, str(Path(PROJECT_ROOT)/"toolchain/make_disk.py"),
str(disk), *disk_files], check=True)
lua_script = state/"interactive.lua"
lua_script.write_text(lua_source, encoding="utf-8")
cmd = [str(profile.binary), "sprinter", "-noreadconfig",
"-rompath", str(profile.rompath), "-bios", profile.bios,
"-kbd", "ms_naturl,bios=sp2k", "-skip_gameinfo",
"-video", "soft", "-window", "-beta:wd179x:0", "35hd",
"-beta:wd179x:1", "35hd", "-flop1", str(disk),
"-flop2", str(profile.dss_image),
"-hard1", str(profile.system_hdd_image),
"-isa0", "zxbus_adapter", "-isa0:zxbus_adapter:card", "neogs",
"-snapshot_directory", str(snapshot_root),
"-autoboot_script", str(lua_script)]
for name in ("cfg", "nvram", "diff"):
cmd += ["-"+name+"_directory", str(state/name)]
if profile.home:
media = profile.home/"IMG/sp_hdd_media.chd"
optical = profile.home/"IMG/SprinterCD.iso"
if media.is_file():
test_media = state/"media.chd"
shutil.copyfile(media, test_media)
cmd += ["-hard2", str(test_media)]
if optical.is_file():
cmd += ["-ata2:0", "cdrom", "-cdrom", str(optical)]
print("Ввод по шагам:", [(round(t, 2), repr(txt)) for t, txt in steps])
print("Скриншоты (сек):", snap_times, " таймаут:", timeout)
try:
result = subprocess.run(cmd, cwd=state, capture_output=True,
text=True, timeout=timeout+8)
except subprocess.TimeoutExpired as error:
print("MAME: истёк лимит времени", file=sys.stderr)
output = error.stdout or ""
stderr = error.stderr or ""
if isinstance(output, bytes):
output = output.decode(errors="replace")
if isinstance(stderr, bytes):
stderr = stderr.decode(errors="replace")
result = subprocess.CompletedProcess(cmd, 124, output, stderr)
for line in result.stdout.splitlines():
if "[interactive]" in line:
print(line)
if result.returncode not in (0, 124):
print("STDERR:", result.stderr[-2000:])
snap_sub = snapshot_root/"sprinter"
if snap_sub.is_dir():
print("Скриншоты:")
for shot in sorted(snap_sub.iterdir()):
print(" ", shot)
else:
print("Скриншотов не создано. Каталог:", snapshot_root)
if __name__ == "__main__":
main()