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>
This commit is contained in:
@@ -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`<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()
|
||||||
Reference in New Issue
Block a user