Add z-machine save file inspection script

Offline diagnostic tool that loads a story + save file and shows
PC, stack frames, player location, objects, and can disassemble
at arbitrary addresses.
This commit is contained in:
Jared Miller 2026-02-10 11:51:55 -05:00
parent 65a080608a
commit 3627ce8245
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

399
scripts/zmachine_inspect.py Executable file
View file

@ -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()