Port 12 trivial opcodes to hybrid z-machine interpreter

This commit is contained in:
Jared Miller 2026-02-09 19:06:56 -05:00
parent 677ddac89f
commit dcc952d4c5
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
22 changed files with 4925 additions and 0 deletions

View file

@ -0,0 +1,27 @@
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the ZVM project nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,9 @@
"""Hybrid z-machine interpreter based on sussman/zvm.
Original: https://github.com/sussman/zvm (BSD license)
Extended with opcode implementations ported from DFillmore/viola.
"""
from .zmachine import ZMachine
__all__ = ["ZMachine"]

View file

@ -0,0 +1,62 @@
#
# A helper class to access individual bits of a bitfield in a Pythonic
# way.
#
# Inspired from a recipe at:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/113799
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class BitField:
"""An bitfield gives read/write access to the individual bits of a
value, in array and slice notation.
Conversion back to an int value is also supported, and a method is
provided to print the value in binary for debug purposes.
For all indexes, 0 is the LSB (Least Significant Bit)."""
def __init__(self, value=0):
"""Initialize a bitfield object from a number or string value."""
if isinstance(value, str):
self._d = ord(value)
else:
self._d = value
def __getitem__(self, index):
"""Get the value of a single bit or slice."""
if isinstance(index, slice):
start, stop = index.start, index.stop
if start > stop:
(start, stop) = (stop, start)
mask = (1<<(stop - start)) -1
return (self._d >> start) & mask
else:
return (self._d >> index) & 1
def __setitem__(self, index, value):
"""Set the value of a single bit or slice."""
if isinstance(value, str):
value = ord(value)
if isinstance(index, slice):
start, stop = index.start, index.stop
mask = (1<<(stop - start)) -1
value = (value & mask) << start
mask = mask << start
self._d = (self._d & ~mask) | value
return (self._d >> start) & mask
else:
value = (value) << index
mask = (1) << index
self._d = (self._d & ~mask) | value
def __int__(self):
"""Return the whole bitfield as an integer."""
return self._d
def to_str(self, len):
"""Print the binary representation of the bitfield."""
return ''.join(["%d" % self[i]
for i in range(len-1,-1,-1)])

347
src/mudlib/zmachine/glk.py Normal file
View file

@ -0,0 +1,347 @@
#
# This module defines a ctypes foreign function interface to a Glk
# library that has been built as a shared library. For more information
# on the Glk API, see http://www.eblong.com/zarf/glk/.
#
# Note that the way this module interfaces with a Glk library is
# slightly different from the standard; the standard interface
# actually assumes that a Glk library is in fact not a library, but a
# "front-end" program that is statically linked to a Glk back-end,
# also known as a Glk "program", by calling the back-end's glk_main()
# function.
#
# Instead of this, we assume that the Glk library is actually a shared
# library that is initialized by some external source--be it a Python
# script or a compiled program--and used by this module. Note that
# this is actually the way some Glk libraries, such as WindowsGlk, are
# made to function.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import ctypes
# These are ctypes-style declarations that reflect the Glk.h file,
# which defines the Glk API, version 0.7.0. The most recent version
# of Glk.h can be found here:
#
# http://www.eblong.com/zarf/glk/glk.h
#
# Note that there are ctypes extension libraries that can do this kind
# of thing for us (that is, take a .h file and automatically generate
# a ctypes wrapper from it); however, the only one that exists at the
# time of this writing is ctypeslib, which has dependencies that would
# make our build process quite complex. Given the relatively small
# size of the Glk API and the freedom we get from hand-coding the
# interface ourselves, we're not using ctypeslib.
glsi32 = ctypes.c_int32
glui32 = ctypes.c_uint32
winid_t = ctypes.c_void_p
strid_t = ctypes.c_void_p
frefid_t = ctypes.c_void_p
schanid_t = ctypes.c_void_p
# TRUE, FALSE, and NULL aren't defined in glk.h, but are mentioned in
# Section 1.9 of the Glk spec 0.7.0.
TRUE = 1
FALSE = 0
NULL = ctypes.pointer(glui32(0))
gestalt_Version = 0
gestalt_CharInput = 1
gestalt_LineInput = 2
gestalt_CharOutput = 3
gestalt_CharOutput_CannotPrint = 0
gestalt_CharOutput_ApproxPrint = 1
gestalt_CharOutput_ExactPrint = 2
gestalt_MouseInput = 4
gestalt_Timer = 5
gestalt_Graphics = 6
gestalt_DrawImage = 7
gestalt_Sound = 8
gestalt_SoundVolume = 9
gestalt_SoundNotify = 10
gestalt_Hyperlinks = 11
gestalt_HyperlinkInput = 12
gestalt_SoundMusic = 13
gestalt_GraphicsTransparency = 14
gestalt_Unicode = 15
evtype_None = 0
evtype_Timer = 1
evtype_CharInput = 2
evtype_LineInput = 3
evtype_MouseInput = 4
evtype_Arrange = 5
evtype_Redraw = 6
evtype_SoundNotify = 7
evtype_Hyperlink = 8
class event_t(ctypes.Structure):
_fields_ = [("type", glui32),
("win", winid_t),
("val1", glui32),
("val2", glui32)]
keycode_Unknown = 0xffffffff
keycode_Left = 0xfffffffe
keycode_Right = 0xfffffffd
keycode_Up = 0xfffffffc
keycode_Down = 0xfffffffb
keycode_Return = 0xfffffffa
keycode_Delete = 0xfffffff9
keycode_Escape = 0xfffffff8
keycode_Tab = 0xfffffff7
keycode_PageUp = 0xfffffff6
keycode_PageDown = 0xfffffff5
keycode_Home = 0xfffffff4
keycode_End = 0xfffffff3
keycode_Func1 = 0xffffffef
keycode_Func2 = 0xffffffee
keycode_Func3 = 0xffffffed
keycode_Func4 = 0xffffffec
keycode_Func5 = 0xffffffeb
keycode_Func6 = 0xffffffea
keycode_Func7 = 0xffffffe9
keycode_Func8 = 0xffffffe8
keycode_Func9 = 0xffffffe7
keycode_Func10 = 0xffffffe6
keycode_Func11 = 0xffffffe5
keycode_Func12 = 0xffffffe4
keycode_MAXVAL = 28
style_Normal = 0
style_Emphasized = 1
style_Preformatted = 2
style_Header = 3
style_Subheader = 4
style_Alert = 5
style_Note = 6
style_BlockQuote = 7
style_Input = 8
style_User1 = 9
style_User2 = 10
style_NUMSTYLES = 11
class stream_result_t(ctypes.Structure):
_fields_ = [("readcount", glui32),
("writecount", glui32)]
wintype_AllTypes = 0
wintype_Pair = 1
wintype_Blank = 2
wintype_TextBuffer = 3
wintype_TextGrid = 4
wintype_Graphics = 5
winmethod_Left = 0x00
winmethod_Right = 0x01
winmethod_Above = 0x02
winmethod_Below = 0x03
winmethod_DirMask = 0x0f
winmethod_Fixed = 0x10
winmethod_Proportional = 0x20
winmethod_DivisionMask = 0xf0
fileusage_Data = 0x00
fileusage_SavedGame = 0x01
fileusage_Transcript = 0x02
fileusage_InputRecord = 0x03
fileusage_TypeMask = 0x0f
fileusage_TextMode = 0x100
fileusage_BinaryMode = 0x000
filemode_Write = 0x01
filemode_Read = 0x02
filemode_ReadWrite = 0x03
filemode_WriteAppend = 0x05
seekmode_Start = 0
seekmode_Current = 1
seekmode_End = 2
stylehint_Indentation = 0
stylehint_ParaIndentation = 1
stylehint_Justification = 2
stylehint_Size = 3
stylehint_Weight = 4
stylehint_Oblique = 5
stylehint_Proportional = 6
stylehint_TextColor = 7
stylehint_BackColor = 8
stylehint_ReverseColor = 9
stylehint_NUMHINTS = 10
stylehint_just_LeftFlush = 0
stylehint_just_LeftRight = 1
stylehint_just_Centered = 2
stylehint_just_RightFlush = 3
# Function prototypes for the core Glk API. It is a list of 3-tuples; each
# item in the list represents a function prototype, and each 3-tuple
# is in the form (result_type, function_name, arg_types).
CORE_GLK_LIB_API = [
(None, "glk_exit", ()),
(None, "glk_tick", ()),
(glui32, "glk_gestalt", (glui32, glui32)),
(glui32, "glk_gestalt_ext", (glui32, glui32, ctypes.POINTER(glui32),
glui32)),
(winid_t, "glk_window_get_root", ()),
(winid_t, "glk_window_open", (winid_t, glui32, glui32, glui32, glui32)),
(None, "glk_window_close", (winid_t, ctypes.POINTER(stream_result_t))),
(None, "glk_window_get_size", (winid_t, ctypes.POINTER(glui32),
ctypes.POINTER(glui32)) ),
(None, "glk_window_set_arrangement", (winid_t, glui32, glui32, winid_t)),
(None, "glk_window_get_arrangement", (winid_t, ctypes.POINTER(glui32),
ctypes.POINTER(glui32),
ctypes.POINTER(winid_t))),
(winid_t, "glk_window_iterate", (winid_t, ctypes.POINTER(glui32))),
(glui32, "glk_window_get_rock", (winid_t,)),
(glui32, "glk_window_get_type", (winid_t,)),
(winid_t, "glk_window_get_parent", (winid_t,)),
(winid_t, "glk_window_get_sibling", (winid_t,)),
(None, "glk_window_clear", (winid_t,)),
(None, "glk_window_move_cursor", (winid_t, glui32, glui32)),
(strid_t, "glk_window_get_stream", (winid_t,)),
(None, "glk_window_set_echo_stream", (winid_t, strid_t)),
(strid_t, "glk_window_get_echo_stream", (winid_t,)),
(None, "glk_set_window", (winid_t,)),
(strid_t, "glk_stream_open_file", (frefid_t, glui32, glui32)),
(strid_t, "glk_stream_open_memory", (ctypes.c_char_p,
glui32, glui32, glui32)),
(None, "glk_stream_close", (strid_t, ctypes.POINTER(stream_result_t))),
(strid_t, "glk_stream_iterate", (strid_t, ctypes.POINTER(glui32))),
(glui32, "glk_stream_get_rock", (strid_t,)),
(None, "glk_stream_set_position", (strid_t, glsi32, glui32)),
(glui32, "glk_stream_get_position", (strid_t,)),
(None, "glk_stream_set_current", (strid_t,)),
(strid_t, "glk_stream_get_current", ()),
(None, "glk_put_char", (ctypes.c_ubyte,)),
(None, "glk_put_char_stream", (strid_t, ctypes.c_ubyte)),
(None, "glk_put_string", (ctypes.c_char_p,)),
(None, "glk_put_string_stream", (strid_t, ctypes.c_char_p)),
(None, "glk_put_buffer", (ctypes.c_char_p, glui32)),
(None, "glk_put_buffer_stream", (strid_t, ctypes.c_char_p, glui32)),
(None, "glk_set_style", (glui32,)),
(None, "glk_set_style_stream", (strid_t, glui32)),
(glsi32, "glk_get_char_stream", (strid_t,)),
(glui32, "glk_get_line_stream", (strid_t, ctypes.c_char_p, glui32)),
(glui32, "glk_get_buffer_stream", (strid_t, ctypes.c_char_p, glui32)),
(None, "glk_stylehint_set", (glui32, glui32, glui32, glsi32)),
(None, "glk_stylehint_clear", (glui32, glui32, glui32)),
(glui32, "glk_style_distinguish", (winid_t, glui32, glui32)),
(glui32, "glk_style_measure", (winid_t, glui32, glui32,
ctypes.POINTER(glui32))),
(frefid_t, "glk_fileref_create_temp", (glui32, glui32)),
(frefid_t, "glk_fileref_create_by_name", (glui32, ctypes.c_char_p,
glui32)),
(frefid_t, "glk_fileref_create_by_prompt", (glui32, glui32, glui32)),
(frefid_t, "glk_fileref_create_from_fileref", (glui32, frefid_t,
glui32)),
(None, "glk_fileref_destroy", (frefid_t,)),
(frefid_t, "glk_fileref_iterate", (frefid_t, ctypes.POINTER(glui32))),
(glui32, "glk_fileref_get_rock", (frefid_t,)),
(None, "glk_fileref_delete_file", (frefid_t,)),
(glui32, "glk_fileref_does_file_exist", (frefid_t,)),
(None, "glk_select", (ctypes.POINTER(event_t),)),
(None, "glk_select_poll", (ctypes.POINTER(event_t),)),
(None, "glk_request_timer_events", (glui32,)),
(None, "glk_request_line_event", (winid_t, ctypes.c_char_p, glui32,
glui32)),
(None, "glk_request_char_event", (winid_t,)),
(None, "glk_request_mouse_event", (winid_t,)),
(None, "glk_cancel_line_event", (winid_t, ctypes.POINTER(event_t))),
(None, "glk_cancel_char_event", (winid_t,)),
(None, "glk_cancel_mouse_event", (winid_t,)),
]
# Function prototypes for the optional Unicode extension of the Glk
# API.
UNICODE_GLK_LIB_API = [
(None, "glk_put_char_uni", (glui32,)),
(None, "glk_put_string_uni", (ctypes.POINTER(glui32),)),
(None, "glk_put_buffer_uni", (ctypes.POINTER(glui32), glui32)),
(None, "glk_put_char_stream_uni", (strid_t, glui32)),
(None, "glk_put_string_stream_uni", (strid_t, ctypes.POINTER(glui32))),
(None, "glk_put_buffer_stream_uni", (strid_t, ctypes.POINTER(glui32),
glui32)),
(glsi32, "glk_get_char_stream_uni", (strid_t,)),
(glui32, "glk_get_buffer_stream_uni", (strid_t, ctypes.POINTER(glui32),
glui32)),
(glui32, "glk_get_line_stream_uni", (strid_t, ctypes.POINTER(glui32),
glui32)),
(strid_t, "glk_stream_open_file_uni", (frefid_t, glui32, glui32)),
(strid_t, "glk_stream_open_memory_uni", (ctypes.POINTER(glui32),
glui32, glui32, glui32)),
(None, "glk_request_char_event_uni", (winid_t,)),
(None, "glk_request_line_event_uni", (winid_t, ctypes.POINTER(glui32),
glui32, glui32))
]
class GlkLib:
"""Encapsulates the ctypes interface to a Glk shared library. When
instantiated, it wraps the shared library with the appropriate
function prototypes from the Glk API to reduce the chance of
mis-calls that may result in segfaults (this effectively simulates
the strong type-checking a C compiler would perform)."""
def __init__(self, lib_name):
"""Instantiates the instance, binding it to the given shared
library (which is referenced by name)."""
self._dll = ctypes.CDLL(lib_name)
self.__bind_prototypes(CORE_GLK_LIB_API)
if self.glk_gestalt(gestalt_Unicode, 0) == 1:
self.__bind_prototypes(UNICODE_GLK_LIB_API)
else:
self.__bind_not_implemented_prototypes(UNICODE_GLK_LIB_API)
def __bind_prototypes(self, function_prototypes):
"""Create function prototypes from the given list of 3-tuples
of the form (result_type, function_name, arg_types), bind them
to functions in our shared library, and then bind the function
instances as methods to this object."""
for function_prototype in function_prototypes:
result_type, function_name, arg_types = function_prototype
prototype = ctypes.CFUNCTYPE(result_type, *arg_types)
function = prototype((function_name, self._dll))
setattr(self, function_name, function)
def __bind_not_implemented_prototypes(self, function_prototypes):
"""Create functions with the names from the given list of
3-tuples of the form (result_type, function_name, arg_types)
that simply raise NotImplementedError, and bind them to this
object. This should be used when a Glk library doesn't
support some optional extension of the Glk API."""
def notImplementedFunction(*args, **kwargs):
raise NotImplementedError( "Function not implemented " \
"by this Glk library." )
for function_prototype in function_prototypes:
_, function_name, _ = function_prototype
setattr(self, function_name, notImplementedFunction)
def glk_char_to_lower(self, ch):
raise NotImplementedError("Use unicode.lower() instead.")
def glk_char_to_upper(self, ch):
raise NotImplementedError("Use unicode.upper() instead.")
def glk_buffer_to_lower_case_uni(self, buf, len, numchars):
raise NotImplementedError("Use unicode.lower() instead.")
def glk_buffer_to_upper_case_uni(self, buf, len, numchars):
raise NotImplementedError("Use unicode.upper() instead.")
def glk_buffer_to_title_case_uni(self, buf, len, numchars, lowerrest):
raise NotImplementedError("Use unicode.title() instead.")

View file

