Compare commits

...

7 commits

Author SHA1 Message Date
14816478aa
Update if-journey.rst with V8/Lost Pig milestone and corrections
Wizard Sniffer is Glulx (.gblorb), not z-machine — out of scope.
Lost Pig is V8, not V5 as originally assumed. Added milestone
section documenting the V8 support work, bugs found and fixed,
and new opcode implementations. Updated game descriptions and
version notes. Added trace_lostpig.py utility script.
2026-02-10 13:53:02 -05:00
8a5ef7b1f6
Implement V5+ opcodes: aread, save_undo, shifts, scan_table, and more
Implements the opcode set needed for Lost Pig (V8):
- op_aread: V5+ input with text at byte 2, char count at byte 1,
  stores terminating character
- op_save_undo/op_restore_undo: stub returning -1/0 (undo not yet
  available, game continues without it)
- op_log_shift/op_art_shift: logical and arithmetic bit shifts
- op_scan_table: table search with configurable entry size and
  word/byte comparison
- op_tokenize: re-tokenize text buffer against dictionary
- op_copy_table: memory copy/zero with forward/backward support
- op_set_font: returns 1 for font 1, 0 for others
- op_print_unicode/op_check_unicode: basic Unicode output support

Lost Pig now runs to completion: 101K steps, 61 unique opcodes,
3 full input cycles with room descriptions rendering correctly.
2026-02-10 13:51:28 -05:00
d71f221277
Implement V5+ call variants and fix double-byte operand decoding
New opcodes: op_call_vn, op_call_vn2, op_call_vs2, op_catch,
op_check_arg_count. All call variants delegate to existing _call().
ZRoutine now tracks arg_count for check_arg_count.

Fixed zopdecoder double-byte operand parsing for call_vs2/call_vn2:
the old code called _parse_operands_byte() twice, but this method
reads both type byte AND operands together. The second call would
read operand data as a type byte. Refactored into _read_type_byte()
+ _parse_operand_list() so both type bytes are read before any
operand data.

Also fixed the double-byte detection: was checking opcode[0:7] (7
bits = 0x7A for call_vn2) instead of opcode_num (5 bits = 0x1A).
The check never matched, so double-byte opcodes were always
mis-parsed.
2026-02-10 13:48:08 -05:00
38e60ae40c
Fix insert_object to remove from old parent before inserting
The old code inserted the object into the new parent first, then
tried to remove it from the old parent. This corrupted the sibling
chain because the object's sibling pointer was already modified.

The Z-spec says "if O already has a parent, it is first removed."
Now delegates to remove_object() before inserting.
2026-02-10 13:47:58 -05:00
e61dcc3ac4
Implement extended opcode decoder for V5+
The 0xBE prefix byte triggers extended opcode parsing. Reads the
opcode number from the next byte, then parses operand types using
the same format as VAR opcodes. Required for all V5+ games.
2026-02-10 13:37:27 -05:00
11d939a70f
Relax version gates to accept V8 story files
V8 uses the same format as V5 (object model, opcodes, stack) with
two differences: packed address scaling (×8 instead of ×4) and max
file size (512KB instead of 256KB).

zmemory: add V8 size validation and packed_address case
zobjectparser: accept version 8 alongside 4-5 in all checks
zstackmanager: allow V8 stack initialization
V6-7 remain unsupported (different packed address format with offsets).
2026-02-10 13:37:22 -05:00
e0573f4229
Fix two zmachine bugs found during code audit
quetzal.py: typo in _parse_umem() — cmem.dynamic_start should be
cmem._dynamic_start. Would crash on uncompressed memory restore.

zcpu.py: op_erase_window had if/if/else instead of if/elif/else.
When window_number was -1, the second if fell through to else,
calling erase_window(-1) after the correct reset path.
2026-02-10 13:30:44 -05:00
9 changed files with 489 additions and 112 deletions

View file

