From b08ce668a6a8624a5fce1f493c40fb5863daac47 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 10 Feb 2026 16:58:48 -0500 Subject: [PATCH] Add smoke test script for z-machine game compatibility --- scripts/smoke_test_games.py | 248 ++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 scripts/smoke_test_games.py diff --git a/scripts/smoke_test_games.py b/scripts/smoke_test_games.py new file mode 100644 index 0000000..4509ef8 --- /dev/null +++ b/scripts/smoke_test_games.py @@ -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()