diff --git a/scripts/zmachine_inspect.py b/scripts/zmachine_inspect.py new file mode 100755 index 0000000..4f3cb31 --- /dev/null +++ b/scripts/zmachine_inspect.py @@ -0,0 +1,399 @@ +#!/usr/bin/env -S uv run --script +"""Z-Machine state inspection and debugging tool. + +Loads a story file and optionally applies a Quetzal save, then displays +machine state for debugging z-machine interpreter issues. +""" + +import argparse +import sys +from pathlib import Path + +# Add src to path so we can import mudlib +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root / "src")) + +from mudlib.zmachine.quetzal import ( # noqa: E402 + QuetzalError, + QuetzalMismatchedFile, + QuetzalParser, +) +from mudlib.zmachine.trivialzui import create_zui # noqa: E402 +from mudlib.zmachine.zmachine import ZMachine # noqa: E402 + +# Opcode name tables for disassembly +OP2_NAMES = { + 1: "je", + 2: "jl", + 3: "jg", + 4: "dec_chk", + 5: "inc_chk", + 6: "jin", + 7: "test", + 8: "or", + 9: "and", + 10: "test_attr", + 11: "set_attr", + 12: "clear_attr", + 13: "store", + 14: "insert_obj", + 15: "loadw", + 16: "loadb", + 17: "get_prop", + 18: "get_prop_addr", + 19: "get_next_prop", + 20: "add", + 21: "sub", + 22: "mul", + 23: "div", + 24: "mod", +} + +OP1_NAMES = { + 0: "jz", + 1: "get_sibling", + 2: "get_child", + 3: "get_parent", + 4: "get_prop_len", + 5: "inc", + 6: "dec", + 7: "print_addr", + 8: "call_1s", + 9: "remove_obj", + 10: "print_obj", + 11: "ret", + 12: "jump", + 13: "print_paddr", + 14: "load", + 15: "not/call_1n", +} + +OP0_NAMES = { + 0: "rtrue", + 1: "rfalse", + 2: "print", + 3: "print_ret", + 4: "nop", + 5: "save", + 6: "restore", + 7: "restart", + 8: "ret_popped", + 9: "pop/catch", + 10: "quit", + 11: "new_line", + 12: "show_status", + 13: "verify", + 15: "piracy", +} + +VAR_NAMES = { + 0: "call", + 1: "storew", + 2: "storeb", + 3: "put_prop", + 4: "sread", + 5: "print_char", + 6: "print_num", + 7: "random", + 8: "push", + 9: "pull", + 10: "split_window", + 11: "set_window", + 19: "output_stream", + 20: "input_stream", + 21: "sound_effect", +} + + +def decode_opcode_class(opcode_byte): + """Determine opcode class from the opcode byte.""" + if opcode_byte < 0x80: + return "2OP" + elif opcode_byte < 0xC0: + op_type = (opcode_byte >> 4) & 3 + if op_type == 3: + return "0OP" + else: + return "1OP" + elif opcode_byte < 0xE0: + return "2OP" + else: + return "VAR" + + +def get_opcode_name(opcode_class, opcode_num): + """Get the name of an opcode.""" + if opcode_class == "2OP": + return OP2_NAMES.get(opcode_num, f"unknown_{opcode_num}") + elif opcode_class == "1OP": + return OP1_NAMES.get(opcode_num, f"unknown_{opcode_num}") + elif opcode_class == "0OP": + return OP0_NAMES.get(opcode_num, f"unknown_{opcode_num}") + elif opcode_class == "VAR": + return VAR_NAMES.get(opcode_num, f"unknown_{opcode_num}") + else: + return "unknown" + + +def parse_operand_types(mem, pc, opcode_byte): + """Parse operand types without evaluating them. Returns (types, bytes_consumed).""" + types = [] + pos = pc + + if opcode_byte < 0x80: + # Long form 2OP + types.append("var" if (opcode_byte & 0x40) else "small") + types.append("var" if (opcode_byte & 0x20) else "small") + return types, pos - pc + elif opcode_byte < 0xC0: + # Short form + op_type = (opcode_byte >> 4) & 3 + if op_type == 0: + types.append("large") + elif op_type == 1: + types.append("small") + elif op_type == 2: + types.append("var") + # op_type == 3 means 0OP, no operands + return types, pos - pc + else: + # Variable form - read types byte + types_byte = mem[pos] + pos += 1 + for i in range(4): + t = (types_byte >> (6 - i * 2)) & 3 + if t == 3: + break + elif t == 0: + types.append("large") + elif t == 1: + types.append("small") + elif t == 2: + types.append("var") + return types, pos - pc + + +def disassemble_at(zmachine, addr, count): + """Disassemble count instructions starting at addr.""" + mem = zmachine._mem + pc = addr + + print(f"\n--- disassembly at 0x{addr:06x} ({count} instructions) ---") + + for _ in range(count): + if pc >= len(mem._memory): + print(f" {pc:06x} (out of bounds)") + break + + start_pc = pc + opcode_byte = mem[pc] + pc += 1 + + # Decode opcode class and number + if opcode_byte < 0x80: + # Long form 2OP + op_class = "2OP" + op_num = opcode_byte & 0x1F + elif opcode_byte < 0xC0: + # Short form + op_type = (opcode_byte >> 4) & 3 + if op_type == 3: + op_class = "0OP" + op_num = opcode_byte & 0x0F + else: + op_class = "1OP" + op_num = opcode_byte & 0x0F + elif opcode_byte < 0xE0: + # Variable form 2OP + op_class = "2OP" + op_num = opcode_byte & 0x1F + else: + # Variable form VAR + op_class = "VAR" + op_num = opcode_byte & 0x1F + + op_name = get_opcode_name(op_class, op_num) + + # Parse operand types + operand_types, type_bytes = parse_operand_types(mem, pc, opcode_byte) + pc += type_bytes + + # Skip past operand values (without reading them) + operand_str_parts = [] + for ot in operand_types: + if ot == "large": + if pc + 1 < len(mem._memory): + val = (mem[pc] << 8) | mem[pc + 1] + operand_str_parts.append(f"#{val}") + pc += 2 + elif ot == "small": + if pc < len(mem._memory): + operand_str_parts.append(f"#{mem[pc]}") + pc += 1 + elif ot == "var" and pc < len(mem._memory): + operand_str_parts.append(f"V{mem[pc]:02x}") + pc += 1 + + operands_str = ", ".join(operand_str_parts) + print(f" {start_pc:06x} {op_class}:{op_num:02d} {op_name}({operands_str})") + + +def get_location_info(zmachine): + """Get current location object and its contents.""" + try: + # Global variable 0 (opcode variable 0x10) is the player location + location_obj = zmachine._mem.read_global(0x10) + + if location_obj == 0: + return None, [] + + obj_parser = zmachine._objectparser + + # Try to get the location name + try: + location_name = obj_parser.get_shortname(location_obj) + except Exception: + # Invalid object number - return None + return None, [] + + # Get objects in this location (children) + objects = [] + child = obj_parser.get_child(location_obj) + while child != 0: + try: + child_name = obj_parser.get_shortname(child) + objects.append(child_name) + except Exception: + objects.append(f"object #{child}") + child = obj_parser.get_sibling(child) + + return (location_obj, location_name), objects + + except Exception: + return None, [] + + +def validate_save(zmachine, save_path): + """Validate a save file and print diagnostic info.""" + print("\n--- save file validation ---") + + # Read the save file + try: + with open(save_path, "rb") as f: + save_data = f.read() + except OSError as e: + print(f"ERROR: Cannot read save file: {e}") + return False + + # Parse it + parser = QuetzalParser(zmachine) + try: + parser.load_from_bytes(save_data) + except QuetzalMismatchedFile: + print("ERROR: Save file does not match story file") + print(" (release number, serial, or checksum mismatch)") + return False + except QuetzalError as e: + print(f"ERROR: Invalid Quetzal file: {e}") + return False + except Exception as e: + print(f"ERROR: Failed to parse save file: {e}") + return False + + # Get metadata + metadata = parser.get_last_loaded() + + print("Save file loaded successfully") + print(f" release: {metadata.get('release number', 'unknown')}") + print(f" serial: {metadata.get('serial number', 'unknown')}") + print(f" checksum: 0x{metadata.get('checksum', 0):04x}") + print(f" PC: 0x{metadata.get('program counter', 0):06x}") + + # Check PC is in bounds + pc = metadata.get("program counter", 0) + mem_size = len(zmachine._mem._memory) + if pc >= mem_size: + print(f" WARNING: PC 0x{pc:06x} is out of bounds (size: {mem_size})") + return False + + print(" PC is within story file bounds") + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Inspect z-machine state for debugging" + ) + parser.add_argument("story", help="Path to story file (.z3/.z5/.z8)") + parser.add_argument("--save", help="Path to Quetzal save file (.qzl)") + parser.add_argument( + "--disasm", type=lambda x: int(x, 0), help="Address to disassemble (hex)" + ) + parser.add_argument( + "--count", type=int, default=5, help="Number of instructions to disassemble" + ) + parser.add_argument( + "--globals", action="store_true", help="Show first 16 global variables" + ) + args = parser.parse_args() + + story_path = Path(args.story) + if not story_path.exists(): + print(f"ERROR: Story file not found: {story_path}") + sys.exit(1) + + # Load story + with open(story_path, "rb") as f: + story_bytes = f.read() + + # Create ZMachine + ui = create_zui() + zmachine = ZMachine(story_bytes, ui) + + # Load save if provided + if args.save: + save_path = Path(args.save) + if not save_path.exists(): + print(f"ERROR: Save file not found: {save_path}") + sys.exit(1) + + if not validate_save(zmachine, save_path): + sys.exit(1) + + # Display state + print("\nzmachine state") + print("==============") + print(f"version: {zmachine._mem.version}") + print(f"story size: {len(story_bytes)} bytes") + print(f"PC: 0x{zmachine._opdecoder.program_counter:06x}") + print(f"stack: {len(zmachine._stackmanager._call_stack) - 1} frames") + + # Location info + location, objects = get_location_info(zmachine) + if location: + loc_obj, loc_name = location + print(f"\nlocation: {loc_name} (#{loc_obj})") + if objects: + print("objects: " + ", ".join(objects)) + else: + print("objects: (none)") + else: + print("\nlocation: (unknown)") + + # Global variables + if args.globals: + print("\nglobals (first 16):") + for i in range(16): + global_val = zmachine._mem.read_global(0x10 + i) + print(f" G{i:02d} (V{0x10 + i:02x}): 0x{global_val:04x} ({global_val})") + + # Disassembly + if args.disasm is not None: + disassemble_at(zmachine, args.disasm, args.count) + else: + # Disassemble instructions at PC + disassemble_at(zmachine, zmachine._opdecoder.program_counter, args.count) + + +if __name__ == "__main__": + main()