Add smoke test script for z-machine game compatibility
This commit is contained in:
parent
243a44e3fb
commit
b08ce668a6
1 changed files with 248 additions and 0 deletions
248
scripts/smoke_test_games.py
Normal file
248
scripts/smoke_test_games.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
"""Smoke test all Z-machine games.
|
||||||
|
|
||||||
|
Runs each game through a series of basic commands, collecting opcode coverage
|
||||||
|
and detecting crashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
|
import contextlib
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root / "src"))
|
||||||
|
|
||||||
|
from mudlib.zmachine import ZMachine, zopdecoder, zstream, zui
|
||||||
|
from mudlib.zmachine.trivialzui import (
|
||||||
|
TrivialAudio,
|
||||||
|
TrivialFilesystem,
|
||||||
|
TrivialScreen,
|
||||||
|
)
|
||||||
|
from mudlib.zmachine.zcpu import (
|
||||||
|
ZCpuNotImplemented,
|
||||||
|
ZCpuQuit,
|
||||||
|
ZCpuRestart,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoInputStream(zstream.ZInputStream):
|
||||||
|
"""Input stream that auto-feeds commands."""
|
||||||
|
|
||||||
|
def __init__(self, commands=None):
|
||||||
|
super().__init__()
|
||||||
|
self._commands = commands or ["look", "inventory", "north", "south", "look"]
|
||||||
|
self._input_count = 0
|
||||||
|
|
||||||
|
def read_line(self, *args, **kwargs):
|
||||||
|
if self._input_count >= len(self._commands):
|
||||||
|
raise ZCpuQuit
|
||||||
|
cmd = self._commands[self._input_count]
|
||||||
|
self._input_count += 1
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def read_char(self, *args, **kwargs):
|
||||||
|
if self._input_count >= len(self._commands):
|
||||||
|
raise ZCpuQuit
|
||||||
|
cmd = self._commands[self._input_count]
|
||||||
|
self._input_count += 1
|
||||||
|
# Return first character as ord
|
||||||
|
return ord(cmd[0]) if cmd else ord(" ")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestResult:
|
||||||
|
"""Result of testing a single game."""
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
version: int
|
||||||
|
success: bool
|
||||||
|
steps: int
|
||||||
|
unique_opcodes: int
|
||||||
|
opcodes: Counter
|
||||||
|
error_type: str | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
error_pc: int | None = None
|
||||||
|
error_opcode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_game(story_path: Path, max_steps: int = 1_000_000) -> TestResult:
|
||||||
|
"""Test a single game, returning results."""
|
||||||
|
story_bytes = story_path.read_bytes()
|
||||||
|
version = story_bytes[0]
|
||||||
|
|
||||||
|
audio = TrivialAudio()
|
||||||
|
screen = TrivialScreen()
|
||||||
|
keyboard = AutoInputStream()
|
||||||
|
filesystem = TrivialFilesystem()
|
||||||
|
ui = zui.ZUI(audio, screen, keyboard, filesystem)
|
||||||
|
|
||||||
|
zm = ZMachine(story_bytes, ui)
|
||||||
|
|
||||||
|
opcodes_seen = Counter()
|
||||||
|
step_count = 0
|
||||||
|
error_type = None
|
||||||
|
error_message = None
|
||||||
|
error_pc = None
|
||||||
|
error_opcode = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while step_count < max_steps:
|
||||||
|
pc = zm._cpu._opdecoder.program_counter
|
||||||
|
(opcode_class, opcode_number, operands) = (
|
||||||
|
zm._cpu._opdecoder.get_next_instruction()
|
||||||
|
)
|
||||||
|
|
||||||
|
cls_str = zopdecoder.OPCODE_STRINGS.get(opcode_class, f"?{opcode_class}")
|
||||||
|
key = f"{cls_str}:{opcode_number:02x}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
implemented, func = zm._cpu._get_handler(opcode_class, opcode_number)
|
||||||
|
except Exception as e:
|
||||||
|
error_type = "ILLEGAL"
|
||||||
|
error_message = str(e)
|
||||||
|
error_pc = pc
|
||||||
|
error_opcode = key
|
||||||
|
break
|
||||||
|
|
||||||
|
opcodes_seen[f"{key} ({func.__name__})"] += 1
|
||||||
|
|
||||||
|
if not implemented:
|
||||||
|
error_type = "UNIMPLEMENTED"
|
||||||
|
error_message = f"Opcode {key} -> {func.__name__}"
|
||||||
|
error_pc = pc
|
||||||
|
error_opcode = key
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
func(zm._cpu, *operands)
|
||||||
|
except ZCpuQuit:
|
||||||
|
break
|
||||||
|
except ZCpuRestart:
|
||||||
|
break
|
||||||
|
except ZCpuNotImplemented as e:
|
||||||
|
error_type = "NOT_IMPLEMENTED"
|
||||||
|
error_message = str(e)
|
||||||
|
error_pc = pc
|
||||||
|
error_opcode = key
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
error_type = type(e).__name__
|
||||||
|
error_message = str(e)
|
||||||
|
error_pc = pc
|
||||||
|
error_opcode = key
|
||||||
|
# Dump trace for debugging
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
zm._cpu._dump_trace()
|
||||||
|
break
|
||||||
|
|
||||||
|
step_count += 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
error_type = "INTERRUPTED"
|
||||||
|
error_message = "Keyboard interrupt"
|
||||||
|
|
||||||
|
success = error_type is None
|
||||||
|
return TestResult(
|
||||||
|
filename=story_path.name,
|
||||||
|
version=version,
|
||||||
|
success=success,
|
||||||
|
steps=step_count,
|
||||||
|
unique_opcodes=len(opcodes_seen),
|
||||||
|
opcodes=opcodes_seen,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message,
|
||||||
|
error_pc=error_pc,
|
||||||
|
error_opcode=error_opcode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run smoke tests on all games or a specified game."""
|
||||||
|
stories_dir = project_root / "content" / "stories"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
# Test a specific game
|
||||||
|
story_path = Path(sys.argv[1])
|
||||||
|
if not story_path.exists():
|
||||||
|
print(f"ERROR: {story_path} not found")
|
||||||
|
sys.exit(1)
|
||||||
|
story_paths = [story_path]
|
||||||
|
else:
|
||||||
|
# Test all games
|
||||||
|
story_paths = sorted(stories_dir.glob("*.z[358]"))
|
||||||
|
|
||||||
|
if not story_paths:
|
||||||
|
print("No story files found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Testing {len(story_paths)} games...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for story_path in story_paths:
|
||||||
|
print(f"Testing {story_path.name}...", end=" ", flush=True)
|
||||||
|
result = test_game(story_path)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
print(f"OK ({result.steps} steps, {result.unique_opcodes} opcodes)")
|
||||||
|
else:
|
||||||
|
print(f"FAILED: {result.error_type}")
|
||||||
|
if result.error_opcode:
|
||||||
|
print(f" Opcode: {result.error_opcode}")
|
||||||
|
if result.error_pc is not None:
|
||||||
|
print(f" PC: {result.error_pc:#x}")
|
||||||
|
if result.error_message:
|
||||||
|
print(f" Message: {result.error_message}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Print summary table
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
f"{'Game':<20} {'Ver':<4} {'Status':<12} "
|
||||||
|
f"{'Steps':>8} {'Opcodes':>8} {'Error':<20}"
|
||||||
|
)
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
status = "PASS" if result.success else "FAIL"
|
||||||
|
error = result.error_type or "-"
|
||||||
|
print(
|
||||||
|
f"{result.filename:<20} {result.version:<4} {status:<12} "
|
||||||
|
f"{result.steps:>8} {result.unique_opcodes:>8} {error:<20}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
passed = sum(1 for r in results if r.success)
|
||||||
|
failed = len(results) - passed
|
||||||
|
print(f"Total: {len(results)} games, {passed} passed, {failed} failed")
|
||||||
|
|
||||||
|
# Print detailed error information
|
||||||
|
if failed > 0:
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("FAILED GAMES DETAILS")
|
||||||
|
print("=" * 80)
|
||||||
|
for result in results:
|
||||||
|
if not result.success:
|
||||||
|
print()
|
||||||
|
print(f"{result.filename} (V{result.version}):")
|
||||||
|
print(f" Error type: {result.error_type}")
|
||||||
|
print(f" Error message: {result.error_message}")
|
||||||
|
if result.error_pc is not None:
|
||||||
|
print(f" PC: {result.error_pc:#x}")
|
||||||
|
if result.error_opcode:
|
||||||
|
print(f" Opcode: {result.error_opcode}")
|
||||||
|
print(f" Steps completed: {result.steps}")
|
||||||
|
print(f" Unique opcodes seen: {result.unique_opcodes}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue