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:
parent
65a080608a
commit
3627ce8245
1 changed files with 399 additions and 0 deletions
399
scripts/zmachine_inspect.py
Executable file
399
scripts/zmachine_inspect.py
Executable 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()
|
||||||
Loading…
Reference in a new issue