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