@ -0,0 +1,463 @@
#
# A class which knows how to write and parse 'Quetzal' files, which is
# the standard save-file format for modern Z-machine implementations.
# This allows ZVM's saved games to load in other interpreters, and
# vice versa.
#
# The Quetzal format is documented at:
# http://www.ifarchive.org/if-archive/infocom/\
# interpreters/specification/savefile_14.txt
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Woohoo! Python has a module to parse IFF files, which is a generic
# interchange format. A Quetzal file is in fact a type of IFF file.
import chunk
import os
from . import bitfield, zstackmanager
from .zlogging import log
# The general format of Queztal is that of a "FORM" IFF file, which is
# a container class for 'chunks'.
#
# "FORM", 4 bytes of container total-length, "IFZS",
# 4-byte chunkname, 4-byte length, length bytes of data
# 4-byte chunkname, 4-byte length, length bytes of data
# 4-byte chunkname, 4-byte length, length bytes of data
# ...
class QuetzalError(Exception):
"General exception for Quetzal classes."
pass
class QuetzalMalformedChunk(QuetzalError):
"Malformed chunk detected."
class QuetzalNoSuchSavefile(QuetzalError):
"Cannot locate save-game file."
class QuetzalUnrecognizedFileFormat(QuetzalError):
"Not a valid Quetzal file."
class QuetzalIllegalChunkOrder(QuetzalError):
"IFhd chunk came after Umem/Cmem/Stks chunks (see section 5.4)."
class QuetzalMismatchedFile(QuetzalError):
"Quetzal file dosen't match current game."
class QuetzalMemoryOutOfBounds(QuetzalError):
"Decompressed dynamic memory has gone out of bounds."
class QuetzalMemoryMismatch(QuetzalError):
"Savefile's dynamic memory image is incorrectly sized."
class QuetzalStackFrameOverflow(QuetzalError):
"Stack frame parsing went beyond bounds of 'Stks' chunk."
class QuetzalParser:
"""A class to read a Quetzal save-file and modify a z-machine."""
def __init__(self, zmachine):
log("Creating new instance of QuetzalParser")
self._zmachine = zmachine
self._seen_mem_or_stks = False
self._last_loaded_metadata = {} # metadata for tests & debugging
def _parse_ifhd(self, data):
"""Parse a chunk of type IFhd, and check that the quetzal file
really belongs to the current story (by comparing release number,
serial number, and checksum.)"""
# Spec says that this chunk *must* come before memory or stack chunks.
if self._seen_mem_or_stks:
raise QuetzalIllegalChunkOrder
bytes = data
if len(bytes) != 13:
raise QuetzalMalformedChunk
chunk_release = (data[0] << 8) + data[1]
chunk_serial = data[2:8]
chunk_checksum = (data[8] << 8) + data[9]
chunk_pc = (data[10] << 16) + (data[11] << 8) + data[12]
self._zmachine._opdecoder.program_counter = chunk_pc
log(" Found release number %d" % chunk_release)
log(" Found serial number %d" % int(chunk_serial))
log(" Found checksum %d" % chunk_checksum)
log(" Initial program counter value is %d" % chunk_pc)
self._last_loaded_metadata["release number"] = chunk_release
self._last_loaded_metadata["serial number"] = chunk_serial
self._last_loaded_metadata["checksum"] = chunk_checksum
self._last_loaded_metadata["program counter"] = chunk_pc
# Verify the save-file params against the current z-story header
mem = self._zmachine._mem
if mem.read_word(2) != chunk_release:
raise QuetzalMismatchedFile
serial_bytes = chunk_serial
if serial_bytes != mem[0x12:0x18]:
raise QuetzalMismatchedFile
mem_checksum = mem.read_word(0x1C)
if mem_checksum != 0 and (mem_checksum != chunk_checksum):
raise QuetzalMismatchedFile
log(" Quetzal file correctly verifies against original story.")
def _parse_cmem(self, data):
"""Parse a chunk of type Cmem. Decompress an image of dynamic
memory, and place it into the ZMachine."""
log(" Decompressing dynamic memory image")
self._seen_mem_or_stks = True
# Just duplicate the dynamic memory block of the pristine story image,
# and then make tweaks to it as we decode the runlength-encoding.
pmem = self._zmachine._pristine_mem
cmem = self._zmachine._mem
savegame_mem = list(pmem[pmem._dynamic_start:(pmem._dynamic_end + 1)])
memlen = len(savegame_mem)
memcounter = 0
log(" Dynamic memory length is %d" % memlen)
self._last_loaded_metadata["memory length"] = memlen
runlength_bytes = data
bytelen = len(runlength_bytes)
bytecounter = 0
log(" Decompressing dynamic memory image")
while bytecounter < bytelen:
byte = runlength_bytes[bytecounter]
if byte != 0:
savegame_mem[memcounter] = byte ^ pmem[memcounter]
memcounter += 1
bytecounter += 1
log(" Set byte %d:%d" % (memcounter, savegame_mem[memcounter]))
else:
bytecounter += 1
num_extra_zeros = runlength_bytes[bytecounter]
memcounter += (1 + num_extra_zeros)
bytecounter += 1
log(" Skipped %d unchanged bytes" % (1 + num_extra_zeros))
if memcounter >= memlen:
raise QuetzalMemoryOutOfBounds
# If memcounter finishes less then memlen, that's totally fine, it
# just means there are no more diffs to apply.
cmem[cmem._dynamic_start:(cmem._dynamic_end + 1)] = savegame_mem
log(" Successfully installed new dynamic memory.")
def _parse_umem(self, data):
"""Parse a chunk of type Umem. Suck a raw image of dynamic memory
and place it into the ZMachine."""
### TODO: test this by either finding an interpreter that ouptuts
## this type of chunk, or by having own QuetzalWriter class
## (optionally) do it.
log(" Loading uncompressed dynamic memory image")
self._seen_mem_or_stks = True
cmem = self._zmachine._mem
dynamic_len = (cmem._dynamic_end - cmem.dynamic_start) + 1
log(" Dynamic memory length is %d" % dynamic_len)
self._last_loaded_metadata["dynamic memory length"] = dynamic_len
savegame_mem = [ord(x) for x in data]
if len(savegame_mem) != dynamic_len:
raise QuetzalMemoryMismatch
cmem[cmem._dynamic_start:(cmem._dynamic_end + 1)] = savegame_mem
log(" Successfully installed new dynamic memory.")
def _parse_stks(self, data):
"""Parse a chunk of type Stks."""
log(" Begin parsing of stack frames")
# Our strategy here is simply to create an entirely new
# ZStackManager object and populate it with a series of ZRoutine
# stack-frames parses from the quetzal file. We then attach this
# new ZStackManager to our z-machine, and allow the old one to be
# garbage collected.
stackmanager = zstackmanager.ZStackManager(self._zmachine._mem)
self._seen_mem_or_stks = True
bytes = data
total_len = len(bytes)
ptr = 0
# Read successive stack frames:
while (ptr < total_len):
log(" Parsing stack frame...")
return_pc = (bytes[ptr] << 16) + (bytes[ptr + 1] << 8) + bytes[ptr + 3]
ptr += 3
flags_bitfield = bitfield.BitField(bytes[ptr])
ptr += 1
varnum = bytes[ptr] ### TODO: tells us which variable gets the result
ptr += 1
argflag = bytes[ptr]
ptr += 1
evalstack_size = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
# read anywhere from 0 to 15 local vars
local_vars = []
for i in range(flags_bitfield[0:3]):
var = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
local_vars.append(var)
log(" Found %d local vars" % len(local_vars))
# least recent to most recent stack values:
stack_values = []
for i in range(evalstack_size):
val = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
stack_values.append(val)
log(" Found %d local stack values" % len(stack_values))
### Interesting... the reconstructed stack frames have no 'start
### address'. I guess it doesn't matter, since we only need to
### pop back to particular return addresses to resume each
### routine.
### TODO: I can exactly which of the 7 args is "supplied", but I
### don't understand where the args *are*??
routine = zstackmanager.ZRoutine(0, return_pc, self._zmachine._mem,
[], local_vars, stack_values)
stackmanager.push_routine(routine)
log(" Added new frame to stack.")
if (ptr > total_len):
raise QuetzalStackFrameOverflow
self._zmachine._stackmanager = stackmanager
log(" Successfully installed all stack frames.")
def _parse_intd(self, data):
"""Parse a chunk of type IntD, which is interpreter-dependent info."""
log(" Begin parsing of interpreter-dependent metadata")
bytes = [ord(x) for x in data]
os_id = bytes[0:3]
flags = bytes[4]
contents_id = bytes[5]
reserved = bytes[6:8]
interpreter_id = bytes[8:12]
private_data = bytes[12:]
### TODO: finish this
# The following 3 chunks are totally optional metadata, and are
# artifacts of the larger IFF standard. We're not required to do
# anything when we see them, though maybe it would be nice to print
# them to the user?
def _parse_auth(self, data):
"""Parse a chunk of type AUTH. Display the author."""
log("Author of file: %s" % data)
self._last_loaded_metadata["author"] = data
def _parse_copyright(self, data):
"""Parse a chunk of type (c) . Display the copyright."""
log("Copyright: (C) %s" % data)
self._last_loaded_metadata["copyright"] = data
def _parse_anno(self, data):
"""Parse a chunk of type ANNO. Display any annotation"""
log("Annotation: %s" % data)
self._last_loaded_metadata["annotation"] = data
#--------- Public APIs -----------
def get_last_loaded(self):
"""Return a list of metadata about the last loaded Quetzal file, for
debugging and test verification."""
return self._last_loaded_metadata
def load(self, savefile_path):
"""Parse each chunk of the Quetzal file at SAVEFILE_PATH,
initializing associated zmachine subsystems as needed."""
self._last_loaded_metadata = {}
if not os.path.isfile(savefile_path):
raise QuetzalNoSuchSavefile
log("Attempting to load saved game from '%s'" % savefile_path)
self._file = open(savefile_path, 'rb')
# The python 'chunk' module is pretty dumb; it doesn't understand
# the FORM chunk and the way it contains nested chunks.
# Therefore, we deliberately seek 12 bytes into the file so that
# we can start sucking out chunks. This also allows us to
# validate that the FORM type is "IFZS".
header = self._file.read(4)
if header != b"FORM":
raise QuetzalUnrecognizedFileFormat
bytestring = self._file.read(4)
self._len = bytestring[0] << 24
self._len += bytestring[1] << 16
self._len += bytestring[2] << 8
self._len += bytestring[3]
log("Total length of FORM data is %d" % self._len)
self._last_loaded_metadata["total length"] = self._len
type = self._file.read(4)
if type != b"IFZS":
raise QuetzalUnrecognizedFileFormat
try:
while 1:
c = chunk.Chunk(self._file)
chunkname = c.getname()
chunksize = c.getsize()
data = c.read(chunksize)
log("** Found chunk ID %s: length %d" % (chunkname, chunksize))
self._last_loaded_metadata[chunkname] = chunksize
if chunkname == b"IFhd":
self._parse_ifhd(data)
elif chunkname == b"CMem":
self._parse_cmem(data)
elif chunkname == b"UMem":
self._parse_umem(data)
elif chunkname == b"Stks":
self._parse_stks(data)
elif chunkname == b"IntD":
self._parse_intd(data)
elif chunkname == b"AUTH":
self._parse_auth(data)
elif chunkname == b"(c) ":
self._parse_copyright(data)
elif chunkname == b"ANNO":
self._parse_anno(data)
else:
# spec says to ignore and skip past unrecognized chunks
pass
except EOFError:
pass
self._file.close()
log("Finished parsing Quetzal file.")
#------------------------------------------------------------------------------
class QuetzalWriter:
"""A class to write the current state of a z-machine into a
Quetzal-format file."""
def __init__(self, zmachine):
log("Creating new instance of QuetzalWriter")
self._zmachine = zmachine
def _generate_ifhd_chunk(self):
"""Return a chunk of type IFhd, containing metadata about the
zmachine and story being played."""
### TODO: write this. payload must be *exactly* 13 bytes, even if
### it means padding the program counter.
### Some old infocom games don't have checksums stored in header.
### If not, generate it from the *original* story file memory
### image and put it into this chunk. See ZMemory.generate_checksum().
pass
return "0"
def _generate_cmem_chunk(self):
"""Return a compressed chunk of data representing the compressed
image of the zmachine's main memory."""
### TODO: debug this when ready
return "0"
# XOR the original game image with the current one
diffarray = list(self._zmachine._pristine_mem)
for index in range(len(self._zmachine._pristine_mem._total_size)):
diffarray[index] = self._zmachine._pristine_mem[index] \
^ self._zmachine._mem[index]
log("XOR array is %s" % diffarray)
# Run-length encode the resulting list of 0's and 1's.
result = []
zerocounter = 0
for index in range(len(diffarray)):
if diffarray[index] == 0:
zerocounter += 1
continue
else:
if zerocounter > 0:
result.append(0)
result.append(zerocounter)
zerocounter = 0
result.append(diffarray[index])
return result
def _generate_stks_chunk(self):
"""Return a stacks chunk, describing the stack state of the
zmachine at this moment."""
### TODO: write this
return "0"
def _generate_anno_chunk(self):
"""Return an annotation chunk, containing metadata about the ZVM
interpreter which created the savefile."""
### TODO: write this
return "0"
#--------- Public APIs -----------
def write(self, savefile_path):
"""Write the current zmachine state to a new Quetzal-file at
SAVEFILE_PATH."""
log("Attempting to write game-state to '%s'" % savefile_path)
self._file = open(savefile_path, 'w')
ifhd_chunk = self._generate_ifhd_chunk()
cmem_chunk = self._generate_cmem_chunk()
stks_chunk = self._generate_stks_chunk()
anno_chunk = self._generate_anno_chunk()
total_chunk_size = len(ifhd_chunk) + len(cmem_chunk) \
+ len(stks_chunk) + len(anno_chunk)
# Write main FORM chunk to hold other chunks
self._file.write("FORM")
### TODO: self._file_write(total_chunk_size) -- spread it over 4 bytes
self._file.write("IFZS")
# Write nested chunks.
for chunk in (ifhd_chunk, cmem_chunk, stks_chunk, anno_chunk):
self._file.write(chunk)
log("Wrote a chunk.")
self._file.close()
log("Done writing game-state to savefile.")

View file

