Files
virtualjaguar-libretro/test/headless.py
Joseph Mattiello 2f25682cac CD Tier 4: import CD test suites, dev tools, and framework headers
Brings in #109's test infrastructure for the CD subsystem. Per the
Tier 5 triage report, redundant general-hardware suites were dropped;
overlapping cherry-picks deferred to a follow-up.

Imported and wired into make test (84 new passing asserts):
  test/test_butch_cd.c, test_bios_config.c, test_boot_config.c,
  test_audio_dac.c

Built but not auto-run (private-ROM gated; surface real per-game
boot regressions when discs are present):
  test/test_cd_boot.c, test_cd_hle_boot.c, test_cd_bios_boot.c,
  test_blitter.c

Framework + helpers + tooling: test_framework.h, cd_assertions.h,
mister_ground_truth.h, dump_pc.c, heap_search.c, headless.py, and
test/tools/{analyze_cd_roms,bios_disasm,disasm_gpu_isr}.py.

Dropped (redundant with our coverage in test_hle_bios, test_dsp_ops,
test_gpu_ops, test_m68k_ops, test_blitter_simd):
  test_dsp_instructions, test_gpu_controlflow, test_gpu_instructions,
  test_m68k_instructions, test_timers, test_irq, test_gpu_ctrl,
  test_video_modes, test_memory_map, test_gpu_irq.

Cherry-picks deferred to a follow-up:
  resolution-derivation cases from test_video_modes ->test_hle_bios,
  gpu_ctrl_bus_hog_readback / gpu_flags_int_enable_all_five from
  test_gpu_ctrl -> test_hle_bios, BUTCH cases from test_irq ->
  test_butch_cd, line_buffer cases from test_memory_map ->
  test_hle_bios.

All imports pass scripts/c89-lint.sh (decls hoisted). test_hle_bios
unchanged at 211; full suite OK: 0 test(s) failed.
2026-05-07 20:53:23 -04:00

163 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""
Headless test runner for the virtualjaguar libretro core.
Drives the built `virtualjaguar_libretro.dylib` (or .so/.dll) via
JesseTG/libretro.py — a Python binding designed for testing libretro cores.
This is a local equivalent of running the core in RetroArch, but completely
headless, deterministic, and scriptable. Use it instead of round-tripping
test logs through a phone or desktop frontend.
Setup (one-time):
python3.12 -m venv .venv-libretropy
source .venv-libretropy/bin/activate
pip install 'libretro.py[cli]'
Usage:
source .venv-libretropy/bin/activate
python test/headless.py <content.cue|.j64|.cdi|.iso> [--frames N] [--cd-bios retail|dev]
The core is auto-detected from the repo root. The system_dir defaults to
test/roms/private/ (where BIOSes are kept). Adjust via --system-dir.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CORE_NAMES = {
"darwin": "virtualjaguar_libretro.dylib",
"linux": "virtualjaguar_libretro.so",
"win32": "virtualjaguar_libretro.dll",
}
def detect_core() -> Path:
name = CORE_NAMES.get(sys.platform, "virtualjaguar_libretro.so")
candidate = REPO_ROOT / name
if not candidate.exists():
sys.exit(f"Core not found at {candidate}. Run `make` first.")
return candidate
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("content", help="Path to game content (.cue, .j64, .cdi, .iso, etc.)")
p.add_argument("--frames", type=int, default=600, help="Frames to run (default: 600)")
p.add_argument("--cd-bios", choices=["retail", "dev"], default="retail",
help="CD BIOS variant (default: retail)")
p.add_argument("--cd-boot-mode", choices=["auto", "hle", "bios"], default="auto",
help="CD boot mode: auto, hle, or bios (default: auto)")
p.add_argument("--core", type=Path, default=None, help="Override core path")
p.add_argument("--system-dir", type=Path, default=REPO_ROOT / "test" / "roms" / "private",
help="Directory containing BIOS files")
p.add_argument("--save-dir", type=Path, default=Path("/tmp/vj_save"),
help="Directory for SRAM/save files")
p.add_argument("--progress-every", type=int, default=60,
help="Print frame progress every N frames (0 = silent)")
p.add_argument("--screenshot", type=Path, default=None,
help="Save final frame as PPM image to this path")
return p.parse_args()
def main() -> int:
args = parse_args()
try:
from libretro import SessionBuilder
from libretro.drivers import PathDriver
except ImportError:
sys.exit(
"libretro.py is not installed. Set up a Python 3.12+ venv and run:\n"
" pip install 'libretro.py[cli]'"
)
core = args.core or detect_core()
content = Path(args.content).resolve()
if not content.exists():
sys.exit(f"Content not found: {content}")
args.save_dir.mkdir(parents=True, exist_ok=True)
if not args.system_dir.exists():
sys.exit(f"system_dir not found: {args.system_dir}")
class FixedPathDriver(PathDriver):
def __init__(self, system: Path, save: Path, corepath: Path):
self._system = str(system).encode()
self._save = str(save).encode()
self._core = str(corepath).encode()
@property
def system_dir(self): return self._system
@property
def libretro_path(self): return self._core
@property
def core_assets_dir(self): return self._system
@property
def save_dir(self): return self._save
@property
def playlist_dir(self): return self._save
@property
def file_browser_start_dir(self): return self._system
@property
def content_dir(self): return self._system
@property
def username(self): return b"libretropy"
@property
def language(self): return None
options = {
"virtualjaguar_bios": "enabled",
"virtualjaguar_usefastblitter": "enabled",
"virtualjaguar_cd_bios_type": args.cd_bios,
"virtualjaguar_cd_boot_mode": args.cd_boot_mode,
}
paths = FixedPathDriver(args.system_dir, args.save_dir, core)
builder = (
SessionBuilder.defaults(str(core))
.with_content(str(content))
.with_options(options)
.with_paths(paths)
)
print(f"Core: {core}", file=sys.stderr)
print(f"Content: {content}", file=sys.stderr)
print(f"Frames: {args.frames}", file=sys.stderr)
with builder.build() as session:
for i in range(args.frames):
session.run()
if args.progress_every and i % args.progress_every == 0:
print(f"frame {i}", file=sys.stderr)
if args.screenshot:
shot = session.video.screenshot()
if shot is None:
print("No frame captured (core has not yet rendered).", file=sys.stderr)
else:
# PPM P6 = simple portable RGB. Strip alpha from ABGR.
w, h = shot.width, shot.height
with open(args.screenshot, "wb") as f:
f.write(f"P6\n{w} {h}\n255\n".encode())
pixels = bytearray(w * h * 3)
src = shot.data
for j in range(w * h):
# ArrayVideoDriver writes ABGR
pixels[j*3+0] = src[j*4+2] # R from B
pixels[j*3+1] = src[j*4+1] # G
pixels[j*3+2] = src[j*4+0] # B from A? actually ABGR -> RGB
f.write(bytes(pixels))
print(f"Screenshot saved: {args.screenshot} ({w}x{h})", file=sys.stderr)
print(f"Done. Ran {args.frames} frames.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())