@ -133,12 +133,12 @@ The games that motivated this work:
The Wizard Sniffer (Buster Hudson, 2017) 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. Z-machine format. Would need V5 support. 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.
Lost Pig (Admiral Jota, 2007) 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 format. V5. 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.
Zork I, II, III Zork I, II, III
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@ -152,7 +152,7 @@ Funny, frustrating, great spectator game. V3.
Also: Anchorhead, Photopia, Spider and Web, Shade, Colossal Cave. Also: Anchorhead, Photopia, Spider and Web, Shade, Colossal Cave.
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. 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.
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. 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? 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: 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. 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.
3. Async model 3. Async model
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@ -268,6 +268,12 @@ 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] 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 milestone — Zork 1 playable in hybrid interpreter
-------------------------------------------------- --------------------------------------------------
@ -316,6 +322,29 @@ What this enables:
- foundation for level 3 (moldable world — write z-machine state from MUD) - foundation for level 3 (moldable world — write z-machine state from MUD)
- no external dependency on dfrotz for V3 games - 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 related documents
----------------- -----------------

127
scripts/trace_lostpig.py Normal file
View file

@ -0,0 +1,127 @@
#!/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 self._seen_mem_or_stks = True
cmem = self._zmachine._mem 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}") log(f" Dynamic memory length is {dynamic_len}")
self._last_loaded_metadata["dynamic memory length"] = dynamic_len self._last_loaded_metadata["dynamic memory length"] = dynamic_len

View file