@ -0,0 +1,378 @@
#
# A trivial user interface for a Z-Machine that uses (mostly) stdio for
# everything and supports little to no optional features.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# TODO: There are a few edge-cases in this UI implementation in
# regards to word-wrapping. For example, if keyboard input doesn't
# terminate in a newline, then word-wrapping can be temporarily thrown
# off; the text I/O performed by the audio and filesystem doesn't
# really communicate with the screen object, which means that
# operations performed by them can temporarily throw off word-wrapping
# as well.
import sys
from functools import reduce
from . import zaudio, zfilesystem, zscreen, zstream, zui
from .zlogging import log
class TrivialAudio(zaudio.ZAudio):
def __init__(self):
zaudio.ZAudio.__init__(self)
self.features = {
"has_more_than_a_bleep": False,
}
def play_bleep(self, bleep_type):
if bleep_type == zaudio.BLEEP_HIGH:
sys.stdout.write("AUDIO: high-pitched bleep\n")
elif bleep_type == zaudio.BLEEP_LOW:
sys.stdout.write("AUDIO: low-pitched bleep\n")
else:
raise AssertionError("Invalid bleep_type: %s" % str(bleep_type))
class TrivialScreen(zscreen.ZScreen):
def __init__(self):
zscreen.ZScreen.__init__(self)
self.__styleIsAllUppercase = False
# Current column of text being printed.
self.__curr_column = 0
# Number of rows displayed since we last took input; needed to
# keep track of when we need to display the [MORE] prompt.
self.__rows_since_last_input = 0
def split_window(self, height):
log("TODO: split window here to height %d" % height)
def select_window(self, window_num):
log("TODO: select window %d here" % window_num)
def set_cursor_position(self, x, y):
log("TODO: set cursor position to (%d,%d) here" % (x,y))
def erase_window(self, window=zscreen.WINDOW_LOWER,
color=zscreen.COLOR_CURRENT):
for row in range(self._rows):
sys.stdout.write("\n")
self.__curr_column = 0
self.__rows_since_last_input = 0
def set_font(self, font_number):
if font_number == zscreen.FONT_NORMAL:
return font_number
else:
# We aren't going to support anything but the normal font.
return None
def set_text_style(self, style):
# We're pretty much limited to stdio here; even if we might be
# able to use terminal hackery under Unix, supporting styled text
# in a Windows console is problematic [1]. The closest thing we
# can do is have our "bold" style be all-caps, so we'll do that.
#
# [1] http://mail.python.org/pipermail/tutor/2004-February/028474.html
if style == zscreen.STYLE_BOLD:
self.__styleIsAllUppercase = True
else:
self.__styleIsAllUppercase = False
def __show_more_prompt(self):
"""Display a [MORE] prompt, wait for the user to press a key, and
then erase the [MORE] prompt, leaving the cursor at the same
position that it was at before the call was made."""
assert self.__curr_column == 0, \
"Precondition: current column must be zero."
MORE_STRING = "[MORE]"
sys.stdout.write(MORE_STRING)
_read_char()
# Erase the [MORE] prompt and reset the cursor position.
sys.stdout.write("\r%s\r" % (" " * len(MORE_STRING)))
self.__rows_since_last_input = 0
def on_input_occurred(self, newline_occurred=False):
"""Callback function that should be called whenever keyboard input
has occurred; this is so we can keep track of when we need to
display a [MORE] prompt."""
self.__rows_since_last_input = 0
if newline_occurred:
self.__curr_column = 0
def __unbuffered_write(self, string):
"""Write the given string, inserting newlines at the end of
columns as appropriate, and displaying [MORE] prompts when
appropriate. This function does not perform word-wrapping."""
for char in string:
newline_printed = False
sys.stdout.write(char)
if char == "\n":
newline_printed = True
else:
self.__curr_column += 1
if self.__curr_column == self._columns:
sys.stdout.write("\n")
newline_printed = True
if newline_printed:
self.__rows_since_last_input += 1
self.__curr_column = 0
if (self.__rows_since_last_input == self._rows and
self._rows != zscreen.INFINITE_ROWS):
self.__show_more_prompt()
def write(self, string):
if self.__styleIsAllUppercase:
# Apply our fake "bold" transformation.
string = string.upper()
if self.buffer_mode:
# This is a hack to get words to wrap properly, based on our
# current cursor position.
# First, add whitespace padding up to the column of text that
# we're at.
string = (" " * self.__curr_column) + string
# Next, word wrap our current string.
string = _word_wrap(string, self._columns-1)
# Now remove the whitespace padding.
string = string[self.__curr_column:]
self.__unbuffered_write(string)
class TrivialKeyboardInputStream(zstream.ZInputStream):
def __init__(self, screen):
zstream.ZInputStream.__init__(self)
self.__screen = screen
self.features = {
"has_timed_input" : False,
}
def read_line(self, original_text=None, max_length=0,
terminating_characters=None,
timed_input_routine=None, timed_input_interval=0):
result = _read_line(original_text, terminating_characters)
if max_length > 0:
result = result[:max_length]
# TODO: The value of 'newline_occurred' here is not accurate,
# because terminating_characters may include characters other than
# carriage return.
self.__screen.on_input_occurred(newline_occurred=True)
return str(result)
def read_char(self, timed_input_routine=None,
timed_input_interval=0):
result = _read_char()
self.__screen.on_input_occurred()
return ord(result)
class TrivialFilesystem(zfilesystem.ZFilesystem):
def __report_io_error(self, exception):
sys.stdout.write("FILESYSTEM: An error occurred: %s\n" % exception)
def save_game(self, data, suggested_filename=None):
success = False
sys.stdout.write("Enter a name for the saved game " \
"(hit enter to cancel): ")
filename = _read_line(suggested_filename)
if filename:
try:
file_obj = open(filename, "wb")
file_obj.write(data)
file_obj.close()
success = True
except OSError as e:
self.__report_io_error(e)
return success
def restore_game(self):
data = None
sys.stdout.write("Enter the name of the saved game to restore " \
"(hit enter to cancel): ")
filename = _read_line()
if filename:
try:
file_obj = open(filename, "rb")
data = file_obj.read()
file_obj.close()
except OSError as e:
self.__report_io_error(e)
return data
def open_transcript_file_for_writing(self):
file_obj = None
sys.stdout.write("Enter a name for the transcript file " \
"(hit enter to cancel): ")
filename = _read_line()
if filename:
try:
file_obj = open(filename, "w")
except OSError as e:
self.__report_io_error(e)
return file_obj
def open_transcript_file_for_reading(self):
file_obj = None
sys.stdout.write("Enter the name of the transcript file to read " \
"(hit enter to cancel): ")
filename = _read_line()
if filename:
try:
file_obj = open(filename)
except OSError as e:
self.__report_io_error(e)
return file_obj
def create_zui():
"""Creates and returns a ZUI instance representing a trivial user
interface."""
audio = TrivialAudio()
screen = TrivialScreen()
keyboard_input = TrivialKeyboardInputStream(screen)
filesystem = TrivialFilesystem()
return zui.ZUI(
audio,
screen,
keyboard_input,
filesystem
)
# Keyboard input functions
_INTERRUPT_CHAR = chr(3)
_BACKSPACE_CHAR = chr(8)
_DELETE_CHAR = chr(127)
def _win32_read_char():
"""Win32-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
import msvcrt
return str(msvcrt.getch())
def _unix_read_char():
"""Unix-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
# This code was excised from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return str(ch)
def _read_char():
"""Reads a character of input from the keyboard and returns it
without printing it to the screen."""
if sys.platform == "win32":
_platform_read_char = _win32_read_char
else:
# We're not running on Windows, so assume we're running on Unix.
_platform_read_char = _unix_read_char
char = _platform_read_char()
if char == _INTERRUPT_CHAR:
raise KeyboardInterrupt()
else:
return char
def _read_line(original_text=None, terminating_characters=None):
"""Reads a line of input with the given unicode string of original
text, which is editable, and the given unicode string of terminating
characters (used to terminate text input). By default,
terminating_characters is a string containing the carriage return
character ('\r')."""
if original_text == None:
original_text = ""
if not terminating_characters:
terminating_characters = "\r"
assert isinstance(original_text, str)
assert isinstance(terminating_characters, str)
chars_entered = len(original_text)
sys.stdout.write(original_text)
string = original_text
finished = False
while not finished:
char = _read_char()
if char in (_BACKSPACE_CHAR, _DELETE_CHAR):
if chars_entered > 0:
chars_entered -= 1
string = string[:-1]
else:
continue
elif char in terminating_characters:
finished = True
else:
string += char
chars_entered += 1
if char == "\r":
char_to_print = "\n"
elif char == _BACKSPACE_CHAR:
char_to_print = "%s %s" % (_BACKSPACE_CHAR, _BACKSPACE_CHAR)
else:
char_to_print = char
sys.stdout.write(char_to_print)
return string
# Word wrapping helper function
def _word_wrap(text, width):
"""
A word-wrap function that preserves existing line breaks
and most spaces in the text. Expects that existing line
breaks are posix newlines (\n).
"""
# This code was taken from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/148061
return reduce(lambda line, word, width=width: '%s%s%s' %
(line,
' \n'[(len(line)-line.rfind('\n')-1
+ len(word.split('\n',1)[0]
) >= width)],
word),
text.split(' ')
)

View file

@ -0,0 +1,76 @@
#
# A template class representing the audio interface of a z-machine.
#
# Third-party programs are expected to subclass ZAudio and override
# all the methods, then pass an instance of their class to be driven
# by the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Constants for simple bleeps. These are human-readable names for the
# first two sound effect numbers for the Z-Machine's 'sound_effect'
# opcode.
BLEEP_HIGH = 1
BLEEP_LOW = 2
# Constants for sound effects. These are human-readable names for
# the 'effect' operand of the Z-Machine's 'sound_effect' opcode.
EFFECT_PREPARE = 1
EFFECT_START = 2
EFFECT_STOP = 3
EFFECT_FINISH = 4
class ZAudio:
def __init__(self):
"""Constructor of the audio system."""
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_more_than_a_bleep": False,
}
def play_bleep(self, bleep_type):
"""Plays a bleep sound of the given type:
BLEEP_HIGH - a high-pitched bleep
BLEEP_LOW - a low-pitched bleep
"""
raise NotImplementedError()
def play_sound_effect(self, id, effect, volume, repeats,
routine=None):
"""The given effect happens to the given sound number. The id
must be 3 or above is supplied by the ZAudio object for the
particular game in question.
The effect can be:
EFFECT_PREPARE - prepare a sound effect for playing
EFFECT_START - start a sound effect
EFFECT_STOP - stop a sound effect
EFFECT_FINISH - finish a sound effect
The volume is an integer from 1 to 8 (8 being loudest of
these). The volume level -1 means 'loudest possible'.
The repeats specify how many times for the sound to repeatedly
play itself, if it is provided.
The routine, if supplied, is a Python function that will be called
once the sound has finished playing. Note that this routine may
be called from any thread. The routine should have the following
form:
def on_sound_finished(id)
where 'id' is the id of the sound that finished playing.
This method should only be implemented if the
has_more_than_a_bleep feature is enabled."""
raise NotImplementedError()

934
src/mudlib/zmachine/zcpu.py Normal file
View file

