Compare commits

..

No commits in common. "14816478aa89d0a57a87fe96613da5686d1317e9" and "f4b7d0548bcfbfaba38d5a684e015a5403aa9465" have entirely different histories.

9 changed files with 113 additions and 490 deletions

View file

@ -133,12 +133,12 @@ The games that motivated this work:
The Wizard Sniffer (Buster Hudson, 2017)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You play a pig who sniffs out wizards. IFComp winner, XYZZY winner. Screwball comedy. The pig/wizard game that started this. Glulx format (.gblorb) — out of scope for embedded interpreter unless we subprocess to a Glulx interpreter.
You play a pig who sniffs out wizards. IFComp winner, XYZZY winner. Screwball comedy. The pig/wizard game that started this. Z-machine format. Would need V5 support.
Lost Pig (Admiral Jota, 2007)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Grunk the orc chases an escaped pig. IFComp winner, 4 XYZZY awards. Famous for its responsive parser and comedy writing. Z-machine V8 format (not V5 as originally assumed). Now playable in the hybrid interpreter.
Grunk the orc chases an escaped pig. IFComp winner, 4 XYZZY awards. Famous for its responsive parser and comedy writing. Z-machine format. V5.
Zork I, II, III
~~~~~~~~~~~~~~~
@ -152,7 +152,7 @@ Funny, frustrating, great spectator game. V3.
Also: Anchorhead, Photopia, Spider and Web, Shade, Colossal Cave.
V3 covers the Infocom catalog. V5/V8 covers most modern IF. Lost Pig is V8 (same opcodes as V5, different packed address scaling). Wizard Sniffer is Glulx (out of scope for embedded). V8 support is now implemented.
V3 covers the Infocom catalog. V5 covers most modern IF including the pig games. V8 covers big modern Inform 7 games but is lower priority.
Note: no Python Glulx interpreter exists. Games that target Glulx (some modern Inform 7) are out of scope unless we subprocess to a C interpreter.
@ -205,7 +205,7 @@ All V3 gaps have been resolved. sread tokenization works correctly. save/restore
Do they represent z-machine memory the same way? Both use bytearrays, but header parsing, object table offsets, string encoding — are these compatible enough that porting opcodes is translation, or is it a deeper rewrite?
UPDATE: Resolved. The hybrid interpreter now works end-to-end for both V3 and V8. V8 uses the same object model as V5 (14-byte frames, 48 attributes, 65535 objects) and the same opcodes. The only difference is packed address scaling (×8 vs ×4). Memory layout handled correctly for all supported versions.
UPDATE: Less urgent now that the hybrid interpreter works end-to-end for V3. The layout question mainly matters for V5 opcode porting (Lost Pig, Wizard Sniffer). The hybrid already handles all V3 memory operations correctly.
3. Async model
~~~~~~~~~~~~~~
@ -268,12 +268,6 @@ Concrete next steps, roughly ordered. Update as items get done.
- [x] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available). (zork1.z3 bundled in content/stories/)
- [x] V8 support and Lost Pig: relaxed version gates (V8 = V5 with ×8 packed addresses), implemented extended opcode decoder, ported V5+ opcodes (aread, call_vn/vn2/vs2, save_undo stub, log/art shift, scan_table, tokenize, copy_table, print_unicode). found and fixed: insert_object wrong removal order, double-byte operand decoder reading type bytes interleaved with operands instead of all types first, opcode detection using 7-bit mask instead of 5-bit. Lost Pig now runs to completion (101K steps, 61 opcodes). (done — LostPig.z8 bundled in content/stories/, see ``scripts/trace_lostpig.py``)
- [ ] wire V8 games to MUD: ``EmbeddedIFSession`` currently only handles .z3 files. extend to .z5/.z8 so Lost Pig is playable via ``play lostpig`` in the MUD.
- [ ] implement real save_undo: currently stubs returning -1 ("not available"). a proper implementation needs in-memory state snapshots (dynamic memory + call stack). Lost Pig works without undo but players expect it.
milestone — Zork 1 playable in hybrid interpreter
--------------------------------------------------
@ -322,29 +316,6 @@ What this enables:
- foundation for level 3 (moldable world — write z-machine state from MUD)
- no external dependency on dfrotz for V3 games
milestone — V8 support: Lost Pig playable
------------------------------------------
The hybrid interpreter now supports V8 (and by extension V5) story files. Lost Pig — the 4-time XYZZY award winner about an orc chasing a pig — runs to completion in the interpreter.
What was needed:
- version gates relaxed: V8 uses the same object model and opcodes as V5, just different packed address scaling (×8 vs ×4) and larger max file size (512KB)
- extended opcode decoder: V5+ uses 0xBE prefix for extended opcodes. the decoder was stubbed. implemented reading opcode number + type byte after prefix
- double-byte operand fix: ``call_vs2`` and ``call_vn2`` have two operand type bytes. the old decoder read them interleaved with operands (type1, operands, type2, operands) instead of both types first. refactored into ``_read_type_byte()`` + ``_parse_operand_list()``
- double-byte detection fix: the check used ``opcode[0:7]`` (7-bit mask) instead of ``opcode_num`` (5-bit). never matched, so double-byte opcodes were always mis-parsed
- ``insert_object`` fix: was inserting into new parent before removing from old. corrupted sibling chains when moving within same parent. now removes first per spec
- 15 new opcodes: ``aread``, ``call_vn``/``vn2``/``vs2``, ``catch``, ``check_arg_count``, ``save_undo`` (stub), ``restore_undo`` (stub), ``log_shift``, ``art_shift``, ``scan_table``, ``tokenize``, ``copy_table``, ``set_font``, ``print_unicode``, ``check_unicode``
Lost Pig trace: 101K instructions, 61 unique opcodes, full gameplay loop working (room descriptions, parser, object manipulation). ``scripts/trace_lostpig.py`` for tracing.
What this enables:
- modern IF games compiled with Inform 6/7 to Z-machine V5/V8 format are now playable
- same level 2 inspection works (object tree, location, state reading)
- level 3 write APIs already handle V4-8 object format (14-byte frames, 48 attributes, 65535 objects)
- Wizard Sniffer is Glulx (out of scope), but Lost Pig is the better game anyway
related documents
-----------------