@ -644,8 +644,9 @@ class ZCpu:
self._stackmanager.pop_stack() self._stackmanager.pop_stack()
def op_catch(self, *args): def op_catch(self, *args):
"""TODO: Write docstring here.""" """Store the current stack frame index (for throw opcode)."""
raise ZCpuNotImplemented frame_index = self._stackmanager.get_stack_frame_index()
self._write_result(frame_index)
def op_quit(self, *args): def op_quit(self, *args):
"""Quit the game.""" """Quit the game."""
@ -744,8 +745,54 @@ class ZCpu:
raise ZCpuNotImplemented raise ZCpuNotImplemented
def op_aread(self, *args): def op_aread(self, *args):
"""TODO: Write docstring here.""" """Read text input from keyboard (V5+).
raise ZCpuNotImplemented
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)
def op_print_char(self, char): def op_print_char(self, char):
"""Output the given ZSCII character.""" """Output the given ZSCII character."""
@ -793,9 +840,9 @@ class ZCpu:
"""Set the given window as the active window.""" """Set the given window as the active window."""
self._ui.screen.select_window(window_num) self._ui.screen.select_window(window_num)
def op_call_vs2(self, *args): def op_call_vs2(self, routine_addr, *args):
"""TODO: Write docstring here.""" """Call routine with up to 7 arguments and store the result."""
raise ZCpuNotImplemented self._call(routine_addr, args, True)
def op_erase_window(self, window_number): def op_erase_window(self, window_number):
"""Clear the window with the given number. If # is -1, unsplit """Clear the window with the given number. If # is -1, unsplit
@ -804,7 +851,7 @@ class ZCpu:
if window_number == -1: if window_number == -1:
self.op_split_window(0) self.op_split_window(0)
self._ui.screen.erase_window(zscreen.WINDOW_LOWER) self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
if window_number == -2: elif window_number == -2:
self._ui.screen.erase_window(zscreen.WINDOW_LOWER) self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
self._ui.screen.erase_window(zscreen.WINDOW_UPPER) self._ui.screen.erase_window(zscreen.WINDOW_UPPER)
else: else:
@ -876,41 +923,113 @@ class ZCpu:
char = self._ui.keyboard_input.read_char() char = self._ui.keyboard_input.read_char()
self._write_result(char) self._write_result(char)
def op_scan_table(self, *args): def op_scan_table(self, x, table, length, *args):
"""TODO: Write docstring here.""" """Search a table for a value, branch if found, store address (V4+).
raise ZCpuNotImplemented
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_not_v5(self, *args): def op_not_v5(self, *args):
"""TODO: Write docstring here.""" """TODO: Write docstring here."""
raise ZCpuNotImplemented raise ZCpuNotImplemented
def op_call_vn(self, *args): def op_call_vn(self, routine_addr, *args):
"""TODO: Write docstring here.""" """Call routine with up to 3 arguments and discard the result."""
raise ZCpuNotImplemented self._call(routine_addr, args, False)
def op_call_vn2(self, *args): def op_call_vn2(self, routine_addr, *args):
"""TODO: Write docstring here.""" """Call routine with up to 7 arguments and discard the result."""
raise ZCpuNotImplemented self._call(routine_addr, args, False)
def op_tokenize(self, *args): def op_tokenize(self, text_buffer, parse_buffer, *args):
"""TODO: Write docstring here.""" """Tokenize text in text_buffer into parse_buffer (V5+).
raise ZCpuNotImplemented
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_encode_text(self, *args): def op_encode_text(self, *args):
"""TODO: Write docstring here.""" """TODO: Write docstring here."""
raise ZCpuNotImplemented raise ZCpuNotImplemented
def op_copy_table(self, *args): def op_copy_table(self, first, second, size):
"""TODO: Write docstring here.""" """Copy a block of memory, or zero-fill (V5+).
raise ZCpuNotImplemented
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_print_table(self, *args): def op_print_table(self, *args):
"""TODO: Write docstring here.""" """TODO: Write docstring here."""
raise ZCpuNotImplemented raise ZCpuNotImplemented
def op_check_arg_count(self, *args): def op_check_arg_count(self, arg_number):
"""TODO: Write docstring here.""" """Branch if the Nth argument was passed to the current routine."""
raise ZCpuNotImplemented current_frame = self._stackmanager._call_stack[-1]
self._branch(arg_number <= current_frame.arg_count)
## EXT opcodes (opcodes 256-284) ## EXT opcodes (opcodes 256-284)
@ -922,33 +1041,65 @@ class ZCpu:
"""TODO: Write docstring here.""" """TODO: Write docstring here."""
raise ZCpuNotImplemented raise ZCpuNotImplemented
def op_log_shift(self, *args): def op_log_shift(self, number, places):
"""TODO: Write docstring here.""" """Logical shift: positive places = left, negative = right (V5+).
raise ZCpuNotImplemented
def op_art_shift(self, *args): Right shift fills with zeros (unsigned/logical shift).
"""TODO: Write docstring here.""" """
raise ZCpuNotImplemented places = self._make_signed(places)
result = (
(number << places) & 0xFFFF
if places >= 0
else (number >> (-places)) & 0xFFFF
)
self._write_result(result)
def op_set_font(self, *args): def op_art_shift(self, number, places):
"""TODO: Write docstring here.""" """Arithmetic shift: positive places = left, negative = right (V5+).
raise ZCpuNotImplemented
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_save_undo(self, *args): def op_save_undo(self, *args):
"""TODO: Write docstring here.""" """Save undo state. Store -1 if not available (V5+).
raise ZCpuNotImplemented
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))
def op_restore_undo(self, *args): def op_restore_undo(self, *args):
"""TODO: Write docstring here.""" """Restore undo state. Store 0 on failure (V5+)."""
raise ZCpuNotImplemented self._write_result(0)
def op_print_unicode(self, *args): def op_print_unicode(self, char_code):
"""TODO: Write docstring here.""" """Print a Unicode character (V5+, EXT:11)."""
raise ZCpuNotImplemented self._ui.screen.write(chr(char_code))
def op_check_unicode(self, *args): def op_check_unicode(self, char_code):
"""TODO: Write docstring here.""" """Check if Unicode character can be printed/read (V5+, EXT:12).
raise ZCpuNotImplemented
Bit 0 = can print, bit 1 = can read. We support printing only.
"""
self._write_result(1) # can print, can't read
# Declaration of the opcode tables. In a Z-Machine, opcodes are # Declaration of the opcode tables. In a Z-Machine, opcodes are
# divided into tables based on the operand type. Within each # divided into tables based on the operand type. Within each

View file

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

View file

@ -77,7 +77,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
self._objecttree_addr = self._propdefaults_addr + 62 self._objecttree_addr = self._propdefaults_addr + 62
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
self._objecttree_addr = self._propdefaults_addr + 126 self._objecttree_addr = self._propdefaults_addr + 126
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -90,7 +90,7 @@ class ZObjectParser:
if not (1 <= objectnum <= 255): if not (1 <= objectnum <= 255):
raise ZObjectIllegalObjectNumber raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (9 * (objectnum - 1)) result = self._objecttree_addr + (9 * (objectnum - 1))
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
if not (1 <= objectnum <= 65535): if not (1 <= objectnum <= 65535):
log(f"error: there is no object {objectnum}") log(f"error: there is no object {objectnum}")
raise ZObjectIllegalObjectNumber raise ZObjectIllegalObjectNumber
@ -111,7 +111,7 @@ class ZObjectParser:
addr += 4 # skip past attributes addr += 4 # skip past attributes
result = self._memory[addr : addr + 3] result = self._memory[addr : addr + 3]
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
addr += 6 # skip past attributes addr += 6 # skip past attributes
result = [ result = [
self._memory.read_word(addr), self._memory.read_word(addr),
@ -135,7 +135,7 @@ class ZObjectParser:
# skip past attributes and relatives # skip past attributes and relatives
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
addr += 7 addr += 7
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
addr += 12 addr += 12
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -150,7 +150,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
if not (1 <= propnum <= 31): if not (1 <= propnum <= 31):
raise ZObjectIllegalPropertyNumber raise ZObjectIllegalPropertyNumber
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
if not (1 <= propnum <= 63): if not (1 <= propnum <= 63):
raise ZObjectIllegalPropertyNumber raise ZObjectIllegalPropertyNumber
else: else:
@ -171,7 +171,7 @@ class ZObjectParser:
raise ZObjectIllegalAttributeNumber raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)]) bf = BitField(self._memory[object_addr + (attrnum // 8)])
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
if not (0 <= attrnum <= 47): if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)]) bf = BitField(self._memory[object_addr + (attrnum // 8)])
@ -192,7 +192,7 @@ class ZObjectParser:
byte_offset = attrnum // 8 byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset]) bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
if not (0 <= attrnum <= 47): if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8 byte_offset = attrnum // 8
@ -215,7 +215,7 @@ class ZObjectParser:
byte_offset = attrnum // 8 byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset]) bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
if not (0 <= attrnum <= 47): if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8 byte_offset = attrnum // 8
@ -233,7 +233,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
max = 32 max = 32
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
max = 48 max = 48
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -269,7 +269,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum) addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
self._memory[addr + 4] = new_parent_num self._memory[addr + 4] = new_parent_num
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
self._memory.write_word(addr + 6, new_parent_num) self._memory.write_word(addr + 6, new_parent_num)
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -280,7 +280,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum) addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
self._memory[addr + 6] = new_child_num self._memory[addr + 6] = new_child_num
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
self._memory.write_word(addr + 10, new_child_num) self._memory.write_word(addr + 10, new_child_num)
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -291,7 +291,7 @@ class ZObjectParser:
addr = self._get_object_addr(objectnum) addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
self._memory[addr + 5] = new_sibling_num self._memory[addr + 5] = new_sibling_num
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
self._memory.write_word(addr + 8, new_sibling_num) self._memory.write_word(addr + 8, new_sibling_num)
else: else:
raise ZObjectIllegalVersion raise ZObjectIllegalVersion
@ -330,40 +330,21 @@ class ZObjectParser:
self.set_sibling(objectnum, 0) self.set_sibling(objectnum, 0)
def insert_object(self, parent_object, new_child): 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.
# Remember all the original pointers within the new_child Per the Z-spec: if new_child already has a parent, it is first
[p, s, c] = self._get_parent_sibling_child(new_child) removed from that parent's child list, then made the first child
of parent_object."""
# First insert new_child intto the parent_object # Remove from old parent first (spec says "first removed")
self.remove_object(new_child)
# Now insert as first child of parent_object
original_child = self.get_child(parent_object) original_child = self.get_child(parent_object)
self.set_sibling(new_child, original_child) self.set_sibling(new_child, original_child)
self.set_parent(new_child, parent_object) self.set_parent(new_child, parent_object)
self.set_child(parent_object, new_child) 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): def get_shortname(self, objectnum):
"""Return 'short name' of object number OBJECTNUM as ascii string.""" """Return 'short name' of object number OBJECTNUM as ascii string."""
@ -402,7 +383,7 @@ class ZObjectParser:
return (addr, size) return (addr, size)
addr += size addr += size
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
while self._memory[addr] != 0: while self._memory[addr] != 0:
bf = BitField(self._memory[addr]) bf = BitField(self._memory[addr])
addr += 1 addr += 1
@ -448,7 +429,7 @@ class ZObjectParser:
proplist[pnum] = (addr, size) proplist[pnum] = (addr, size)
addr += size addr += size
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
while self._memory[addr] != 0: while self._memory[addr] != 0:
bf = BitField(self._memory[addr]) bf = BitField(self._memory[addr])
addr += 1 addr += 1
@ -516,7 +497,7 @@ class ZObjectParser:
if 1 <= self._memory.version <= 3: if 1 <= self._memory.version <= 3:
bf = BitField(self._memory[addr]) bf = BitField(self._memory[addr])
return bf[0:5] return bf[0:5]
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
bf = BitField(self._memory[addr]) bf = BitField(self._memory[addr])
return bf[0:6] return bf[0:6]
else: else:
@ -555,7 +536,7 @@ class ZObjectParser:
size = bf[5:8] + 1 size = bf[5:8] + 1
return size return size
elif 4 <= self._memory.version <= 5: elif 4 <= self._memory.version <= 5 or self._memory.version == 8:
bf = BitField(self._memory[size_addr]) bf = BitField(self._memory[size_addr])
if bf[7]: if bf[7]:
# Two-byte header: size is in bits 0-5 of this byte # 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}") log(f"Decode opcode {opcode:x}")
# Determine the opcode type, and hand off further parsing. # 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 # Extended opcode
return self._parse_opcode_extended() return self._parse_opcode_extended()
@ -122,19 +122,30 @@ class ZOpDecoder:
opcode_num = opcode[0:5] opcode_num = opcode[0:5]
# Parse the types byte to retrieve the operands. # Read all type bytes FIRST, before parsing any operands.
operands = self._parse_operands_byte() # call_vs2 (VAR:12) and call_vn2 (VAR:26) have two type bytes;
# all others have one. Both type bytes must be read before
# Special case: opcodes 12 and 26 have a second operands byte. # operand data starts in the stream.
if opcode[0:7] == 0xC or opcode[0:7] == 0x1A: operand_types = self._read_type_byte()
if opcode_type == OPCODE_VAR and opcode_num in (0xC, 0x1A):
log("Opcode has second operand byte") log("Opcode has second operand byte")
operands += self._parse_operands_byte() operand_types += self._read_type_byte()
operands = self._parse_operand_list(operand_types)
return (opcode_type, opcode_num, operands) return (opcode_type, opcode_num, operands)
def _parse_opcode_extended(self): def _parse_opcode_extended(self):
"""Parse an extended opcode (v5+ feature).""" """Parse an extended opcode (v5+ feature)."""
raise NotImplementedError("Extended opcodes (v5+) not yet implemented") 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)
def _parse_operand(self, operand_type): def _parse_operand(self, operand_type):
"""Read and return an operand of the given type. """Read and return an operand of the given type.
@ -170,25 +181,31 @@ class ZOpDecoder:
return operand return operand
def _parse_operands_byte(self): def _read_type_byte(self):
"""Parse operands given by the operand byte and return a list of """Read one operand type byte and return a list of type codes."""
values.
"""
operand_byte = BitField(self._get_pc()) operand_byte = BitField(self._get_pc())
operands = [] return [
for operand_type in [
operand_byte[6:8], operand_byte[6:8],
operand_byte[4:6], operand_byte[4:6],
operand_byte[2:4], operand_byte[2:4],
operand_byte[0:2], 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) operand = self._parse_operand(operand_type)
if operand is None: if operand is None:
break break
operands.append(operand) operands.append(operand)
return operands 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())
# Public funcs that the ZPU may also need to call, depending on the # Public funcs that the ZPU may also need to call, depending on the
# opcode being executed: # opcode being executed:

View file

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

64
tests/test_zmemory_v8.py Normal file
View file

@ -0,0 +1,64 @@
"""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)