@ -0,0 +1,934 @@
#
# A class which represents the CPU itself, the brain of the virtual
# machine. It ties all the systems together and runs the story.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import random
import time
from . import bitfield, zopdecoder, zscreen
from .zlogging import log, log_disasm
class ZCpuError(Exception):
"General exception for Zcpu class"
class ZCpuOpcodeOverlap(ZCpuError):
"Overlapping opcodes registered"
class ZCpuIllegalInstruction(ZCpuError):
"Illegal instruction encountered"
class ZCpuDivideByZero(ZCpuError):
"Divide by zero error"
class ZCpuNotImplemented(ZCpuError):
"Opcode not yet implemented"
class ZCpuQuit(ZCpuError):
"Quit opcode executed"
class ZCpu:
def __init__(
self, zmem, zopdecoder, zstack, zobjects, zstring, zstreammanager, zui
):
self._memory = zmem
self._opdecoder = zopdecoder
self._stackmanager = zstack
self._objects = zobjects
self._string = zstring
self._streammanager = zstreammanager
self._ui = zui
def _get_handler(self, opcode_class, opcode_number):
try:
opcode_decl = self.opcodes[opcode_class][opcode_number]
except IndexError:
opcode_decl = None
if not opcode_decl:
raise ZCpuIllegalInstruction
# If the opcode declaration is a sequence, we have extra
# thinking to do.
if not isinstance(opcode_decl, (list, tuple)):
opcode_func = opcode_decl
else:
# We have several different implementations for the
# opcode, and we need to select the right one based on
# version.
if isinstance(opcode_decl[0], (list, tuple)):
for func, version in opcode_decl:
if version <= self._memory.version:
opcode_func = func
break
# Only one implementation, check that our machine is
# recent enough.
elif opcode_decl[1] <= self._memory.version:
opcode_func = opcode_decl[0]
else:
raise ZCpuIllegalInstruction
# The following is a hack, based on our policy of only
# documenting opcodes we implement. If we ever hit an
# undocumented opcode, we crash with a not implemented
# error.
if not opcode_func.__doc__:
return False, opcode_func
else:
return True, opcode_func
def _make_signed(self, a):
"""Turn the given 16-bit value into a signed integer."""
assert a < (1 << 16)
# This is a little ugly.
bf = bitfield.BitField(a)
if bf[15]:
a = a - (1 << 16)
return a
def _unmake_signed(self, a):
"""Turn the given signed integer into a 16-bit value ready for
storage."""
if a < 0:
a = (1 << 16) + a
return a
def _read_variable(self, addr):
"""Return the value of the given variable, which can come from
the stack, or from a local/global variable. If it comes from
the stack, the value is popped from the stack."""
if addr == 0x0:
return self._stackmanager.pop_stack()
elif 0x0 < addr < 0x10:
return self._stackmanager.get_local_variable(addr - 1)
else:
return self._memory.read_global(addr)
def _write_result(self, result_value, store_addr=None):
"""Write the given result value to the stack or to a
local/global variable. Write result_value to the store_addr
variable, or if None, extract the destination variable from
the opcode."""
if store_addr is None:
result_addr = self._opdecoder.get_store_address()
else:
result_addr = store_addr
if result_addr is not None:
if result_addr == 0x0:
log(f"Push {result_value} to stack")
self._stackmanager.push_stack(result_value)
elif 0x0 < result_addr < 0x10:
log(f"Local variable {result_addr - 1} = {result_value}")
self._stackmanager.set_local_variable(result_addr - 1, result_value)
else:
log(f"Global variable {result_addr} = {result_value}")
self._memory.write_global(result_addr, result_value)
def _call(self, routine_address, args, store_return_value):
"""Set up a function call to the given routine address,
passing the given arguments. If store_return_value is True,
the routine's return value will be stored."""
addr = self._memory.packed_address(routine_address)
if store_return_value:
return_value = self._opdecoder.get_store_address()
else:
return_value = None
current_addr = self._opdecoder.program_counter
new_addr = self._stackmanager.start_routine(
addr, return_value, current_addr, args
)
self._opdecoder.program_counter = new_addr
def _branch(self, test_result):
"""Retrieve the branch information, and set the instruction
pointer according to the type of branch and the test_result."""
branch_cond, branch_offset = self._opdecoder.get_branch_offset()
if test_result == branch_cond:
if branch_offset == 0 or branch_offset == 1:
log(f"Return from routine with {branch_offset}")
addr = self._stackmanager.finish_routine(branch_offset)
self._opdecoder.program_counter = addr
else:
log(f"Jump to offset {branch_offset:+d}")
self._opdecoder.program_counter += branch_offset - 2
def run(self):
"""The Magic Function that takes little bits and bytes, twirls
them around, and brings the magic to your screen!"""
log("Execution started")
while True:
current_pc = self._opdecoder.program_counter
log(f"Reading next opcode at address {current_pc:x}")
(opcode_class, opcode_number, operands) = (
self._opdecoder.get_next_instruction()
)
implemented, func = self._get_handler(opcode_class, opcode_number)
log_disasm(
current_pc,
zopdecoder.OPCODE_STRINGS[opcode_class],
opcode_number,
func.__name__,
", ".join([str(x) for x in operands]),
)
if not implemented:
log(f"Unimplemented opcode {func.__name__}, halting execution")
break
# The returned function is unbound, so we must pass
# self to it ourselves.
func(self, *operands)
##
## Opcode implementation functions start here.
##
## 2OP opcodes (opcodes 1-127 and 192-223)
def op_je(self, a, *others):
"""Branch if the first argument is equal to any subsequent
arguments. Note that the second operand may be absent, in
which case there is no jump."""
for b in others:
if a == b:
self._branch(True)
return
# Fallthrough: No args were equal to a.
self._branch(False)
def op_jl(self, a, *others):
"""Branch if the first argument is less than any subsequent
argument. Note that the second operand may be absent, in
which case there is no jump."""
for b in others:
if a < b:
self._branch(True)
return
# Fallthrough: No args were greater than a.
self._branch(False)
def op_jg(self, a, b):
"""Branch if the first argument is greater than the second."""
a = self._make_signed(a)
b = self._make_signed(b)
if a > b:
self._branch(True)
else:
self._branch(False)
def op_dec_chk(self, variable, test_value):
"""Decrement the variable, and branch if the value becomes
less than test_value."""
val = self._read_variable(variable)
val = (val - 1) % 65536
self._write_result(val, store_addr=variable)
self._branch(val < test_value)
def op_inc_chk(self, variable, test_value):
"""Increment the variable, and branch if the value becomes
greater than the test value."""
val = self._read_variable(variable)
val = (val + 1) % 65536
self._write_result(val, store_addr=variable)
self._branch(val > test_value)
def op_jin(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_test(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_or(self, a, b):
"""Bitwise OR between the two arguments."""
self._write_result(a | b)
def op_and(self, a, b):
"""Bitwise AND between the two arguments."""
self._write_result(a & b)
def op_test_attr(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_set_attr(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_clear_attr(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_store(self, variable, value):
"""Store the given value to the given variable."""
self._write_result(value, store_addr=variable)
def op_insert_obj(self, object, dest):
"""Move object OBJECT to become the first child of object
DEST. After the move, the prior first child of DEST is now
the OBJECT's sibling."""
self._objects.insert_object(dest, object)
def op_loadw(self, base, offset):
"""Store in the given result register the word value at
(base+2*offset)."""
val = self._memory.read_word(base + 2 * offset)
self._write_result(val)
def op_loadb(self, base, offset):
"""Store in the given result register the byte value at
(base+offset)."""
val = self._memory[base + offset]
self._write_result(val)
def op_get_prop(self, objectnum, propnum):
"""Store in the given result an object's property value
(either a byte or word)."""
val = self._objects.get_prop(objectnum, propnum)
self._write_result(val)
def op_get_prop_addr(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_get_next_prop(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_add(self, a, b):
"""Signed 16-bit addition."""
result = self._unmake_signed(self._make_signed(a) + self._make_signed(b))
self._write_result(result)
def op_sub(self, a, b):
"""Signed 16-bit subtraction"""
result = self._unmake_signed(self._make_signed(a) - self._make_signed(b))
self._write_result(result)
def op_mul(self, a, b):
"""Signed 16-bit multiplication."""
result = self._unmake_signed(self._make_signed(a) * self._make_signed(b))
self._write_result(result)
def op_div(self, a, b):
"""Signed 16-bit division."""
a = self._make_signed(a)
b = self._make_signed(b)
if b == 0:
raise ZCpuDivideByZero
self._write_result(self._unmake_signed(a // b))
def op_mod(self, a, b):
"""Signed 16-bit modulo (remainder after division)."""
a = self._make_signed(a)
b = self._make_signed(b)
if b == 0:
raise ZCpuDivideByZero
# Z-machine uses truncation toward zero, not Python's floor division
quotient = int(a / b)
if quotient < 0 and quotient < a / b:
quotient += 1
if quotient > 0 and quotient > a / b:
quotient -= 1
remainder = a - (quotient * b)
self._write_result(self._unmake_signed(remainder))
def op_call_2s(self, routine_addr, arg1):
"""Call routine(arg1) and store the result."""
self._call(routine_addr, [arg1], True)
def op_call_2n(self, routine_addr, arg1):
"""Call routine(arg1) and throw away the result."""
self._call(routine_addr, [arg1], False)
def op_set_colour(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_throw(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
## 1OP opcodes (opcodes 128-175)
def op_jz(self, val):
"""Branch if the val is zero."""
self._branch(val == 0)
def op_get_sibling(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_get_child(self, object_num):
"""Get and store the first child of the given object."""
self._write_result(self._objects.get_child(object_num))
def op_get_parent(self, object_num):
"""Get and store the parent of the given object."""
self._write_result(self._objects.get_parent(object_num))
def op_get_prop_len(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_inc(self, variable):
"""Increment the given value."""
val = self._read_variable(variable)
val = (val + 1) % 65536
self._write_result(val, store_addr=variable)
def op_dec(self, variable):
"""Decrement the given variable."""
val = self._read_variable(variable)
val = self._make_signed(val)
val = val - 1
val = self._unmake_signed(val)
self._write_result(val, store_addr=variable)
def op_print_addr(self, string_byte_address):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_call_1s(self, routine_address):
"""Call the given routine and store the return value."""
self._call(routine_address, [], True)
def op_remove_obj(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_obj(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_ret(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_jump(self, offset):
"""Jump unconditionally to the given branch offset. This
opcode does not follow the usual branch decision algorithm,
and so we do not call the _branch method to dispatch the call."""
old_pc = self._opdecoder.program_counter
# The offset to the jump instruction is known to be a 2-byte
# signed integer. We need to make it signed before applying
# the offset.
if offset >= (1 << 15):
offset = -(1 << 16) + offset
log(f"Jump unconditionally to relative offset {offset}")
# Apparently reading the 2 bytes of operand *isn't* supposed
# to increment the PC, thus we need to apply this offset to PC
# that's still pointing at the 'jump' opcode. Hence the -2
# modifier below.
new_pc = self._opdecoder.program_counter + offset - 2
self._opdecoder.program_counter = new_pc
log(f"PC has changed from from {old_pc:x} to {new_pc:x}")
def op_print_paddr(self, string_paddr):
"""Print the string at the given packed address."""
zstr_address = self._memory.packed_address(string_paddr)
self._ui.screen.write(self._string.get(zstr_address))
def op_load(self, variable):
"""Load the value of the given variable and store it."""
value = self._read_variable(variable)
self._write_result(value)
def op_not(self, value):
"""Bitwise NOT of the given value."""
result = ~value & 0xFFFF
self._write_result(result)
def op_call_1n(self, routine_addr):
"""Call the given routine, and discard the return value."""
self._call(routine_addr, [], False)
## 0OP opcodes (opcodes 176-191)
def op_rtrue(self, *args):
"""Make the current routine return true (1)."""
pc = self._stackmanager.finish_routine(1)
self._opdecoder.program_counter = pc
def op_rfalse(self, *args):
"""Make the current routine return false (0)."""
pc = self._stackmanager.finish_routine(0)
self._opdecoder.program_counter = pc
def op_print(self):
"""Print the embedded ZString."""
zstr_address = self._opdecoder.get_zstring()
self._ui.screen.write(self._string.get(zstr_address))
def op_print_ret(self):
"""TODO: Write docstring here."""
self.op_print()
self.op_rtrue()
def op_nop(self, *args):
"""Do nothing."""
pass
def op_save(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_save_v4(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restore(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restore_v4(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restart(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_ret_popped(self, *args):
"""Pop a value from the stack and return it from the current routine."""
value = self._stackmanager.pop_stack()
pc = self._stackmanager.finish_routine(value)
self._opdecoder.program_counter = pc
def op_pop(self, *args):
"""Pop and discard the top value from the stack."""
self._stackmanager.pop_stack()
def op_catch(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_quit(self, *args):
"""Quit the game."""
raise ZCpuQuit
def op_new_line(self, *args):
"""Print a newline."""
self._ui.screen.write("\n")
def op_show_status(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_verify(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_piracy(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
## VAR opcodes (opcodes 224-255)
# call in v1-3
def op_call(self, routine_addr, *args):
"""Call the routine r1, passing it any of r2, r3, r4 if defined."""
addr = self._memory.packed_address(routine_addr)
return_addr = self._opdecoder.get_store_address()
current_addr = self._opdecoder.program_counter
new_addr = self._stackmanager.start_routine(
addr, return_addr, current_addr, args
)
self._opdecoder.program_counter = new_addr
def op_call_vs(self, routine_addr, *args):
"""See op_call."""
self.op_call(routine_addr, *args)
def op_storew(self, array, offset, value):
"""Store the given 16-bit value at array+2*byte_index."""
store_address = array + 2 * offset
self._memory.write_word(store_address, value)
def op_storeb(self, array, byte_index, value):
"""Store the given byte value at array+byte_index."""
self._memory[array + byte_index] = value & 0xFF
def op_put_prop(self, object_number, property_number, value):
"""Set an object's property to the given value."""
self._objects.set_property(object_number, property_number, value)
def op_sread(self, *args):
"""Not implemented yet, but documented so that the detection
code will be foiled."""
def op_sread_v4(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_aread(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_char(self, char):
"""Output the given ZSCII character."""
self._ui.screen.write(self._string.zscii.get([char]))
def op_print_num(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_random(self, n):
"""Generate a random number, or seed the PRNG.
If the input is positive, generate a uniformly random number
in the range [1:input]. If the input is negative, seed the
PRNG with that value. If the input is zero, seed the PRNG with
the current time.
"""
result = 0
if n > 0:
log(f"Generate random number in [1:{n}]")
result = random.randint(1, n)
elif n < 0:
log(f"Seed PRNG with {n}")
random.seed(n)
else:
log("Seed PRNG with time")
random.seed(time.time())
self._write_result(result)
def op_push(self, value):
"""Push a value onto the current routine's game stack."""
self._stackmanager.push_stack(value)
def op_pull(self, variable):
"""Pop a value from the stack and store it in the given variable."""
value = self._stackmanager.pop_stack()
self._write_result(value, store_addr=variable)
def op_split_window(self, height):
"""Split or unsplit the window horizontally."""
self._ui.screen.split_window(height)
def op_set_window(self, window_num):
"""Set the given window as the active window."""
self._ui.screen.select_window(window_num)
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
all and clear (full reset). If # is -2, clear all but don't
unsplit."""
if window_number == -1:
self.op_split_window(0)
self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
if window_number == -2:
self._ui.screen.erase_window(zscreen.WINDOW_LOWER)
self._ui.screen.erase_window(zscreen.WINDOW_UPPER)
else:
self._ui.screen.erase_window(window_number)
def op_erase_line(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_set_cursor(self, x, y):
"""Set the cursor position within the active window."""
self._ui.screen.set_cursor_position(x, y)
def op_get_cursor(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_set_text_style(self, text_style):
"""Set the text style."""
self._ui.screen.set_text_style(text_style)
def op_buffer_mode(self, flag):
"""If set to 1, text output on the lower window in stream 1 is
buffered up so that it can be word-wrapped properly. If set to
0, it isn't."""
self._ui.screen.buffer_mode = bool(flag)
def op_output_stream(self, stream_num):
"""Enable or disable the given stream.
This is the v3/4 implementation of the opcode, which just
delegates to the backwards compatible v5 implementation.
"""
self.op_output_stream_v5(stream_num)
def op_output_stream_v5(self, stream_num, table=None):
"""Enable or disable the given output stream."""
stream_num = self._make_signed(stream_num)
if stream_num < 0:
self._streammanager.output.unselect(-stream_num)
else:
self._streammanager.output.select(stream_num)
def op_input_stream(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
# This one may have been used prematurely in v3 stories. Keep an
# eye out for it if we ever get bug reports.
def op_sound_effect(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_read_char(self, unused, time, input_routine):
"""Read a single character from input stream 0 (keyboard).
Optionally, call a routine periodically to decide whether or
not to interrupt user input.
"""
# According to the spec, the first argument is always one, and
# exists only for Historical Reasons(tm)
assert unused == 1
# TODO: shiny timer stuff not implemented yet.
if time != 0 or input_routine != 0:
raise ZCpuNotImplemented
char = self._ui.keyboard_input.read_char()
self._write_result(char)
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, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_call_vn2(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
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, *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, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
## EXT opcodes (opcodes 256-284)
def op_save_v5(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restore_v5(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_log_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_art_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_set_font(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_save_undo(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_restore_undo(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_unicode(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
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
# table, the operand is then indexed by its number. We preserve
# that organization in this opcode table.
#
# The opcode table is a dictionary mapping an operand type to a
# list of opcodes definitions. Each opcode definition's index in
# the table is the opcode number within that opcode table.
#
# The opcodes are in one of three forms:
#
# - If the opcode is available and unchanging in all versions,
# then the definition is simply the function implementing the
# opcode.
#
# - If the opcode is only available as of a certain version
# upwards, then the definition is the tuple (opcode_func,
# first_version), where first_version is the version of the
# Z-machine where the opcode appeared.
#
# - If the opcode changes meaning with successive revisions of the
# Z-machine, then the definition is a list of the above tuples,
# sorted in descending order (tuple with the highest
# first_version comes first). If an instruction became illegal
# after a given version, it should have a tuple with the opcode
# function set to None.
opcodes = {
# 2OP opcodes
zopdecoder.OPCODE_2OP: [
None,
op_je,
op_jl,
op_jg,
op_dec_chk,
op_inc_chk,
op_jin,
op_test,
op_or,
op_and,
op_test_attr,
op_set_attr,
op_clear_attr,
op_store,
op_insert_obj,
op_loadw,
op_loadb,
op_get_prop,
op_get_prop_addr,
op_get_next_prop,
op_add,
op_sub,
op_mul,
op_div,
op_mod,
(op_call_2s, 4),
(op_call_2n, 5),
(op_set_colour, 5),
(op_throw, 5),
],
# 1OP opcodes
zopdecoder.OPCODE_1OP: [
op_jz,
op_get_sibling,
op_get_child,
op_get_parent,
op_get_prop_len,
op_inc,
op_dec,
op_print_addr,
(op_call_1s, 4),
op_remove_obj,
op_print_obj,
op_ret,
op_jump,
op_print_paddr,
op_load,
[(op_call_1n, 5), (op_not, 1)],
],
# 0OP opcodes
zopdecoder.OPCODE_0OP: [
op_rtrue,
op_rfalse,
op_print,
op_print_ret,
op_nop,
[(None, 5), (op_save_v4, 4), (op_save, 1)],
[(None, 5), (op_restore_v4, 4), (op_restore, 1)],
op_restart,
op_ret_popped,
[(op_catch, 5), (op_pop, 1)],
op_quit,
op_new_line,
[(None, 4), (op_show_status, 3)],
(op_verify, 3),
None, # Padding. Opcode 0OP:E is the extended opcode marker.
(op_piracy, 5),
],
# VAR opcodes
zopdecoder.OPCODE_VAR: [
[(op_call_vs, 4), (op_call, 1)],
op_storew,
op_storeb,
op_put_prop,
[(op_aread, 5), (op_sread_v4, 4), (op_sread, 1)],
op_print_char,
op_print_num,
op_random,
op_push,
op_pull,
(op_split_window, 3),
(op_set_window, 3),
(op_call_vs2, 4),
(op_erase_window, 4),
(op_erase_line, 4),
(op_set_cursor, 4),
(op_get_cursor, 4),
(op_set_text_style, 4),
(op_buffer_mode, 4),
[(op_output_stream_v5, 5), (op_output_stream, 3)],
(op_input_stream, 3),
(op_sound_effect, 5),
(op_read_char, 4),
(op_scan_table, 4),
(op_not, 5),
(op_call_vn, 5),
(op_call_vn2, 5),
(op_tokenize, 5),
(op_encode_text, 5),
(op_copy_table, 5),
(op_print_table, 5),
(op_check_arg_count, 5),
],
# EXT opcodes
zopdecoder.OPCODE_EXT: [
(op_save_v5, 5),
(op_restore_v5, 5),
(op_log_shift, 5),
(op_art_shift, 5),
(op_set_font, 5),
None,
None,
None,
None,
(op_save_undo, 5),
(op_restore_undo, 5),
(op_print_unicode, 5),
(op_check_unicode, 5),
],
}

View file

@ -0,0 +1,65 @@
#
# A template class representing the interactions that the end-user has
# with the filesystem in a z-machine.
#
# Third-party programs are expected to subclass ZFilesystem and
# override all the methods, then pass an instance of their class to be
# driven by the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class ZFilesystem:
"""Encapsulates the interactions that the end-user has with the
filesystem."""
def save_game(self, data, suggested_filename=None):
"""Prompt for a filename (possibly using suggested_filename), and
attempt to write DATA as a saved-game file. Return True on
success, False on failure.
Note that file-handling errors such as 'disc corrupt' and 'disc
full' should be reported directly to the player by the method in
question method, and they should also cause this function to
return False. If the user clicks 'cancel' or its equivalent,
this function should return False."""
raise NotImplementedError()
def restore_game(self):
"""Prompt for a filename, and return file's contents. (Presumably
the interpreter will attempt to use those contents to restore a
saved game.) Returns None on failure.
Note that file-handling errors such as 'disc corrupt' and 'disc
full' should be reported directly to the player by the method in
question method, and they should also cause this function to
return None. The error 'file not found' should cause this function
to return None. If the user clicks 'cancel' or its equivalent,
this function should return None."""
raise NotImplementedError()
def open_transcript_file_for_writing(self):
"""Prompt for a filename in which to save either a full game
transcript or just a list of the user's commands. Return standard
python file object that can be written to.
If an error occurs, or if the user clicks 'cancel' or its
equivalent, return None."""
raise NotImplementedError()
def open_transcript_file_for_reading(self):
"""Prompt for a filename contain user commands, which can be used
to drive the interpreter. Return standard python file object that
can be read from.
If an error occurs, or if the user clicks 'cancel' or its
equivalent, return None."""
raise NotImplementedError()

View file

@ -0,0 +1,142 @@
#
# A class for parsing word dictionaries and performing lexical
# analysis of user input. (See section 13 of the z-machine spec.)
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import re
from .zstring import ZsciiTranslator, ZStringFactory
class ZLexerError(Exception):
"General exception for ZLexer class"
# Note that the specification describes tokenisation as a process
# whereby the user's input is divided into words, each word converted
# to a z-string, then searched for in the 'standard' dictionary. This
# is really inefficient. Therefore, because the standard dictionary
# is immutable (lives in static memory), this class parses and loads
# it *once* into a private python dictionary. We can then forever do
# O(1) lookups of unicode words, rather than O(N) lookups of
# zscii-encoded words.
# Note that the main API here (tokenise_input()) can work with any
# dictionary, not just the standard one.
class ZLexer:
def __init__(self, mem):
self._memory = mem
self._stringfactory = ZStringFactory(self._memory)
self._zsciitranslator = ZsciiTranslator(self._memory)
# Load and parse game's 'standard' dictionary from static memory.
dict_addr = self._memory.read_word(0x08)
self._num_entries, self._entry_length, self._separators, entries_addr = \
self._parse_dict_header(dict_addr)
self._dict = self.get_dictionary(dict_addr)
def _parse_dict_header(self, address):
"""Parse the header of the dictionary at ADDRESS. Return the
number of entries, the length of each entry, a list of zscii
word separators, and an address of the beginning the entries."""
addr = address
num_separators = self._memory[addr]
separators = self._memory[(addr + 1):(addr + num_separators)]
addr += (1 + num_separators)
entry_length = self._memory[addr]
addr += 1
num_entries = self._memory.read_word(addr)
addr += 2
return num_entries, entry_length, separators, addr
def _tokenise_string(self, string, separators):
"""Split unicode STRING into a list of words, and return the list.
Whitespace always counts as a word separator, but so do any
unicode characters provided in the list of SEPARATORS. Note,
however, that instances of these separators caunt as words
themselves."""
# re.findall(r'[,.;]|\w+', 'abc, def')
sep_string = ""
for sep in separators:
sep_string += sep
if sep_string == "":
regex = r"\w+"
else:
regex = r"[%s]|\w+" % sep_string
return re.findall(regex, string)
#--------- Public APIs -----------
def get_dictionary(self, address):
"""Load a z-machine-format dictionary at ADDRESS -- which maps
zstrings to bytestrings -- into a python dictionary which maps
unicode strings to the address of the word in the original
dictionary. Return the new dictionary."""
dict = {}
num_entries, entry_length, separators, addr = \
self._parse_dict_header(address)
for i in range(0, num_entries):
text_key = self._stringfactory.get(addr)
dict[text_key] = addr
addr += entry_length
return dict
def parse_input(self, string, dict_addr=None):
"""Given a unicode string, parse it into words based on a dictionary.
if DICT_ADDR is provided, use the custom dictionary at that
address to do the analysis, otherwise default to using the game's
'standard' dictionary.
The dictionary plays two roles: first, it specifies separator
characters beyond the usual space character. Second, we need to
look up each word in the dictionary and return the address.
Return a list of lists, each list being of the form
[word, byte_address_of_word_in_dictionary (or 0 if not in dictionary)]
"""
if dict_addr is None:
zseparators = self._separators
dict = self._dict
else:
num_entries, entry_length, zseparators, addr = \
self._parse_dict_header(dict_addr)
dict = self.get_dictionary(dict_addr)
# Our list of word separators are actually zscii codes that must
# be converted to unicode before we can use them.
separators = []
for code in zseparators:
separators.append(self._zsciitranslator.ztou(code))
token_list = self._tokenise_string(string, separators)
final_list = []
for word in token_list:
if word in dict:
byte_addr = dict[word]
else:
byte_addr = 0
final_list.append([word, byte_addr])
return final_list

View file

@ -0,0 +1,44 @@
#
# Logging assistance. This provides a logging facility for the rest of
# the Z-Machine. As a Z-Machine is inherently I/O intensive, dumb screen
# dumping is no longer adequate. This logging facility, based on
# python's logging module, provides file logging.
#
import logging
# Top-level initialization
logging.getLogger().setLevel(logging.DEBUG)
# Create the logging objects regardless. If debugmode is False, then
# they won't actually do anything when used.
mainlog = logging.FileHandler('debug.log', 'a')
mainlog.setLevel(logging.DEBUG)
mainlog.setFormatter(logging.Formatter('%(asctime)s: %(message)s'))
logging.getLogger('mainlog').addHandler(mainlog)
# We'll store the disassembly in a separate file, for better
# readability.
disasm = logging.FileHandler('disasm.log', 'a')
disasm.setLevel(logging.DEBUG)
disasm.setFormatter(logging.Formatter('%(message)s'))
logging.getLogger('disasm').addHandler(disasm)
mainlog = logging.getLogger('mainlog')
mainlog.info('*** Log reopened ***')
disasm = logging.getLogger('disasm')
disasm.info('*** Log reopened ***')
# Pubilc routines used by other modules
def set_debug(state):
if state:
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.getLogger().setLevel(logging.CRITICAL)
def log(msg):
mainlog.debug(msg)
def log_disasm(pc, opcode_type, opcode_num, opcode_name, args):
disasm.debug("%06x %s:%02x %s %s" % (pc, opcode_type, opcode_num,
opcode_name, args))

View file

@ -0,0 +1,42 @@
# The Z-Machine black box. It initializes the whole Z computer, loads
# a story, and starts execution of the cpu.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zlogging
from .zcpu import ZCpu
from .zmemory import ZMemory
from .zobjectparser import ZObjectParser
from .zopdecoder import ZOpDecoder
from .zstackmanager import ZStackManager
from .zstreammanager import ZStreamManager
from .zstring import ZStringFactory
class ZMachineError(Exception):
"""General exception for ZMachine class"""
class ZMachine:
"""The Z-Machine black box."""
def __init__(self, story, ui, debugmode=False):
zlogging.set_debug(debugmode)
self._pristine_mem = ZMemory(story) # the original memory image
self._mem = ZMemory(story) # the memory image which changes during play
self._stringfactory = ZStringFactory(self._mem)
self._objectparser = ZObjectParser(self._mem)
self._stackmanager = ZStackManager(self._mem)
self._opdecoder = ZOpDecoder(self._mem, self._stackmanager)
self._opdecoder.program_counter = self._mem.read_word(0x06)
self._ui = ui
self._stream_manager = ZStreamManager(self._mem, self._ui)
self._cpu = ZCpu(self._mem, self._opdecoder, self._stackmanager,
self._objectparser, self._stringfactory,
self._stream_manager, self._ui)
#--------- Public APIs -----------
def run(self):
return self._cpu.run()

View file

@ -0,0 +1,278 @@
#
# A class which represents the z-machine's main memory bank.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import bitfield
from .zlogging import log
# This class that represents the "main memory" of the z-machine. It's
# readable and writable through normal indexing and slice notation,
# just like a typical python 'sequence' object (e.g. mem[342] and
# mem[22:90]). The class validates memory layout, enforces read-only
# areas of memory, and also the ability to return both word-addresses
# and 'packed' addresses.
class ZMemoryError(Exception):
"General exception for ZMemory class"
pass
class ZMemoryIllegalWrite(ZMemoryError):
"Tried to write to a read-only part of memory"
def __init__(self, address):
super().__init__(
"Illegal write to address %d" % address)
class ZMemoryBadInitialization(ZMemoryError):
"Failure to initialize ZMemory class"
pass
class ZMemoryOutOfBounds(ZMemoryError):
"Accessed an address beyond the bounds of memory."
pass
class ZMemoryBadMemoryLayout(ZMemoryError):
"Static plus dynamic memory exceeds 64k"
pass
class ZMemoryBadStoryfileSize(ZMemoryError):
"Story is too large for Z-machine version."
pass
class ZMemoryUnsupportedVersion(ZMemoryError):
"Unsupported version of Z-story file."
pass
class ZMemory:
# A list of 64 tuples describing who's allowed to tweak header-bytes.
# Index into the list is the header-byte being tweaked.
# List value is a tuple of the form
#
# [minimum_z_version, game_allowed, interpreter_allowed]
#
# Note: in section 11.1 of the spec, we should technically be
# enforcing authorization by *bit*, not by byte. Maybe do this
# someday.
HEADER_PERMS = ([1,0,0], [3,0,1], None, None,
[1,0,0], None, [1,0,0], None,
[1,0,0], None, [1,0,0], None,
[1,0,0], None, [1,0,0], None,
[1,1,1], [1,1,1], None, None,
None, None, None, None,
[2,0,0], None, [3,0,0], None,
[3,0,0], None, [4,1,1], [4,1,1],
[4,0,1], [4,0,1], [5,0,1], None,
[5,0,1], None, [5,0,1], [5,0,1],
[6,0,0], None, [6,0,0], None,
[5,0,1], [5,0,1], [5,0,0], None,
[6,0,1], None, [1,0,1], None,
[5,0,0], None, [5,0,0], None,
None, None, None, None,
None, None, None, None)
def __init__(self, initial_string):
"""Construct class based on a string that represents an initial
'snapshot' of main memory."""
if initial_string is None:
raise ZMemoryBadInitialization
# Copy string into a _memory sequence that represents main memory.
self._total_size = len(initial_string)
self._memory = bytearray(initial_string)
# Figure out the different sections of memory
self._static_start = self.read_word(0x0e)
self._static_end = min(0x0ffff, self._total_size)
self._dynamic_start = 0
self._dynamic_end = self._static_start - 1
self._high_start = self.read_word(0x04)
self._high_end = self._total_size
self._global_variable_start = self.read_word(0x0c)
# Dynamic + static must not exceed 64k
dynamic_plus_static = ((self._dynamic_end - self._dynamic_start)
+ (self._static_end - self._static_start))
if dynamic_plus_static > 65534:
raise ZMemoryBadMemoryLayout
# What z-machine version is this story file?
self.version = self._memory[0]
# Validate game size
if 1 <= self.version <= 3:
if self._total_size > 131072:
raise ZMemoryBadStoryfileSize
elif 4 <= self.version <= 5:
if self._total_size > 262144:
raise ZMemoryBadStoryfileSize
else:
raise ZMemoryUnsupportedVersion
log("Memory system initialized, map follows")
log(" Dynamic memory: %x - %x" % (self._dynamic_start, self._dynamic_end))
log(" Static memory: %x - %x" % (self._static_start, self._static_end))
log(" High memory: %x - %x" % (self._high_start, self._high_end))
log(" Global variable start: %x" % self._global_variable_start)
def _check_bounds(self, index):
if isinstance(index, slice):
start, stop = index.start, index.stop
else:
start, stop = index, index
if not ((0 <= start < self._total_size) and (0 <= stop < self._total_size)):
raise ZMemoryOutOfBounds
def _check_static(self, index):
"""Throw error if INDEX is within the static-memory area."""
if isinstance(index, slice):
start, stop = index.start, index.stop
else:
start, stop = index, index
if (
self._static_start <= start <= self._static_end
and self._static_start <= stop <= self._static_end
):
raise ZMemoryIllegalWrite(index)
def print_map(self):
"""Pretty-print a description of the memory map."""
print("Dynamic memory: ", self._dynamic_start, "-", self._dynamic_end)
print(" Static memory: ", self._static_start, "-", self._static_end)
print(" High memory: ", self._high_start, "-", self._high_end)
def __getitem__(self, index):
"""Return the byte value stored at address INDEX.."""
self._check_bounds(index)
return self._memory[index]
def __setitem__(self, index, value):
"""Set VALUE in memory address INDEX."""
self._check_bounds(index)
self._check_static(index)
self._memory[index] = value
def __getslice__(self, start, end):
"""Return a sequence of bytes from memory."""
self._check_bounds(start)
self._check_bounds(end)
return self._memory[start:end]
def __setslice__(self, start, end, sequence):
"""Set a range of memory addresses to SEQUENCE."""
self._check_bounds(start)
self._check_bounds(end - 1)
self._check_static(start)
self._check_static(end - 1)
self._memory[start:end] = sequence
def word_address(self, address):
"""Return the 'actual' address of word address ADDRESS."""
if address < 0 or address > (self._total_size / 2):
raise ZMemoryOutOfBounds
return address*2
def packed_address(self, address):
"""Return the 'actual' address of packed address ADDRESS."""
if 1 <= self.version <= 3:
if address < 0 or address > (self._total_size / 2):
raise ZMemoryOutOfBounds
return address*2
elif 4 <= self.version <= 5:
if address < 0 or address > (self._total_size / 4):
raise ZMemoryOutOfBounds
return address*4
else:
raise ZMemoryUnsupportedVersion
def read_word(self, address):
"""Return the 16-bit value stored at ADDRESS, ADDRESS+1."""
if address < 0 or address >= (self._total_size - 1):
raise ZMemoryOutOfBounds
return (self._memory[address] << 8) + self._memory[(address + 1)]
def write_word(self, address, value):
"""Write the given 16-bit value at ADDRESS, ADDRESS+1."""
if address < 0 or address >= (self._total_size - 1):
raise ZMemoryOutOfBounds
# Handle writing of a word to the game headers. If write_word is
# used for this, we assume that it's the game that is setting the
# header. The interpreter should use the specialized method.
value_msb = (value >> 8) & 0xFF
value_lsb = value & 0xFF
if 0 <= address < 64:
self.game_set_header(address, value_msb)
self.game_set_header(address+1, value_lsb)
else:
self._memory[address] = value_msb
self._memory[address+1] = value_lsb
# Normal sequence syntax cannot be used to set bytes in the 64-byte
# header. Instead, the interpreter or game must call one of the
# following APIs.
def interpreter_set_header(self, address, value):
"""Possibly allow the interpreter to set header ADDRESS to VALUE."""
if address < 0 or address > 63:
raise ZMemoryOutOfBounds
perm_tuple = self.HEADER_PERMS[address]
if perm_tuple is None:
raise ZMemoryIllegalWrite(address)
if self.version >= perm_tuple[0] and perm_tuple[2]:
self._memory[address] = value
else:
raise ZMemoryIllegalWrite(address)
def game_set_header(self, address, value):
"""Possibly allow the game code to set header ADDRESS to VALUE."""
if address < 0 or address > 63:
raise ZMemoryOutOfBounds
perm_tuple = self.HEADER_PERMS[address]
if perm_tuple is None:
raise ZMemoryIllegalWrite(address)
if self.version >= perm_tuple[0] and perm_tuple[1]:
self._memory[address] = value
else:
raise ZMemoryIllegalWrite(address)
# The ZPU will need to read and write global variables. The 240
# global variables are located at a place determined by the header.
def read_global(self, varnum):
"""Return 16-bit value of global variable VARNUM. Incoming VARNUM
must be between 0x10 and 0xFF."""
if not (0x10 <= varnum <= 0xFF):
raise ZMemoryOutOfBounds
actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
return self.read_word(actual_address)
def write_global(self, varnum, value):
"""Write 16-bit VALUE to global variable VARNUM. Incoming VARNUM
must be between 0x10 and 0xFF."""
if not (0x10 <= varnum <= 0xFF):
raise ZMemoryOutOfBounds
if not (0x00 <= value <= 0xFFFF):
raise ZMemoryIllegalWrite(value)
log("Write %d to global variable %d" % (value, varnum))
actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
bf = bitfield.BitField(value)
self._memory[actual_address] = bf[8:15]
self._memory[actual_address + 1] = bf[0:7]
# The 'verify' opcode and the QueztalWriter class both need to have
# a checksum of memory generated.
def generate_checksum(self):
"""Return a checksum value which represents all the bytes of
memory added from $0040 upwards, modulo $10000."""
count = 0x40
total = 0
while count < self._total_size:
total += self._memory[count]
count += 1
return (total % 0x10000)

View file

@ -0,0 +1,432 @@
#
# A class which knows how to parse objects in the object tree.
# Implements section 12 of Z-code specification.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# This part of of the z-machine is where it becomes really clear that
# the original authoris were MIT Lisp-heads. :-) They've got a tree
# of objects going, where each object is basically a linked list of
# siblings. Specifically, each object contains a pointer to a parent,
# a pointer to its "next sibling" in the list, and a pointer to the
# head of its own children-list.
from .bitfield import BitField
from .zlogging import log
from .zstring import ZStringFactory
class ZObjectError(Exception):
"General exception for ZObject class"
pass
class ZObjectIllegalObjectNumber(ZObjectError):
"Illegal object number given."
pass
class ZObjectIllegalAttributeNumber(ZObjectError):
"Illegal attribute number given."
pass
class ZObjectIllegalPropertyNumber(ZObjectError):
"Illegal property number given."
pass
class ZObjectIllegalPropertySet(ZObjectError):
"Illegal set of a property whose size is not 1 or 2."
pass
class ZObjectIllegalVersion(ZObjectError):
"Unsupported z-machine version."
pass
class ZObjectIllegalPropLength(ZObjectError):
"Illegal property length."
pass
class ZObjectMalformedTree(ZObjectError):
"Object tree is malformed."
pass
# The interpreter should only need exactly one instance of this class.
class ZObjectParser:
def __init__(self, zmem):
self._memory = zmem
self._propdefaults_addr = zmem.read_word(0x0a)
self._stringfactory = ZStringFactory(self._memory)
if 1 <= self._memory.version <= 3:
self._objecttree_addr = self._propdefaults_addr + 62
elif 4 <= self._memory.version <= 5:
self._objecttree_addr = self._propdefaults_addr + 126
else:
raise ZObjectIllegalVersion
def _get_object_addr(self, objectnum):
"""Return address of object number OBJECTNUM."""
result = 0
if 1 <= self._memory.version <= 3:
if not (1 <= objectnum <= 255):
raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (9 * (objectnum - 1))
elif 4 <= self._memory.version <= 5:
if not (1 <= objectnum <= 65535):
log("error: there is no object %d" % objectnum)
raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (14 * (objectnum - 1))
else:
raise ZObjectIllegalVersion
log("address of object %d is %d" % (objectnum, result))
return result
def _get_parent_sibling_child(self, objectnum):
"""Return [parent, sibling, child] object numbers of object OBJECTNUM."""
addr = self._get_object_addr(objectnum)
result = 0
if 1 <= self._memory.version <= 3:
addr += 4 # skip past attributes
result = self._memory[addr:addr+3]
elif 4 <= self._memory.version <= 5:
addr += 6 # skip past attributes
result = [self._memory.read_word(addr),
self._memory.read_word(addr + 2),
self._memory.read_word(addr + 4)]
else:
raise ZObjectIllegalVersion
log ("parent/sibling/child of object %d is %d, %d, %d" %
(objectnum, result[0], result[1], result[2]))
return result
def _get_proptable_addr(self, objectnum):
"""Return address of property table of object OBJECTNUM."""
addr = self._get_object_addr(objectnum)
# skip past attributes and relatives
if 1 <= self._memory.version <= 3:
addr += 7
elif 4 <= self._memory.version <= 5:
addr += 12
else:
raise ZObjectIllegalVersion
return self._memory.read_word(addr)
def _get_default_property_addr(self, propnum):
"""Return address of default value for property PROPNUM."""
addr = self._propdefaults_addr
if 1 <= self._memory.version <= 3:
if not (1 <= propnum <= 31):
raise ZObjectIllegalPropertyNumber
elif 4 <= self._memory.version <= 5:
if not (1 <= propnum <= 63):
raise ZObjectIllegalPropertyNumber
else:
raise ZObjectIllegalVersion
return (addr + (2 * (propnum - 1)))
#--------- Public APIs -----------
def get_attribute(self, objectnum, attrnum):
"""Return value (0 or 1) of attribute number ATTRNUM of object
number OBJECTNUM."""
object_addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
if not (0 <= attrnum <= 31):
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum / 8)])
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum / 8)])
else:
raise ZObjectIllegalVersion
return bf[7 - (attrnum % 8)]
def get_all_attributes(self, objectnum):
"""Return a list of all attribute numbers that are set on object
OBJECTNUM"""
if 1 <= self._memory.version <= 3:
max = 32
elif 4 <= self._memory.version <= 5:
max = 48
else:
raise ZObjectIllegalVersion
# really inefficient, but who cares?
attrs = []
for i in range (0, max):
if self.get_attribute(objectnum, i):
attrs.append(i)
return attrs
def get_parent(self, objectnum):
"""Return object number of parent of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return parent
def get_child(self, objectnum):
"""Return object number of child of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return child
def get_sibling(self, objectnum):
"""Return object number of sibling of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return sibling
def set_parent(self, objectnum, new_parent_num):
"""Make OBJECTNUM's parent pointer point to NEW_PARENT_NUM."""
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:
self._memory.write_word(addr + 6, new_parent_num)
else:
raise ZObjectIllegalVersion
def set_child(self, objectnum, new_child_num):
"""Make OBJECTNUM's child pointer point to NEW_PARENT_NUM."""
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:
self._memory.write_word(addr + 10, new_child_num)
else:
raise ZObjectIllegalVersion
def set_sibling(self, objectnum, new_sibling_num):
"""Make OBJECTNUM's sibling pointer point to NEW_PARENT_NUM."""
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:
self._memory.write_word(addr + 8, new_sibling_num)
else:
raise ZObjectIllegalVersion
def insert_object(self, parent_object, new_child):
"""Prepend object NEW_CHILD to the list of PARENT_OBJECT's children."""
# Remember all the original pointers within the new_child
[p, s, c] = self._get_parent_sibling_child(new_child)
# 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
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."""
addr = self._get_proptable_addr(objectnum)
return self._stringfactory.get(addr+1)
def get_prop(self, objectnum, propnum):
"""Return either a byte or word value of property PROPNUM of
object OBJECTNUM."""
(addr, size) = self.get_prop_addr_len(objectnum, propnum)
if size == 1:
return self._memory[addr]
elif size == 2:
return self._memory.read_word(addr)
else:
raise ZObjectIllegalPropLength
def get_prop_addr_len(self, objectnum, propnum):
"""Return address & length of value for property number PROPNUM of
object number OBJECTNUM. If object has no such property, then
return the address & length of the 'default' value for the property."""
# start at the beginning of the object's proptable
addr = self._get_proptable_addr(objectnum)
# skip past the shortname of the object
addr += (2 * self._memory[addr])
pnum = 0
if 1 <= self._memory.version <= 3:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[4:0]
size = bf[7:5] + 1
if pnum == propnum:
return (addr, size)
addr += size
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[5:0]
if bf[7]:
bf2 = BitField(self._memory[addr])
addr += 1
size = bf2[5:0]
else:
if bf[6]:
size = 2
else:
size = 1
if pnum == propnum:
return (addr, size)
addr += size
else:
raise ZObjectIllegalVersion
# property list ran out, so return default propval instead.
default_value_addr = self._get_default_property_addr(propnum)
return (default_value_addr, 2)
def get_all_properties(self, objectnum):
"""Return a dictionary of all properties listed in the property
table of object OBJECTNUM. (Obviously, this discounts 'default'
property values.). The dictionary maps property numbers to (addr,
len) propval tuples."""
proplist = {}
# start at the beginning of the object's proptable
addr = self._get_proptable_addr(objectnum)
# skip past the shortname of the object
shortname_length = self._memory[addr]
addr += 1
addr += (2*shortname_length)
if 1 <= self._memory.version <= 3:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[4:0]
size = bf[7:5] + 1
proplist[pnum] = (addr, size)
addr += size
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[0:6]
if bf[7]:
bf2 = BitField(self._memory[addr])
addr += 1
size = bf2[0:6]
if size == 0:
size = 64
else:
if bf[6]:
size = 2
else:
size = 1
proplist[pnum] = (addr, size)
addr += size
else:
raise ZObjectIllegalVersion
return proplist
def set_property(self, objectnum, propnum, value):
"""Set a property on an object."""
proplist = self.get_all_properties(objectnum)
if propnum not in proplist:
raise ZObjectIllegalPropertyNumber
addr, size = proplist[propnum]
if size == 1:
self._memory[addr] = (value & 0xFF)
elif size == 2:
self._memory.write_word(addr, value)
else:
raise ZObjectIllegalPropertySet
def describe_object(self, objectnum):
"""For debugging purposes, pretty-print everything known about
object OBJECTNUM."""
print("Object number:", objectnum)
print(" Short name:", self.get_shortname(objectnum))
print(" Parent:", self.get_parent(objectnum), end=' ')
print(" Sibling:", self.get_sibling(objectnum), end=' ')
print(" Child:", self.get_child(objectnum))
print(" Attributes:", self.get_all_attributes(objectnum))
print(" Properties:")
proplist = self.get_all_properties(objectnum)
for key in list(proplist.keys()):
(addr, len) = proplist[key]
print(" [%2d] :" % key, end=' ')
for i in range(0, len):
print("%02X" % self._memory[addr+i], end=' ')
print()

View file

@ -0,0 +1,234 @@
#
# A class which represents the Program Counter and decodes instructions
# to be executed by the ZPU. Implements section 4 of Z-code specification.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from .bitfield import BitField
from .zlogging import log
class ZOperationError(Exception):
"General exception for ZOperation class"
pass
# Constants defining the known instruction types. These types are
# related to the number of operands the opcode has: for each operand
# count, there is a separate opcode table, and the actual opcode
# number is an index into that table.
OPCODE_0OP = 0
OPCODE_1OP = 1
OPCODE_2OP = 2
OPCODE_VAR = 3
OPCODE_EXT = 4
# Mapping of those constants to strings describing the opcode
# classes. Used for pretty-printing only.
OPCODE_STRINGS = {
OPCODE_0OP: '0OP',
OPCODE_1OP: '1OP',
OPCODE_2OP: '2OP',
OPCODE_VAR: 'VAR',
OPCODE_EXT: 'EXT',
}
# Constants defining the possible operand types.
LARGE_CONSTANT = 0x0
SMALL_CONSTANT = 0x1
VARIABLE = 0x2
ABSENT = 0x3
class ZOpDecoder:
def __init__(self, zmem, zstack):
""
self._memory = zmem
self._stack = zstack
self._parse_map = {}
self.program_counter = self._memory.read_word(0x6)
def _get_pc(self):
byte = self._memory[self.program_counter]
self.program_counter += 1
return byte
def get_next_instruction(self):
"""Decode the opcode & operands currently pointed to by the
program counter, and appropriately increment the program counter
afterwards. A decoded operation is returned to the caller in the form:
[opcode-class, opcode-number, [operand, operand, operand, ...]]
If the opcode has no operands, the operand list is present but empty."""
opcode = self._get_pc()
log("Decode opcode %x" % opcode)
# Determine the opcode type, and hand off further parsing.
if self._memory.version == 5 and opcode == 0xBE:
# Extended opcode
return self._parse_opcode_extended()
opcode = BitField(opcode)
if opcode[7] == 0:
# Long opcode
return self._parse_opcode_long(opcode)
elif opcode[6] == 0:
# Short opcode
return self._parse_opcode_short(opcode)
else:
# Variable opcode
return self._parse_opcode_variable(opcode)
def _parse_opcode_long(self, opcode):
"""Parse an opcode of the long form."""
# Long opcodes are always 2OP. The types of the two operands are
# encoded in bits 5 and 6 of the opcode.
log("Opcode is long")
LONG_OPERAND_TYPES = [SMALL_CONSTANT, VARIABLE]
operands = [self._parse_operand(LONG_OPERAND_TYPES[opcode[6]]),
self._parse_operand(LONG_OPERAND_TYPES[opcode[5]])]
return (OPCODE_2OP, opcode[0:5], operands)
def _parse_opcode_short(self, opcode):
"""Parse an opcode of the short form."""
# Short opcodes can have either 1 operand, or no operand.
log("Opcode is short")
operand_type = opcode[4:6]
operand = self._parse_operand(operand_type)
if operand is None: # 0OP variant
log("Opcode is 0OP variant")
return (OPCODE_0OP, opcode[0:4], [])
else:
log("Opcode is 1OP variant")
return (OPCODE_1OP, opcode[0:4], [operand])
def _parse_opcode_variable(self, opcode):
"""Parse an opcode of the variable form."""
log("Opcode is variable")
if opcode[5]:
log("Variable opcode of VAR kind")
opcode_type = OPCODE_VAR
else:
log("Variable opcode of 2OP kind")
opcode_type = OPCODE_2OP
opcode_num = opcode[0:5]
# 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()
return (opcode_type, opcode_num, operands)
def _parse_operand(self, operand_type):
"""Read and return an operand of the given type.
This assumes that the operand is in memory, at the address pointed
by the Program Counter."""
assert operand_type <= 0x3
if operand_type == LARGE_CONSTANT:
log("Operand is large constant")
operand = self._memory.read_word(self.program_counter)
self.program_counter += 2
elif operand_type == SMALL_CONSTANT:
log("Operand is small constant")
operand = self._get_pc()
elif operand_type == VARIABLE:
variable_number = self._get_pc()
log("Operand is variable %d" % variable_number)
if variable_number == 0:
log("Operand value comes from stack")
operand = self._stack.pop_stack() # TODO: make sure this is right.
elif variable_number < 16:
log("Operand value comes from local variable")
operand = self._stack.get_local_variable(variable_number - 1)
else:
log("Operand value comes from global variable")
operand = self._memory.read_global(variable_number)
elif operand_type == ABSENT:
log("Operand is absent")
operand = None
if operand is not None:
log("Operand value: %d" % operand)
return operand
def _parse_operands_byte(self):
"""Parse operands given by the operand byte and return a list of
values.
"""
operand_byte = BitField(self._get_pc())
operands = []
for operand_type in [operand_byte[6:8], operand_byte[4:6],
operand_byte[2:4], operand_byte[0:2]]:
operand = self._parse_operand(operand_type)
if operand is None:
break
operands.append(operand)
return operands
# Public funcs that the ZPU may also need to call, depending on the
# opcode being executed:
def get_zstring(self):
"""For string opcodes, return the address of the zstring pointed
to by the PC. Increment PC just past the text."""
start_addr = self.program_counter
bf = BitField(0)
while True:
bf.__init__(self._memory[self.program_counter])
self.program_counter += 2
if bf[7] == 1:
break
return start_addr
def get_store_address(self):
"""For store opcodes, read byte pointed to by PC and return the
variable number in which the operation result should be stored.
Increment the PC as necessary."""
return self._get_pc()
def get_branch_offset(self):
"""For branching opcodes, examine address pointed to by PC, and
return two values: first, either True or False (indicating whether
to branch if true or branch if false), and second, the address to
jump to. Increment the PC as necessary."""
bf = BitField(self._get_pc())
branch_if_true = bool(bf[7])
if bf[6]:
branch_offset = bf[0:6]
else:
# We need to do a little magic here. The branch offset is
# written as a signed 14-bit number, with signed meaning '-n' is
# written as '65536-n'. Or in this case, as we have 14 bits,
# '16384-n'.
#
# So, if the MSB (ie. bit 13) is set, we have a negative
# number. We take the value, and substract 16384 to get the
# actual offset as a negative integer.
#
# If the MSB is not set, we just extract the value and return it.
#
# Can you spell "Weird" ?
branch_offset = self._get_pc() + (bf[0:5] << 8)
if bf[5]:
branch_offset -= 8192
log('Branch if %s to offset %+d' % (branch_if_true, branch_offset))
return branch_if_true, branch_offset

View file

@ -0,0 +1,303 @@
#
# A template class representing the screen of a z-machine.
#
# Third-party programs are expected to subclass zscreen and override all
# the methods, then pass an instance of their class to be driven by
# the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zstream
# Constants for window numbers.
#
# TODO: The Z-Machine standard mentions upper and lower windows and
# window numbers, but never appears to define a mapping between the
# two. So the following values are simply a best guess and may need
# to be changed in the future.
WINDOW_UPPER = 1
WINDOW_LOWER = 2
# Constants for fonts. These are human-readable names for the font ID
# numbers as described in section 8.1.2 of the Z-Machine Standards
# Document.
FONT_NORMAL = 1
FONT_PICTURE = 2
FONT_CHARACTER_GRAPHICS = 3
FONT_FIXED_PITCH = 4
# Constants for text styles. These are human-readable names for the
# 'style' operand of the Z-Machine's 'set_text_style' opcode.
STYLE_ROMAN = 0
STYLE_REVERSE_VIDEO = 1
STYLE_BOLD = 2
STYLE_ITALIC = 4
STYLE_FIXED_PITCH = 8
# Constants for colors. These are human-readable names for the color
# codes as described in section 8.3.1 of the Z-Machine Standards
# Document. Note that the colors defined by Z-Machine Version 6 are
# not defined here, since we are not currently supporting that
# version.
COLOR_CURRENT = 0
COLOR_DEFAULT = 1
COLOR_BLACK = 2
COLOR_RED = 3
COLOR_GREEN = 4
COLOR_YELLOW = 5
COLOR_BLUE = 6
COLOR_MAGENTA = 7
COLOR_CYAN = 8
COLOR_WHITE = 9
# The number of screen rows that represents an "infinite" screen
# height; a screen with its rows set to this value should never
# display a [MORE] prompt, as described in section 8.4.1 of the
# Z-Machine Standards Document.
INFINITE_ROWS = 255
class ZScreenObserver:
"""Observer that is notified of changes in the state of a ZScreen
object.
Note that all methods in this class may be called by any thread at
any time, so they should take any necessary precautions to ensure
the integrity of any data they modify."""
def on_screen_size_change(self, zscreen):
"""Called when the screen size of a ZScreen changes."""
pass
def on_font_size_change(self, zscreen):
"""Called when the font size of a ZScreen changes."""
pass
class ZScreen(zstream.ZBufferableOutputStream):
"""Subclass of zstream.ZBufferableOutputStream that provides an
abstraction of a computer screen."""
def __init__(self):
"Constructor for the screen."
zstream.ZBufferableOutputStream.__init__(self)
# The size of the screen.
self._columns = 79
self._rows = 24
# The size of the current font, in characters
self._fontheight = 1
self._fontwidth = 1
# List of our observers; clients can directly append to and remove
# from this.
self.observers = []
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_status_line" : False,
"has_upper_window" : False,
"has_graphics_font" : False,
"has_text_colors": False,
}
# Window Management
#
# The z-machine has 2 windows for displaying text, "upper" and
# "lower". (The upper window has an inital height of 0.)
#
# The upper window is not necessarily where the "status line"
# appears; see section 8.6.1.1 of the Z-Machine Standards Document.
#
# The UI is responsible for making the lower window scroll properly,
# as well as wrapping words ("buffering"). The upper window,
# however, should *never* scroll or wrap words.
#
# The UI is also responsible for displaying [MORE] prompts when
# printing more text than the screen's rows can display. (Note: if
# the number of screen rows is INFINITE_ROWS, then it should never
# prompt [MORE].)
def get_screen_size(self):
"""Return the current size of the screen as [rows, columns]."""
return [self._rows, self._columns]
def select_window(self, window):
"""Select a window to be the 'active' window, and move that
window's cursor to the upper left.
WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER.
This method should only be implemented if the
has_upper_window feature is enabled."""
raise NotImplementedError()
def split_window(self, height):
"""Make the upper window appear and be HEIGHT lines tall. To
'unsplit' a window, call with a height of 0 lines.
This method should only be implemented if the has_upper_window
feature is enabled."""
raise NotImplementedError()
def set_cursor_position(self, x, y):
"""Set the cursor to (row, column) coordinates (X,Y) in the
current window, where (1,1) is the upper-left corner.
This function only does something if the current window is the
upper window; if the current window is the lower window, this
function has no effect.
This method should only be implemented if the has_upper_window
feature is enabled, as the upper window is the only window that
supports cursor positioning."""
raise NotImplementedError()
def erase_window(self, window=WINDOW_LOWER,
color=COLOR_CURRENT):
"""Erase WINDOW to background COLOR.
WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER.
If the has_upper_window feature is not supported, WINDOW is
ignored (in such a case, this function should clear the entire
screen).
COLOR should be one of the COLOR_* constants.
If the has_text_colors feature is not supported, COLOR is ignored."""
raise NotImplementedError()
def erase_line(self):
"""Erase from the current cursor position to the end of its line
in the current window.
This method should only be implemented if the has_upper_window
feature is enabled, as the upper window is the only window that
supports cursor positioning."""
raise NotImplementedError()
# Status Line
#
# These routines are only called if the has_status_line capability
# is set. Specifically, one of them is called whenever the
# show_status opcode is executed, and just before input is read from
# the user.
def print_status_score_turns(self, text, score, turns):
"""Print a status line in the upper window, as follows:
On the left side of the status line, print TEXT.
On the right side of the status line, print SCORE/TURNS.
This method should only be implemented if the has_status_line
feature is enabled.
"""
raise NotImplementedError()
def print_status_time(self, hours, minutes):
"""Print a status line in the upper window, as follows:
On the left side of the status line, print TEXT.
On the right side of the status line, print HOURS:MINUTES.
This method should only be implemented if the has_status_line
feature is enabled.
"""
raise NotImplementedError()
# Text Appearances
#
def get_font_size(self):
"""Return the current font's size as [width, height]."""
return [self._fontwidth, self._fontheight]
def set_font(self, font_number):
"""Set the current window's font to one of
FONT_NORMAL - normal font
FONT_PICTURE - picture font (IGNORE, this means nothing)
FONT_CHARACTER_GRAPHICS - character graphics font
FONT_FIXED_WIDTH - fixed-width font
If a font is not available, return None. Otherwise, set the
new font, and return the number of the *previous* font.
The only font that must be supported is FONT_NORMAL; all others
are optional, as per section 8.1.3 of the Z-Machine Standards
Document."""
raise NotImplementedError()
def set_text_style(self, style):
"""Set the current text style to the given text style.
STYLE is a sequence, each element of which should be one of the
following values:
STYLE_ROMAN - Roman
STYLE_REVERSE_VIDEO - Reverse video
STYLE_BOLD - Bold
STYLE_ITALIC - Italic
STYLE_FIXED_PITCH - Fixed-width
It is not a requirement that the screen implementation support
every combination of style; if no combinations are possible, it is
acceptable to simply use the first style in the sequence and ignore
the rest.
As per section 8.7.1.1 of the Z-Machine Standards Document, the
implementation need not provide bold or italic, and is free to
interpret them broadly.
"""
raise NotImplementedError()
def set_text_color(self, foreground_color, background_color):
"""Set current text foreground and background color. Each color
should correspond to one of the COLOR_* constants.
This method should only be implemented if the has_text_colors
feature is enabled.
"""
raise NotImplementedError()
# Standard output
def write(self, string):
"""Implementation of the ZOutputStream method. Prints the given
unicode string to the currently active window, using the current
text style settings."""
raise NotImplementedError()

View file

@ -0,0 +1,204 @@
#
# A class which manages both (1) the general purpose stack ("data
# stack") used by the story code to store temporary data, and (2) the
# interpreter-private stack of routines ("call stack") and their local
# variables.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from .zlogging import log
class ZStackError(Exception):
"General exception for stack or routine-related errors"
pass
class ZStackUnsupportedVersion(ZStackError):
"Unsupported version of Z-story file."
pass
class ZStackNoRoutine(ZStackError):
"No routine is being executed."
pass
class ZStackNoSuchVariable(ZStackError):
"Trying to access non-existent local variable."
pass
class ZStackPopError(ZStackError):
"Nothing to pop from stack!"
pass
# Helper class used by ZStackManager; a 'routine' object which
# includes its own private stack of data.
class ZRoutine:
def __init__(self, start_addr, return_addr, zmem, args,
local_vars=None, stack=None):
"""Initialize a routine object beginning at START_ADDR in ZMEM,
with initial argument values in list ARGS. If LOCAL_VARS is None,
then parse them from START_ADDR."""
self.start_addr = start_addr
self.return_addr = return_addr
self.program_counter = 0 # used when execution interrupted
if stack is None:
self.stack = []
else:
self.stack = stack[:]
if local_vars is not None:
self.local_vars = local_vars[:]
else:
num_local_vars = zmem[self.start_addr]
if not (0 <= num_local_vars <= 15):
log("num local vars is %d" % num_local_vars)
raise ZStackError
self.start_addr += 1
# Initialize the local vars in the ZRoutine's dictionary. This is
# 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 != 5:
raise ZStackUnsupportedVersion
# Place call arguments into local vars, if available
for i in range(0, len(args)):
self.local_vars[i] = args[i]
def pretty_print(self):
"Display a ZRoutine nicely, for debugging purposes."
log("ZRoutine: start address: %d" % self.start_addr)
log("ZRoutine: return value address: %d" % self.return_addr)
log("ZRoutine: program counter: %d" % self.program_counter)
log("ZRoutine: local variables: %d" % self.local_vars)
class ZStackBottom:
def __init__(self):
self.program_counter = 0 # used as a cache only
class ZStackManager:
def __init__(self, zmem):
self._memory = zmem
self._stackbottom = ZStackBottom()
self._call_stack = [self._stackbottom]
def get_local_variable(self, varnum):
"""Return value of local variable VARNUM from currently-running
routine. VARNUM must be a value between 0 and 15, and must
exist."""
if self._call_stack[-1] == self._stackbottom:
raise ZStackNoRoutine
if not 0 <= varnum <= 15:
raise ZStackNoSuchVariable
current_routine = self._call_stack[-1]
return current_routine.local_vars[varnum]
def set_local_variable(self, varnum, value):
"""Set value of local variable VARNUM to VALUE in
currently-running routine. VARNUM must be a value between 0 and
15, and must exist."""
if self._call_stack[1] == self._stackbottom:
raise ZStackNoRoutine
if not 0 <= varnum <= 15:
raise ZStackNoSuchVariable
current_routine = self._call_stack[-1]
current_routine.local_vars[varnum] = value
def push_stack(self, value):
"Push VALUE onto the top of the current routine's data stack."
current_routine = self._call_stack[-1]
current_routine.stack.append(value)
def pop_stack(self):
"Remove and return value from the top of the data stack."
current_routine = self._call_stack[-1]
return current_routine.stack.pop()
def get_stack_frame_index(self):
"Return current stack frame number. For use by 'catch' opcode."
return len(self._call_stack) - 1
# Used by quetzal save-file parser to reconstruct stack-frames.
def push_routine(self, routine):
"""Blindly push a ZRoutine object to the call stack.
WARNING: do not use this unless you know what you're doing; you
probably want the more full-featured start_routine() belowe
instead."""
self._call_stack.append(routine)
# ZPU should call this whenever it decides to call a new routine.
def start_routine(self, routine_addr, return_addr,
program_counter, args):
"""Save the state of the currenly running routine (by examining
the current value of the PROGRAM_COUNTER), and prepare for
execution of a new routine at ROUTINE_ADDR with list of initial
arguments ARGS."""
new_routine = ZRoutine(routine_addr, return_addr,
self._memory, args)
current_routine = self._call_stack[-1]
current_routine.program_counter = program_counter
self._call_stack.append(new_routine)
return new_routine.start_addr
# ZPU should call this whenever it decides to return from current
# routine.
def finish_routine(self, return_value):
"""Toss the currently running routine from the call stack, and
toss any leftover values pushed to the data stack by said routine.
Return the previous routine's program counter address, so that
execution can resume where from it left off."""
exiting_routine = self._call_stack.pop()
current_routine = self._call_stack[-1]
# Depending on many things, return stuff.
if exiting_routine.return_addr != None:
if exiting_routine.return_addr == 0: # Push to stack
self.push_stack(return_value)
elif 0 < exiting_routine.return_addr < 10: # Store in local var
self.set_local_variable(exiting_routine.return_addr,
return_value)
else: # Store in global var
self._memory.write_global(exiting_routine.return_addr,
return_value)
return current_routine.program_counter

View file

@ -0,0 +1,98 @@
#
# Template classes representing input/output streams of a z-machine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class ZOutputStream:
"""Abstract class representing an output stream for a z-machine."""
def write(self, string):
"""Prints the given unicode string to the output stream."""
raise NotImplementedError()
class ZBufferableOutputStream(ZOutputStream):
"""Abstract class representing a buffered output stream for a
z-machine, which can be optionally configured at run-time to provide
'buffering', also known as word-wrap."""
def __init__(self):
# This is a public variable that determines whether buffering is
# enabled for this stream or not. Subclasses can make it a
# Python property if necessary.
self.buffer_mode = False
class ZInputStream:
"""Abstract class representing an input stream for a z-machine."""
def __init__(self):
"""Constructor for the input stream."""
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_timed_input" : False,
}
def read_line(self, original_text=None, max_length=0,
terminating_characters=None,
timed_input_routine=None, timed_input_interval=0):
"""Reads from the input stream and returns a unicode string
representing the characters the end-user entered. The characters
are displayed to the screen as the user types them.
original_text, if provided, is pre-filled-in unicode text that the
end-user may delete or otherwise modify if they so choose.
max_length is the maximum length, in characters, of the text that
the end-user may enter. Any typing the end-user does after these
many characters have been entered is ignored. 0 means that there
is no practical limit to the number of characters the end-user can
enter.
terminating_characters is a string of unicode characters
representing the characters that can signify the end of a line of
input. If not provided, it defaults to a string containing a
carriage return character ('\r'). The terminating character is
not contained in the returned string.
timed_input_routine is a function that will be called every
time_input_interval milliseconds. This function should be of the
form:
def timed_input_routine(interval)
where interval is simply the value of timed_input_interval that
was passed in to read_line(). The function should also return
True if input should continue to be collected, or False if input
should stop being collected; if False is returned, then
read_line() will return a unicode string representing the
characters typed so far.
The timed input routine will be called from the same thread that
called read_line().
Note, however, that supplying a timed input routine is only useful
if the has_timed_input feature is supported by the input stream.
If it is unsupported, then the timed input routine will not be
called."""
raise NotImplementedError()
def read_char(self, timed_input_routine=None,
timed_input_interval=0):
"""Reads a single character from the stream and returns it as a
unicode character.
timed_input_routine and timed_input_interval are the same as
described in the documentation for read_line().
TODO: Should the character be automatically printed to the screen?
The Z-Machine documentation for the read_char opcode, which this
function is meant to ultimately implement, doesn't specify."""
raise NotImplementedError()

View file

@ -0,0 +1,97 @@
#
# A class which represents the i/o streams of the Z-Machine and their
# current state of selection.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Constants for output streams. These are human-readable names for
# the stream ID numbers as described in sections 7.1.1 and 7.1.2
# of the Z-Machine Standards Document.
OUTPUT_SCREEN = 1 # spews text to the the screen
OUTPUT_TRANSCRIPT = 2 # contains everything player typed, plus our responses
OUTPUT_MEMORY = 3 # if the z-machine wants to write to memory
OUTPUT_PLAYER_INPUT = 4 # contains *only* the player's typed commands
# Constants for input streams. These are human-readable names for the
# stream ID numbers as described in section 10.2 of the Z-Machine
# Standards Document.
INPUT_KEYBOARD = 0
INPUT_FILE = 1
class ZOutputStreamManager:
"""Manages output streams for a Z-Machine."""
def __init__(self, zmem, zui):
# TODO: Actually set/create the streams as necessary.
self._selectedStreams = []
self._streams = {}
def select(self, stream):
"""Selects the given stream ID for output."""
if stream not in self._selectedStreams:
self._selectedStreams.append(stream)
def unselect(self, stream):
"""Unselects the given stream ID for output."""
if stream in self._selectedStreams:
self._selectedStreams.remove(stream)
def get(self, stream):
"""Retrieves the given stream ID."""
return self._streams[stream]
def write(self, string):
"""Writes the given unicode string to all currently selected output
streams."""
# TODO: Implement section 7.1.2.2 of the Z-Machine Standards
# Document, so that while stream 3 is selected, no text is
# sent to any other output streams which are selected. (However,
# they remain selected.).
# TODO: Implement section 7.1.2.2.1, so that newlines are written to
# output stream 3 as ZSCII 13.
# TODO: Implement section 7.1.2.3, so that whiles stream 4 is
# selected, the only text printed to it is that of the player's
# commands and keypresses (as read by read_char). This may not
# ultimately happen via this method.
for stream in self._selectedStreams:
self._streams[stream].write(string)
class ZInputStreamManager:
"""Manages input streams for a Z-Machine."""
def __init__(self, zui):
# TODO: Actually set/create the streams as necessary.
self._selectedStream = None
self._streams = {}
def select(self, stream):
"""Selects the given stream ID as the currently active input stream."""
# TODO: Ensure section 10.2.4, so that while stream 1 is selected,
# the only text printed to it is that of the player's commands and
# keypresses (as read by read_char). Not sure where this logic
# will ultimately go, however.
self._selectedStream = stream
def getSelected(self):
"""Returns the input stream object for the currently active input
stream."""
return self._streams[self._selectedStream]
class ZStreamManager:
def __init__(self, zmem, zui):
self.input = ZInputStreamManager(zui)
self.output = ZOutputStreamManager(zmem, zui)

View file

@ -0,0 +1,408 @@
#
# A ZString-to-Unicode Universal Translator.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import itertools
class ZStringEndOfString(Exception):
"""No more data left in string."""
class ZStringIllegalAbbrevInString(Exception):
"""String abbreviation encountered within a string in a context
where it is not allowed."""
class ZStringTranslator:
def __init__(self, zmem):
self._mem = zmem
def get(self, addr):
from .bitfield import BitField
pos = (addr, BitField(self._mem.read_word(addr)), 0)
s = []
try:
while True:
s.append(self._read_char(pos))
pos = self._next_pos(pos)
except ZStringEndOfString:
return s
def _read_char(self, pos):
offset = (2 - pos[2]) * 5
return pos[1][offset:offset+5]
def _is_final(self, pos):
return pos[1][15] == 1
def _next_pos(self, pos):
from .bitfield import BitField
offset = pos[2] + 1
# Overflowing from current block?
if offset == 3:
# Was last block?
if self._is_final(pos):
# Kill processing.
raise ZStringEndOfString
# Get and return the next block.
return (pos[0] + 2,
BitField(self._mem.read_word(pos[0] + 2)),
0)
# Just increment the intra-block counter.
return (pos[0], pos[1], offset)
class ZCharTranslator:
# The default alphabet tables for ZChar translation.
# As the codes 0-5 are special, alphabets start with code 0x6.
DEFAULT_A0 = [ord(x) for x in "abcdefghijklmnopqrstuvwxyz"]
DEFAULT_A1 = [ord(x) for x in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
# A2 also has 0x6 as special char, so they start at 0x7.
DEFAULT_A2 = [ord(x) for x in "0123456789.,!?_#'\"/\\<-:()"]
DEFAULT_A2_V5 = [ord(x) for x in "\n0123456789.,!?_#'\"/\\-:()"]
ALPHA = (DEFAULT_A0, DEFAULT_A1, DEFAULT_A2)
ALPHA_V5 = (DEFAULT_A0, DEFAULT_A1, DEFAULT_A2_V5)
def __init__(self, zmem):
self._mem = zmem
# Initialize the alphabets
if self._mem.version == 5:
self._alphabet = self._load_custom_alphabet() or self.ALPHA_V5
else:
self._alphabet = self.ALPHA
# Initialize the special state handlers
self._load_specials()
# Initialize the abbreviations (if supported)
self._load_abbrev_tables()
def _load_custom_alphabet(self):
"""Check for the existence of a custom alphabet, and load it
if it does exist. Return the custom alphabet if it was found,
None otherwise."""
# The custom alphabet table address is at 0x34 in the memory.
if self._mem[0x34] == 0:
return None
alph_addr = self._mem.read_word(0x34)
alphabet = self._mem[alph_addr:alph_addr+78]
return [alphabet[0:26], alphabet[26:52], alphabet[52:78]]
def _load_abbrev_tables(self):
self._abbrevs = {}
# If the ZM doesn't do abbrevs, just return an empty dict.
if self._mem.version == 1:
return
# Build ourselves a ZStringTranslator for the abbrevs.
xlator = ZStringTranslator(self._mem)
def _load_subtable(num, base):
for i,zoff in [(i,base+(num*64)+(i*2))
for i in range(0, 32)]:
zaddr = self._mem.read_word(zoff)
zstr = xlator.get(self._mem.word_address(zaddr))
zchr = self.get(zstr, allow_abbreviations=False)
self._abbrevs[(num, i)] = zchr
abbrev_base = self._mem.read_word(0x18)
_load_subtable(0, abbrev_base)
# Does this ZM support the extended abbrev tables?
if self._mem.version >= 3:
_load_subtable(1, abbrev_base)
_load_subtable(2, abbrev_base)
def _load_specials(self):
"""Load the special character code handlers for the current
machine version.
"""
# The following three functions define the three possible
# special character code handlers.
def newline(state):
"""Append ZSCII 13 (newline) to the output."""
state['zscii'].append(13)
def shift_alphabet(state, direction, lock):
"""Shift the current alphaber up or down. If lock is
False, the alphabet will revert to the previous alphabet
after outputting 1 character. Else, the alphabet will
remain unchanged until the next shift.
"""
state['curr_alpha'] = (state['curr_alpha'] + direction) % 3
if lock:
state['prev_alpha'] = state['curr_alpha']
def abbreviation(state, abbrev):
"""Insert the given abbreviation from the given table into
the output stream.
This character was an abbreviation table number. The next
character will be the offset within that table of the
abbreviation. Set up a state handler to intercept the next
character and output the right abbreviation."""
def write_abbreviation(state, c, subtable):
state['zscii'] += self._abbrevs[(subtable, c)]
del state['state_handler']
# If we're parsing an abbreviation, there should be no
# nested abbreviations. So this is just a sanity check for
# people feeding us bad stories.
if not state['allow_abbreviations']:
raise ZStringIllegalAbbrevInString
state['state_handler'] = lambda s,c: write_abbreviation(s, c,
abbrev)
# Register the specials handlers depending on machine version.
if self._mem.version == 1:
self._specials = {
1: lambda s: newline(s),
2: lambda s: shift_alphabet(s, +1, False),
3: lambda s: shift_alphabet(s, -1, False),
4: lambda s: shift_alphabet(s, +1, True),
5: lambda s: shift_alphabet(s, -1, True),
}
elif self._mem.version == 2:
self._specials = {
1: lambda s: abbreviation(s, 0),
2: lambda s: shift_alphabet(s, +1, False),
3: lambda s: shift_alphabet(s, -1, False),
4: lambda s: shift_alphabet(s, +1, True),
5: lambda s: shift_alphabet(s, -1, True),
}
else: # ZM v3-5
self._specials = {
1: lambda s: abbreviation(s, 0),
2: lambda s: abbreviation(s, 1),
3: lambda s: abbreviation(s, 2),
4: lambda s: shift_alphabet(s, +1, False),
5: lambda s: shift_alphabet(s, -1, False),
}
def _special_zscii(self, state, char):
if 'zscii_char' not in list(state.keys()):
state['zscii_char'] = char
else:
zchar = (state['zscii_char'] << 5) + char
state['zscii'].append(zchar)
del state['zscii_char']
del state['state_handler']
def get(self, zstr, allow_abbreviations=True):
state = {
'curr_alpha': 0,
'prev_alpha': 0,
'zscii': [],
'allow_abbreviations': allow_abbreviations,
}
for c in zstr:
if 'state_handler' in list(state.keys()):
# If a special handler has registered itself, then hand
# processing over to it.
state['state_handler'](state, c)
elif c in list(self._specials.keys()):
# Hand off per-ZM version special char handling.
self._specials[c](state)
elif state['curr_alpha'] == 2 and c == 6:
# Handle the strange A2/6 character
state['state_handler'] = self._special_zscii
else:
# Do the usual Thing: append a zscii code to the
# decoded sequence and revert to the "previous"
# alphabet (or not, if it hasn't recently changed or
# was locked)
if c == 0:
# Append a space.
z = 32
elif state['curr_alpha'] == 2:
# The symbol alphabet table only has 25 chars
# because of the A2/6 special char, so we need to
# adjust differently.
z = self._alphabet[state['curr_alpha']][c-7]
else:
z = self._alphabet[state['curr_alpha']][c-6]
state['zscii'].append(z)
state['curr_alpha'] = state['prev_alpha']
return state['zscii']
class ZsciiTranslator:
# The default Unicode Translation Table that maps to ZSCII codes
# 155-251. The codes are unicode codepoints for a host of strange
# characters.
DEFAULT_UTT = [chr(x) for x in
(0xe4, 0xf6, 0xfc, 0xc4, 0xd6, 0xdc,
0xdf, 0xbb, 0xab, 0xeb, 0xef, 0xff,
0xcb, 0xcf, 0xe1, 0xe9, 0xed, 0xf3,
0xfa, 0xfd, 0xc1, 0xc9, 0xcd, 0xd3,
0xda, 0xdd, 0xe0, 0xe8, 0xec, 0xf2,
0xf9, 0xc0, 0xc8, 0xcc, 0xd2, 0xd9,
0xe2, 0xea, 0xee, 0xf4, 0xfb, 0xc2,
0xca, 0xce, 0xd4, 0xdb, 0xe5, 0xc5,
0xf8, 0xd8, 0xe3, 0xf1, 0xf5, 0xc3,
0xd1, 0xd5, 0xe6, 0xc6, 0xe7, 0xc7,
0xfe, 0xf0, 0xde, 0xd0, 0xa3, 0x153,
0x152, 0xa1, 0xbf)]
# And here is the offset at which the Unicode Translation Table
# starts.
UTT_OFFSET = 155
# This subclass just lists all the "special" character codes that
# are capturable from an input stream. They're just there so that
# the user of the virtual machine can give them a nice name.
class Input:
DELETE = 8
ESCAPE = 27
# The cursor pad
CUR_UP = 129
CUR_DOWN = 130
CUR_LEFT = 131
CUR_RIGHT = 132
# The Function keys
F1 = 133
F2 = 134
F3 = 135
F4 = 136
F5 = 137
F6 = 138
F7 = 139
F8 = 140
F9 = 141
F10 = 142
F11 = 143
F12 = 144
# The numpad (keypad) keys.
KP_0 = 145
KP_1 = 146
KP_2 = 147
KP_3 = 148
KP_4 = 149
KP_5 = 150
KP_6 = 151
KP_7 = 152
KP_8 = 153
KP_9 = 154
def __init__(self, zmem):
self._mem = zmem
self._output_table = {
0 : "",
10: "\n"
}
self._input_table = {
"\n": 10
}
self._load_unicode_table()
# Populate the input and output tables with the ASCII and UTT
# characters.
for code,char in [(x,chr(x)) for x in range(32,127)]:
self._output_table[code] = char
self._input_table[char] = code
# Populate the input table with the extra "special" input
# codes. The cool trick we use here, is that all these values
# are in fact numbers, so their key will be available in both
# dicts, and ztoa will provide the correct code if you pass it
# a special symbol instead of a character to translate!
#
# Oh and we also pull the items from the subclass into this
# instance, so as to make reference to these special codes
# easier.
for name,code in [(c,v) for c,v in list(self.Input.__dict__.items())
if not c.startswith('__')]:
self._input_table[code] = code
setattr(self, name, code)
# The only special support required for ZSCII: ZM v5 defines
# an extra character code to represent a mouse click. If we're
# booting a v5 ZM, define this.
if self._mem.version == 5:
self.MOUSE_CLICK = 254
self._input_table[254] = 254
def _load_unicode_table(self):
if self._mem.version == 5:
# Read the header extension table address
ext_table_addr = self._mem.read_word(0x36)
# If:
# - The extension header's address is non-null
# - There are at least 3 words in the extension header
# (the unicode translation table is the third word)
# - The 3rd word (unicode translation table address) is
# non-null
#
# Then there is a unicode translation table other than the
# default that needs loading.
if (ext_table_addr != 0 and
self._mem.read_word(ext_table_addr) >= 3 and
self._mem.read_word(ext_table_addr+6) != 0):
# The first byte is the number of unicode characters
# in the table.
utt_len = self._mem[ext_table_addr]
# Build the range of addresses to load from, and build
# the unicode translation table as a list of unicode
# chars.
utt_range = range(ext_table+1, ext_table+1+(utt_len*2), 2)
utt = [chr(self._mem.read_word(i)) for i in utt_range]
else:
utt = self.DEFAULT_UTT
# One way or another, we have a unicode translation
# table. Add all the characters in it to the input and
# output translation tables.
for zscii, unichar in zip(itertools.count(155), utt):
self._output_table[zscii] = unichar
self._input_table[unichar] = zscii
def ztou(self, index):
"""Translate the given ZSCII code into the corresponding
output Unicode character and return it, or raise an exception if
the requested index has no translation."""
try:
return self._output_table[index]
except KeyError:
raise IndexError("No such ZSCII character")
def utoz(self, char):
"""Translate the given Unicode code into the corresponding
input ZSCII character and return it, or raise an exception if
the requested character has no translation."""
try:
return self._input_table[char]
except KeyError:
raise IndexError("No such input character")
def get(self, zscii):
return ''.join([self.ztou(c) for c in zscii])
class ZStringFactory:
def __init__(self, zmem):
self._mem = zmem
self.zstr = ZStringTranslator(zmem)
self.zchr = ZCharTranslator(zmem)
self.zscii = ZsciiTranslator(zmem)
def get(self, addr):
zstr = self.zstr.get(addr)
zchr = self.zchr.get(zstr)
return self.zscii.get(zchr)

View file

@ -0,0 +1,31 @@
#
# A class representing the entire user interface of a Z-Machine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zaudio, zfilesystem, zscreen, zstream
class ZUI:
"""This class encapsulates the entire user interface of a
Z-Machine, providing access to all functionality that the end-user
directly experiences or interacts with."""
def __init__(self, audio, screen, keyboard_input, filesystem):
"""Initializes the ZUI with the given components."""
assert isinstance(audio, zaudio.ZAudio)
assert isinstance(screen, zscreen.ZScreen)
assert isinstance(keyboard_input, zstream.ZInputStream)
assert isinstance(filesystem, zfilesystem.ZFilesystem)
# The following are all public attributes of the instance, but
# should be considered read-only. In the future, we may want
# to make them Python properties.
self.audio = audio
self.screen = screen
self.keyboard_input = keyboard_input
self.filesystem = filesystem

View file

@ -0,0 +1,251 @@
"""
Unit tests for the 12 newly implemented Z-machine opcodes.
These tests verify the basic behavior of each opcode by mocking the
required dependencies (memory, stack, decoder, etc).
"""
from unittest import TestCase
from unittest.mock import Mock
from mudlib.zmachine.zcpu import ZCpu, ZCpuDivideByZero, ZCpuQuit
class MockMemory:
"""Mock memory for testing."""
def __init__(self):
self.data = bytearray(65536)
self.version = 3
self.globals = {}
def __getitem__(self, addr):
return self.data[addr]
def __setitem__(self, addr, value):
self.data[addr] = value & 0xFF
def read_word(self, addr):
return (self.data[addr] << 8) | self.data[addr + 1]
def write_word(self, addr, value):
self.data[addr] = (value >> 8) & 0xFF
self.data[addr + 1] = value & 0xFF
def read_global(self, varnum):
return self.globals.get(varnum, 0)
def write_global(self, varnum, value):
self.globals[varnum] = value
class MockStackManager:
"""Mock stack manager for testing."""
def __init__(self):
self.stack = []
self.locals = [0] * 15
def push_stack(self, value):
self.stack.append(value)
def pop_stack(self):
return self.stack.pop()
def get_local_variable(self, index):
return self.locals[index]
def set_local_variable(self, index, value):
self.locals[index] = value
def finish_routine(self, return_value):
# Mock implementation - just return a PC value
return 0x1000
class MockOpDecoder:
"""Mock opcode decoder for testing."""
def __init__(self):
self.program_counter = 0x800
self.store_address = None
self.branch_condition = True
self.branch_offset = 2
def get_store_address(self):
return self.store_address
def get_branch_offset(self):
return (self.branch_condition, self.branch_offset)
class MockUI:
"""Mock UI for testing."""
def __init__(self):
self.screen = Mock()
self.screen.write = Mock()
class ZMachineOpcodeTests(TestCase):
"""Test suite for Z-machine opcodes."""
def setUp(self):
"""Create a minimal CPU for testing."""
self.memory = MockMemory()
self.stack = MockStackManager()
self.decoder = MockOpDecoder()
self.ui = MockUI()
# Create CPU with mocked dependencies
self.cpu = ZCpu(
self.memory,
self.decoder,
self.stack,
Mock(), # objects
Mock(), # string
Mock(), # stream manager
self.ui,
)
def test_op_nop(self):
"""Test NOP does nothing."""
# Should just return without error
self.cpu.op_nop()
def test_op_new_line(self):
"""Test new_line prints a newline."""
self.cpu.op_new_line()
self.ui.screen.write.assert_called_once_with("\n")
def test_op_ret_popped(self):
"""Test ret_popped pops stack and returns."""
self.stack.push_stack(42)
self.cpu.op_ret_popped()
# Should have popped the value and set PC
self.assertEqual(len(self.stack.stack), 0)
self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)
def test_op_pop(self):
"""Test pop discards top of stack."""
self.stack.push_stack(100)
self.stack.push_stack(200)
self.cpu.op_pop()
self.assertEqual(len(self.stack.stack), 1)
self.assertEqual(self.stack.stack[0], 100)
def test_op_quit(self):
"""Test quit raises exception."""
with self.assertRaises(ZCpuQuit):
self.cpu.op_quit()
def test_op_dec(self):
"""Test decrement variable."""
# Set local variable 1 to 10
self.stack.set_local_variable(0, 10)
# Decrement it (variable 1 = local 0)
self.cpu.op_dec(1)
# Should be 9 now
self.assertEqual(self.stack.get_local_variable(0), 9)
def test_op_dec_wrapping(self):
"""Test decrement wraps at zero."""
# Set local variable 1 to 0
self.stack.set_local_variable(0, 0)
# Decrement it
self.cpu.op_dec(1)
# Should wrap to 65535
self.assertEqual(self.stack.get_local_variable(0), 65535)
def test_op_not(self):
"""Test bitwise NOT."""
self.decoder.store_address = 0 # Store to stack
self.cpu.op_not(0x00FF)
result = self.stack.pop_stack()
self.assertEqual(result, 0xFF00)
def test_op_not_all_ones(self):
"""Test NOT of all ones gives zero."""
self.decoder.store_address = 0
self.cpu.op_not(0xFFFF)
result = self.stack.pop_stack()
self.assertEqual(result, 0)
def test_op_load(self):
"""Test load reads variable."""
# Set local variable 2 to 42
self.stack.set_local_variable(1, 42)
self.decoder.store_address = 0 # Store to stack
# Load variable 2
self.cpu.op_load(2)
result = self.stack.pop_stack()
self.assertEqual(result, 42)
def test_op_mod_positive(self):
"""Test modulo with positive numbers."""
self.decoder.store_address = 0
self.cpu.op_mod(17, 5)
result = self.stack.pop_stack()
self.assertEqual(result, 2)
def test_op_mod_negative_dividend(self):
"""Test modulo with negative dividend."""
self.decoder.store_address = 0
# -17 mod 5 = -2 (z-machine uses C-style truncation toward zero)
self.cpu.op_mod(self.cpu._unmake_signed(-17), 5)
result = self.cpu._make_signed(self.stack.pop_stack())
self.assertEqual(result, -2)
def test_op_mod_divide_by_zero(self):
"""Test modulo by zero raises exception."""
self.decoder.store_address = 0
with self.assertRaises(ZCpuDivideByZero):
self.cpu.op_mod(10, 0)
def test_op_storeb(self):
"""Test store byte to memory."""
self.cpu.op_storeb(0x1000, 5, 0x42)
self.assertEqual(self.memory[0x1005], 0x42)
def test_op_storeb_truncates(self):
"""Test store byte truncates to 8 bits."""
self.cpu.op_storeb(0x2000, 10, 0x1FF)
self.assertEqual(self.memory[0x200A], 0xFF)
def test_op_jg_true(self):
"""Test jump if greater (signed) - true case."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(10, 5)
# Should have branched (offset - 2)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 98)
def test_op_jg_false(self):
"""Test jump if greater (signed) - false case."""
self.decoder.branch_condition = True # Branch if true (but test is false)
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(5, 10)
# Should not have branched (test is false, condition is true)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_jg_signed(self):
"""Test jump if greater handles signed comparison."""
# -1 (as unsigned 65535) should NOT be greater than 1
self.decoder.branch_condition = False
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(65535, 1)
# Should not branch (false condition matches)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_pull(self):
"""Test pull from stack to variable."""
# Push value onto stack
self.stack.push_stack(123)
# Pull into local variable 1
self.cpu.op_pull(1)
# Should have stored in local variable 0 (variable 1)
self.assertEqual(self.stack.get_local_variable(0), 123)
# Stack should be empty
self.assertEqual(len(self.stack.stack), 0)