View file

@ -1,127 +0,0 @@
#!/usr/bin/env -S uv run --script
"""Trace Lost Pig V8 opcodes — find what's needed for V5+ support.
Runs the interpreter step-by-step, collecting unique opcodes hit.
When an unimplemented opcode is encountered, reports what was seen
and what's missing.
"""
# ruff: noqa: E402
import sys
from collections import Counter
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,
)
story_path = project_root / "content" / "stories" / "LostPig.z8"
if not story_path.exists():
print(f"ERROR: {story_path} not found")
sys.exit(1)
story_bytes = story_path.read_bytes()
print(f"Loaded LostPig.z8: {len(story_bytes)} bytes, version {story_bytes[0]}")
class AutoInputStream(zstream.ZInputStream):
"""Input stream that auto-feeds commands."""
def __init__(self):
super().__init__()
self._input_count = 0
self._max_inputs = 3
def read_line(self, *args, **kwargs):
self._input_count += 1
if self._input_count > self._max_inputs:
raise ZCpuQuit
return "look"
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
max_steps = 500_000
print(f"Tracing up to {max_steps} steps...")
print()
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:
print(f"ILLEGAL at step {step_count}: {key} (pc={pc:#x})")
print(f" {e}")
break
opcodes_seen[f"{key} ({func.__name__})"] += 1
if not implemented:
print(f"UNIMPLEMENTED at step {step_count}: {key} -> {func.__name__}")
print(f" PC: {pc:#x}")
print()
break
try:
func(zm._cpu, *operands)
except ZCpuQuit:
print(f"Game quit after {step_count} steps")
break
except ZCpuRestart:
print(f"Game restart after {step_count} steps")
break
except ZCpuNotImplemented as e:
print(f"NOT IMPLEMENTED at step {step_count}: {key} -> {func.__name__}")
print(f" PC: {pc:#x}")
print(f" Error: {e}")
print()
break
except Exception as e:
print(f"ERROR at step {step_count}: {key} -> {func.__name__}")
print(f" PC: {pc:#x}")
print(f" Operands: {operands}")
print(f" {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
zm._cpu._dump_trace()
break
step_count += 1
except KeyboardInterrupt:
print(f"\nInterrupted at step {step_count}")
print(f"\nTotal steps: {step_count}")
print(f"Unique opcodes seen: {len(opcodes_seen)}")
print()
print("Opcodes by frequency:")
for op, count in opcodes_seen.most_common():
print(f" {count:>8} {op}")

View file

@ -172,7 +172,7 @@ class QuetzalParser:
self._seen_mem_or_stks = True
cmem = self._zmachine._mem
dynamic_len = (cmem._dynamic_end - cmem._dynamic_start) + 1
dynamic_len = (cmem._dynamic_end - cmem.dynamic_start) + 1
log(f" Dynamic memory length is {dynamic_len}")
self._last_loaded_metadata["dynamic memory length"] = dynamic_len

View file

@ -644,9 +644,8 @@ class ZCpu:
self._stackmanager.pop_stack()
def op_catch(self, *args):
"""Store the current stack frame index (for throw opcode)."""
frame_index = self._stackmanager.get_stack_frame_index()
self._write_result(frame_index)
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_quit(self, *args):
"""Quit the game."""
@ -745,54 +744,8 @@ class ZCpu:
raise ZCpuNotImplemented
def op_aread(self, *args):
"""Read text input from keyboard (V5+).
V5 text buffer: byte 0 = max chars, byte 1 = num chars written,
text starts at byte 2. Stores the terminating ZSCII character
(13 for newline) as result. Does not lowercase (game handles it).
Args:
args[0]: text buffer address
args[1]: parse buffer address (0 = skip tokenization)
args[2]: optional timer interval (tenths of a second, 0 = none)
args[3]: optional timer routine address
"""
text_buffer_addr = args[0]
parse_buffer_addr = args[1] if len(args) > 1 else 0
# Read input from keyboard
text = self._ui.keyboard_input.read_line()
text = text.lower().strip("\n\r")
# Store in text buffer (V5 format: text at byte 2, count at byte 1)
max_len = self._memory[text_buffer_addr]
text = text[:max_len]
self._memory[text_buffer_addr + 1] = len(text)
for i, ch in enumerate(text):
self._memory[text_buffer_addr + 2 + i] = ord(ch)
# Tokenize if parse buffer provided
if parse_buffer_addr != 0:
max_words = self._memory[parse_buffer_addr]
tokens = self._lexer.parse_input(text)
num_words = min(len(tokens), max_words)
self._memory[parse_buffer_addr + 1] = num_words
offset = 0
for i in range(num_words):
word_str, dict_addr = tokens[i]
pos = text.find(word_str, offset)
if pos == -1:
pos = offset
word_len = len(word_str)
base = parse_buffer_addr + 2 + (i * 4)
self._memory.write_word(base, dict_addr)
self._memory[base + 2] = word_len
self._memory[base + 3] = pos + 2 # offset from start of text buffer
offset = pos + word_len
# Store terminating character (13 = newline)
self._write_result(13)
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_char(self, char):
"""Output the given ZSCII character."""
@ -840,9 +793,9 @@ class ZCpu:
"""Set the given window as the active window."""
self._ui.screen.select_window(window_num)
def op_call_vs2(self, routine_addr, *args):
"""Call routine with up to 7 arguments and store the result."""
self._call(routine_addr, args, True)
def op_call_vs2(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_erase_window(self, window_number):
"""Clear the window with the given number. If # is -1, unsplit
@ -851,7 +804,7 @@ class ZCpu:
if window_number == -1:
self.op_split_window(0)
self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
elif window_number == -2:
if window_number == -2:
self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
self._ui.screen.erase_window(zscreen.WINDOW_UPPER)
else:
@ -923,113 +876,41 @@ class ZCpu:
char = self._ui.keyboard_input.read_char()
self._write_result(char)
def op_scan_table(self, x, table, length, *args):
"""Search a table for a value, branch if found, store address (V4+).
Searches length entries starting at table. Each entry is form
bytes wide (default 2). Compares against byte 0-1 (word) or
byte 0 (if form bit 7 is set, compare bytes not words).
form & 0x7f = entry size in bytes.
"""
form = args[0] if len(args) > 0 else 0x82 # default: word entries, 2 bytes wide
entry_size = form & 0x7F
compare_word = not (form & 0x80)
for i in range(length):
addr = table + (i * entry_size)
val = self._memory.read_word(addr) if compare_word else self._memory[addr]
if val == x:
self._write_result(addr)
self._branch(True)
return
self._write_result(0)
self._branch(False)
def op_scan_table(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_not_v5(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_call_vn(self, routine_addr, *args):
"""Call routine with up to 3 arguments and discard the result."""
self._call(routine_addr, args, False)
def op_call_vn(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_call_vn2(self, routine_addr, *args):
"""Call routine with up to 7 arguments and discard the result."""
self._call(routine_addr, args, False)
def op_call_vn2(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_tokenize(self, text_buffer, parse_buffer, *args):
"""Tokenize text in text_buffer into parse_buffer (V5+).
Uses V5 text buffer format (count at byte 1, text at byte 2+).
Optional args[0] = dictionary address, args[1] = flag.
"""
_dictionary = args[0] if len(args) > 0 else 0 # custom dict, not yet used
flag = args[1] if len(args) > 1 else 0
# Read text from V5 text buffer
num_chars = self._memory[text_buffer + 1]
text = ""
for i in range(num_chars):
text += chr(self._memory[text_buffer + 2 + i])
# Tokenize
max_words = self._memory[parse_buffer]
tokens = self._lexer.parse_input(text)
num_words = min(len(tokens), max_words)
if not flag:
self._memory[parse_buffer + 1] = num_words
offset = 0
for i in range(num_words):
word_str, dict_addr = tokens[i]
pos = text.find(word_str, offset)
if pos == -1:
pos = offset
word_len = len(word_str)
base = parse_buffer + 2 + (i * 4)
# When flag is set, only fill in entries that have dict matches
if not flag or dict_addr != 0:
self._memory.write_word(base, dict_addr)
self._memory[base + 2] = word_len
self._memory[base + 3] = pos + 2
offset = pos + word_len
def op_tokenize(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_encode_text(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_copy_table(self, first, second, size):
"""Copy a block of memory, or zero-fill (V5+).
If second is 0, zero-fill first for size bytes.
If size is positive, copy forward (safe for non-overlapping).
If size is negative, copy backward (safe for overlapping).
"""
if second == 0:
for i in range(abs(self._make_signed(size))):
self._memory[first + i] = 0
else:
signed_size = self._make_signed(size)
count = abs(signed_size)
if signed_size >= 0:
# Forward copy (may corrupt overlapping regions)
for i in range(count):
self._memory[second + i] = self._memory[first + i]
else:
# Backward copy (safe for overlapping)
for i in range(count - 1, -1, -1):
self._memory[second + i] = self._memory[first + i]
def op_copy_table(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_table(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_check_arg_count(self, arg_number):
"""Branch if the Nth argument was passed to the current routine."""
current_frame = self._stackmanager._call_stack[-1]
self._branch(arg_number <= current_frame.arg_count)
def op_check_arg_count(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
## EXT opcodes (opcodes 256-284)
@ -1041,65 +922,33 @@ class ZCpu:
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_log_shift(self, number, places):
"""Logical shift: positive places = left, negative = right (V5+).
def op_log_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
Right shift fills with zeros (unsigned/logical shift).
"""
places = self._make_signed(places)
result = (
(number << places) & 0xFFFF
if places >= 0
else (number >> (-places)) & 0xFFFF
)
self._write_result(result)
def op_art_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_art_shift(self, number, places):
"""Arithmetic shift: positive places = left, negative = right (V5+).
Right shift preserves the sign bit (signed/arithmetic shift).
"""
signed_number = self._make_signed(number)
places = self._make_signed(places)
if places >= 0:
result = (signed_number << places) & 0xFFFF
else:
result = self._unmake_signed(signed_number >> (-places))
self._write_result(result)
def op_set_font(self, font_id):
"""Set the current font. Returns the previous font, or 0 if
the requested font is unavailable (V5+).
Font 1 is the normal font. We only support font 1.
"""
if font_id == 1:
self._write_result(1) # was font 1, now font 1
else:
self._write_result(0) # unsupported font
def op_set_font(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_save_undo(self, *args):
"""Save undo state. Store -1 if not available (V5+).
Stores 1 on success, 0 on failure, -1 if not available.
Real undo support deferred; return -1 for now.
"""
self._write_result(self._unmake_signed(-1))
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restore_undo(self, *args):
"""Restore undo state. Store 0 on failure (V5+)."""
self._write_result(0)
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_unicode(self, char_code):
"""Print a Unicode character (V5+, EXT:11)."""
self._ui.screen.write(chr(char_code))
def op_print_unicode(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_check_unicode(self, char_code):
"""Check if Unicode character can be printed/read (V5+, EXT:12).
Bit 0 = can print, bit 1 = can read. We support printing only.
"""
self._write_result(1) # can print, can't read
def op_check_unicode(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
# Declaration of the opcode tables. In a Z-Machine, opcodes are
# divided into tables based on the operand type. Within each

View file

@ -172,9 +172,6 @@ class ZMemory:
elif 4 <= self.version <= 5:
if self._total_size > 262144:
raise ZMemoryBadStoryfileSize
elif self.version == 8:
if self._total_size > 524288:
raise ZMemoryBadStoryfileSize
else:
raise ZMemoryUnsupportedVersion
@ -256,10 +253,6 @@ class ZMemory:
if address < 0 or address > (self._total_size // 4):
raise ZMemoryOutOfBounds
return address * 4
elif self.version == 8:
if address < 0 or address > (self._total_size // 8):
raise ZMemoryOutOfBounds
return address * 8
else:
raise ZMemoryUnsupportedVersion

View file

@ -77,7 +77,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3:
self._objecttree_addr = self._propdefaults_addr + 62
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
self._objecttree_addr = self._propdefaults_addr + 126
else:
raise ZObjectIllegalVersion
@ -90,7 +90,7 @@ class ZObjectParser:
if not (1 <= objectnum <= 255):
raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (9 * (objectnum - 1))
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
if not (1 <= objectnum <= 65535):
log(f"error: there is no object {objectnum}")
raise ZObjectIllegalObjectNumber
@ -111,7 +111,7 @@ class ZObjectParser:
addr += 4 # skip past attributes
result = self._memory[addr : addr + 3]
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
addr += 6 # skip past attributes
result = [
self._memory.read_word(addr),
@ -135,7 +135,7 @@ class ZObjectParser:
# skip past attributes and relatives
if 1 <= self._memory.version <= 3:
addr += 7
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
addr += 12
else:
raise ZObjectIllegalVersion
@ -150,7 +150,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3:
if not (1 <= propnum <= 31):
raise ZObjectIllegalPropertyNumber
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
if not (1 <= propnum <= 63):
raise ZObjectIllegalPropertyNumber
else:
@ -171,7 +171,7 @@ class ZObjectParser:
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)])
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)])
@ -192,7 +192,7 @@ class ZObjectParser:
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
@ -215,7 +215,7 @@ class ZObjectParser:
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
@ -233,7 +233,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3:
max = 32
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
max = 48
else:
raise ZObjectIllegalVersion
@ -269,7 +269,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 4] = new_parent_num
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 6, new_parent_num)
else:
raise ZObjectIllegalVersion
@ -280,7 +280,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 6] = new_child_num
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 10, new_child_num)
else:
raise ZObjectIllegalVersion
@ -291,7 +291,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 5] = new_sibling_num
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 8, new_sibling_num)
else:
raise ZObjectIllegalVersion
@ -330,21 +330,40 @@ class ZObjectParser:
self.set_sibling(objectnum, 0)
def insert_object(self, parent_object, new_child):
"""Prepend object NEW_CHILD to the list of PARENT_OBJECT's children.
"""Prepend object NEW_CHILD to the list of PARENT_OBJECT's children."""
Per the Z-spec: if new_child already has a parent, it is first
removed from that parent's child list, then made the first child
of parent_object."""
# Remember all the original pointers within the new_child
[p, s, c] = self._get_parent_sibling_child(new_child)
# Remove from old parent first (spec says "first removed")
self.remove_object(new_child)
# Now insert as first child of parent_object
# First insert new_child intto the parent_object
original_child = self.get_child(parent_object)
self.set_sibling(new_child, original_child)
self.set_parent(new_child, parent_object)
self.set_child(parent_object, new_child)
if p == 0: # no need to 'remove' new_child, since it wasn't in a tree
return
# Hunt down and remove the new_child from its old location
item = self.get_child(p)
if item == 0:
# new_object claimed to have parent p, but p has no children!?
raise ZObjectMalformedTree
elif item == new_child: # done! new_object was head of list
self.set_child(p, s) # note that s might be 0, that's fine.
else: # walk across list of sibling links
prev = item
current = self.get_sibling(item)
while current != 0:
if current == new_child:
self.set_sibling(prev, s) # s might be 0, that's fine.
break
prev = current
current = self.get_sibling(current)
else:
# we reached the end of the list, never got a match
raise ZObjectMalformedTree
def get_shortname(self, objectnum):
"""Return 'short name' of object number OBJECTNUM as ascii string."""
@ -383,7 +402,7 @@ class ZObjectParser:
return (addr, size)
addr += size
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
@ -429,7 +448,7 @@ class ZObjectParser:
proplist[pnum] = (addr, size)
addr += size
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
@ -497,7 +516,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3:
bf = BitField(self._memory[addr])
return bf[0:5]
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
bf = BitField(self._memory[addr])
return bf[0:6]
else:
@ -536,7 +555,7 @@ class ZObjectParser:
size = bf[5:8] + 1
return size
elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
elif 4 <= self._memory.version <= 5:
bf = BitField(self._memory[size_addr])
if bf[7]:
# Two-byte header: size is in bits 0-5 of this byte

View file

@ -70,7 +70,7 @@ class ZOpDecoder:
log(f"Decode opcode {opcode:x}")
# Determine the opcode type, and hand off further parsing.
if self._memory.version >= 5 and opcode == 0xBE:
if self._memory.version == 5 and opcode == 0xBE:
# Extended opcode
return self._parse_opcode_extended()
@ -122,30 +122,19 @@ class ZOpDecoder:
opcode_num = opcode[0:5]
# Read all type bytes FIRST, before parsing any operands.
# call_vs2 (VAR:12) and call_vn2 (VAR:26) have two type bytes;
# all others have one. Both type bytes must be read before
# operand data starts in the stream.
operand_types = self._read_type_byte()
if opcode_type == OPCODE_VAR and opcode_num in (0xC, 0x1A):
log("Opcode has second operand byte")
operand_types += self._read_type_byte()
# Parse the types byte to retrieve the operands.
operands = self._parse_operands_byte()
# Special case: opcodes 12 and 26 have a second operands byte.
if opcode[0:7] == 0xC or opcode[0:7] == 0x1A:
log("Opcode has second operand byte")
operands += self._parse_operands_byte()
operands = self._parse_operand_list(operand_types)
return (opcode_type, opcode_num, operands)
def _parse_opcode_extended(self):
"""Parse an extended opcode (v5+ feature)."""
log("Opcode is extended")
# Read the extended opcode number
opcode_num = self._get_pc()
log(f"Extended opcode number: {opcode_num:x}")
# Parse the operand types byte and retrieve operands
operands = self._parse_operands_byte()
return (OPCODE_EXT, opcode_num, operands)
raise NotImplementedError("Extended opcodes (v5+) not yet implemented")
def _parse_operand(self, operand_type):
"""Read and return an operand of the given type.
@ -181,30 +170,24 @@ class ZOpDecoder:
return operand
def _read_type_byte(self):
"""Read one operand type byte and return a list of type codes."""
def _parse_operands_byte(self):
"""Parse operands given by the operand byte and return a list of
values.
"""
operand_byte = BitField(self._get_pc())
return [
operands = []
for operand_type in [
operand_byte[6:8],
operand_byte[4:6],
operand_byte[2:4],
operand_byte[0:2],
]
def _parse_operand_list(self, operand_types):
"""Parse operands from a list of type codes, stopping at ABSENT."""
operands = []
for operand_type in operand_types:
]:
operand = self._parse_operand(operand_type)
if operand is None:
break
operands.append(operand)
return operands
def _parse_operands_byte(self):
"""Read one type byte and parse its operands. Used by extended
opcodes and other callers that always have a single type byte."""
return self._parse_operand_list(self._read_type_byte())
return operands
# Public funcs that the ZPU may also need to call, depending on the
# opcode being executed:

View file

@ -54,7 +54,6 @@ class ZRoutine:
self.start_addr = start_addr
self.return_addr = return_addr
self.program_counter = 0 # used when execution interrupted
self.arg_count = len(args) # track number of args passed
if stack is None:
self.stack = []
@ -71,14 +70,14 @@ class ZRoutine:
self.start_addr += 1
# Initialize the local vars in the ZRoutine's dictionary. This is
# only needed on machines v1 through v4. In v5 and v8 machines, all
# local variables are preinitialized to zero.
# only needed on machines v1 through v4. In v5 machines, all local
# variables are preinitialized to zero.
self.local_vars = [0 for _ in range(15)]
if 1 <= zmem.version <= 4:
for i in range(num_local_vars):
self.local_vars[i] = zmem.read_word(self.start_addr)
self.start_addr += 2
elif zmem.version not in (5, 8):
elif zmem.version != 5:
raise ZStackUnsupportedVersion
# Place call arguments into local vars, if available

View file

@ -1,64 +0,0 @@
"""Tests for V8 z-machine version support."""
import pytest
from mudlib.zmachine.zmemory import ZMemory, ZMemoryUnsupportedVersion
def make_minimal_story(version: int, size: int) -> bytes:
"""Create a minimal z-machine story file of the specified version and size."""
story = bytearray(size)
story[0] = version # version byte
# Set static memory base (0x0E) to a reasonable value
story[0x0E] = 0x04
story[0x0F] = 0x00
# Set high memory base (0x04) to end of file
story[0x04] = (size >> 8) & 0xFF
story[0x05] = size & 0xFF
# Set global variables base (0x0C)
story[0x0C] = 0x03
story[0x0D] = 0x00
return bytes(story)
def test_v3_accepts_128kb_story():
"""V3 stories can be up to 128KB (131072 bytes)."""
story = make_minimal_story(3, 131072)
mem = ZMemory(story)
assert mem.version == 3
def test_v5_accepts_256kb_story():
"""V5 stories can be up to 256KB (262144 bytes)."""
story = make_minimal_story(5, 262144)
mem = ZMemory(story)
assert mem.version == 5
def test_v8_accepts_512kb_story():
"""V8 stories can be up to 512KB (524288 bytes)."""
story = make_minimal_story(8, 524288)
mem = ZMemory(story)
assert mem.version == 8
def test_v8_packed_address_scaling():
"""V8 uses ×8 scaling for packed addresses (not ×4 like V5)."""
story = make_minimal_story(8, 10000)
mem = ZMemory(story)
# V8 packed address 100 should map to byte address 800
assert mem.packed_address(100) == 800
def test_v6_unsupported():
"""V6 should still be unsupported (different packed address format)."""
story = make_minimal_story(6, 10000)
with pytest.raises(ZMemoryUnsupportedVersion):
ZMemory(story)
def test_v7_unsupported():
"""V7 should still be unsupported (different packed address format)."""
story = make_minimal_story(7, 10000)
with pytest.raises(ZMemoryUnsupportedVersion):
ZMemory(story)