diff --git a/src/mudlib/zmachine/LICENSE b/src/mudlib/zmachine/LICENSE new file mode 100644 index 0000000..7ff653a --- /dev/null +++ b/src/mudlib/zmachine/LICENSE @@ -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. diff --git a/src/mudlib/zmachine/__init__.py b/src/mudlib/zmachine/__init__.py new file mode 100644 index 0000000..2c7dbc4 --- /dev/null +++ b/src/mudlib/zmachine/__init__.py @@ -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"] diff --git a/src/mudlib/zmachine/bitfield.py b/src/mudlib/zmachine/bitfield.py new file mode 100644 index 0000000..0c79551 --- /dev/null +++ b/src/mudlib/zmachine/bitfield.py @@ -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)]) diff --git a/src/mudlib/zmachine/glk.py b/src/mudlib/zmachine/glk.py new file mode 100644 index 0000000..558b5ee --- /dev/null +++ b/src/mudlib/zmachine/glk.py @@ -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.") diff --git a/src/mudlib/zmachine/quetzal.py b/src/mudlib/zmachine/quetzal.py new file mode 100644 index 0000000..2048d9a --- /dev/null +++ b/src/mudlib/zmachine/quetzal.py @@ -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.") diff --git a/src/mudlib/zmachine/trivialzui.py b/src/mudlib/zmachine/trivialzui.py new file mode 100644 index 0000000..a76ac58 --- /dev/null +++ b/src/mudlib/zmachine/trivialzui.py @@ -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(' ') + ) diff --git a/src/mudlib/zmachine/zaudio.py b/src/mudlib/zmachine/zaudio.py new file mode 100644 index 0000000..bd93cb7 --- /dev/null +++ b/src/mudlib/zmachine/zaudio.py @@ -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() diff --git a/src/mudlib/zmachine/zcpu.py b/src/mudlib/zmachine/zcpu.py new file mode 100644 index 0000000..81c107e --- /dev/null +++ b/src/mudlib/zmachine/zcpu.py @@ -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), + ], + } diff --git a/src/mudlib/zmachine/zfilesystem.py b/src/mudlib/zmachine/zfilesystem.py new file mode 100644 index 0000000..2746e7c --- /dev/null +++ b/src/mudlib/zmachine/zfilesystem.py @@ -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() diff --git a/src/mudlib/zmachine/zlexer.py b/src/mudlib/zmachine/zlexer.py new file mode 100644 index 0000000..8360dc4 --- /dev/null +++ b/src/mudlib/zmachine/zlexer.py @@ -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 diff --git a/src/mudlib/zmachine/zlogging.py b/src/mudlib/zmachine/zlogging.py new file mode 100644 index 0000000..1491f93 --- /dev/null +++ b/src/mudlib/zmachine/zlogging.py @@ -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)) diff --git a/src/mudlib/zmachine/zmachine.py b/src/mudlib/zmachine/zmachine.py new file mode 100644 index 0000000..19abb83 --- /dev/null +++ b/src/mudlib/zmachine/zmachine.py @@ -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() diff --git a/src/mudlib/zmachine/zmemory.py b/src/mudlib/zmachine/zmemory.py new file mode 100644 index 0000000..f556361 --- /dev/null +++ b/src/mudlib/zmachine/zmemory.py @@ -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) diff --git a/src/mudlib/zmachine/zobjectparser.py b/src/mudlib/zmachine/zobjectparser.py new file mode 100644 index 0000000..007145c --- /dev/null +++ b/src/mudlib/zmachine/zobjectparser.py @@ -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() + diff --git a/src/mudlib/zmachine/zopdecoder.py b/src/mudlib/zmachine/zopdecoder.py new file mode 100644 index 0000000..5e08125 --- /dev/null +++ b/src/mudlib/zmachine/zopdecoder.py @@ -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 diff --git a/src/mudlib/zmachine/zscreen.py b/src/mudlib/zmachine/zscreen.py new file mode 100644 index 0000000..2762bdd --- /dev/null +++ b/src/mudlib/zmachine/zscreen.py @@ -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() diff --git a/src/mudlib/zmachine/zstackmanager.py b/src/mudlib/zmachine/zstackmanager.py new file mode 100644 index 0000000..6993dfb --- /dev/null +++ b/src/mudlib/zmachine/zstackmanager.py @@ -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 + diff --git a/src/mudlib/zmachine/zstream.py b/src/mudlib/zmachine/zstream.py new file mode 100644 index 0000000..6a34e9a --- /dev/null +++ b/src/mudlib/zmachine/zstream.py @@ -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() diff --git a/src/mudlib/zmachine/zstreammanager.py b/src/mudlib/zmachine/zstreammanager.py new file mode 100644 index 0000000..5665961 --- /dev/null +++ b/src/mudlib/zmachine/zstreammanager.py @@ -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) diff --git a/src/mudlib/zmachine/zstring.py b/src/mudlib/zmachine/zstring.py new file mode 100644 index 0000000..46d6fbe --- /dev/null +++ b/src/mudlib/zmachine/zstring.py @@ -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) diff --git a/src/mudlib/zmachine/zui.py b/src/mudlib/zmachine/zui.py new file mode 100644 index 0000000..fb01a33 --- /dev/null +++ b/src/mudlib/zmachine/zui.py @@ -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 diff --git a/tests/test_zmachine_opcodes.py b/tests/test_zmachine_opcodes.py new file mode 100644 index 0000000..f3d947a --- /dev/null +++ b/tests/test_zmachine_opcodes.py @@ -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)