Files
Sprinter-SDCC/toolchain/mame_interactive.py
T
snark13 72466f7cad toolchain: скриптовый интерактивный ввод в DSS через MAME
mame_interactive.py печатает произвольный текст в командную строку DSS,
дёргая поля AT/PS-2-клавиатуры :kbd:ms_naturl через Lua set_value
(at_keyboard сам генерит scancode'ы → SIO Z84C015 → DSS). Раньше
инъекция шла в ZX-матрицу :IO_LINE*, которую DSS не читает — отсюда
«нет эффекта». Полная раскладка char→(port,mask,shift) с авто-Shift.

Квирки: attotime.seconds целое (субсекунды через attoseconds/1e18),
клавишу держать коротко (~0.06с, иначе автоповтор), дискета без
AUTORUN.BAT → приглашение C:\>. Проверено end-to-end: dir<Enter> и
запуск теста набором a:\rt_test.exe<Enter> (Shift для ':' и '\').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:18:37 +03:00

288 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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`<Enter> выполняется.
Тонкости, на которых уже наступали:
- `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()