#!/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()