diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b1068f9..75767ce 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,7 +20,10 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv. - `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec - `scripts/` - standalone tools (map renderer, etc) - `build/` - generated output (gitignored) -- `repos/` - symlinked reference repos (telnetlib3, miniboa). gitignored, not our code +- `repos/` - symlinked reference repos, gitignored, not our code. includes: + - `repos/viola/` - DFillmore/viola z-machine interpreter (working, global state) + - `repos/zvm/` - sussman/zvm z-machine interpreter (clean architecture, half-built) + - `repos/telnetlib3/`, `repos/miniboa/` - telnet libraries ## Docs diff --git a/.gitignore b/.gitignore index f61b122..55feb34 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ data .worktrees .testmondata *.z* +debug.log +disasm.log diff --git a/src/mudlib/zmachine/__init__.py b/src/mudlib/zmachine/__init__.py index 2c7dbc4..05cacb8 100644 --- a/src/mudlib/zmachine/__init__.py +++ b/src/mudlib/zmachine/__init__.py @@ -1,5 +1,4 @@ """Hybrid z-machine interpreter based on sussman/zvm. - Original: https://github.com/sussman/zvm (BSD license) Extended with opcode implementations ported from DFillmore/viola. """ diff --git a/src/mudlib/zmachine/bitfield.py b/src/mudlib/zmachine/bitfield.py index 0c79551..d68b17f 100644 --- a/src/mudlib/zmachine/bitfield.py +++ b/src/mudlib/zmachine/bitfield.py @@ -9,6 +9,7 @@ # 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. @@ -31,7 +32,7 @@ class BitField: start, stop = index.start, index.stop if start > stop: (start, stop) = (stop, start) - mask = (1<<(stop - start)) -1 + mask = (1 << (stop - start)) - 1 return (self._d >> start) & mask else: return (self._d >> index) & 1 @@ -42,7 +43,7 @@ class BitField: value = ord(value) if isinstance(index, slice): start, stop = index.start, index.stop - mask = (1<<(stop - start)) -1 + mask = (1 << (stop - start)) - 1 value = (value & mask) << start mask = mask << start self._d = (self._d & ~mask) | value @@ -50,7 +51,7 @@ class BitField: else: value = (value) << index mask = (1) << index - self._d = (self._d & ~mask) | value + self._d = (self._d & ~mask) | value def __int__(self): """Return the whole bitfield as an integer.""" @@ -58,5 +59,4 @@ class BitField: 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)]) + return "".join([f"{self[i]}" for i in range(len - 1, -1, -1)]) diff --git a/src/mudlib/zmachine/glk.py b/src/mudlib/zmachine/glk.py index 558b5ee..85bd747 100644 --- a/src/mudlib/zmachine/glk.py +++ b/src/mudlib/zmachine/glk.py @@ -80,38 +80,37 @@ 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 +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 @@ -126,9 +125,10 @@ style_User1 = 9 style_User2 = 10 style_NUMSTYLES = 11 + class stream_result_t(ctypes.Structure): - _fields_ = [("readcount", glui32), - ("writecount", glui32)] + _fields_ = [("readcount", glui32), ("writecount", glui32)] + wintype_AllTypes = 0 wintype_Pair = 1 @@ -137,23 +137,23 @@ wintype_TextBuffer = 3 wintype_TextGrid = 4 wintype_Graphics = 5 -winmethod_Left = 0x00 +winmethod_Left = 0x00 winmethod_Right = 0x01 winmethod_Above = 0x02 winmethod_Below = 0x03 -winmethod_DirMask = 0x0f +winmethod_DirMask = 0x0F winmethod_Fixed = 0x10 winmethod_Proportional = 0x20 -winmethod_DivisionMask = 0xf0 +winmethod_DivisionMask = 0xF0 fileusage_Data = 0x00 fileusage_SavedGame = 0x01 fileusage_Transcript = 0x02 fileusage_InputRecord = 0x03 -fileusage_TypeMask = 0x0f +fileusage_TypeMask = 0x0F -fileusage_TextMode = 0x100 +fileusage_TextMode = 0x100 fileusage_BinaryMode = 0x000 filemode_Write = 0x01 @@ -190,17 +190,26 @@ 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)), + (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_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))), + ( + 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,)), @@ -213,8 +222,7 @@ CORE_GLK_LIB_API = [ (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)), + (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,)), @@ -236,14 +244,11 @@ CORE_GLK_LIB_API = [ (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))), + (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_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)), + (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,)), @@ -252,14 +257,13 @@ CORE_GLK_LIB_API = [ (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_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. @@ -269,20 +273,24 @@ UNICODE_GLK_LIB_API = [ (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)), + (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)), + (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)), + ( + 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)) - ] + ( + 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 @@ -299,7 +307,7 @@ class GlkLib: self.__bind_prototypes(CORE_GLK_LIB_API) - if self.glk_gestalt(gestalt_Unicode, 0) == 1: + if self.glk_gestalt(gestalt_Unicode, 0) == 1: # type: ignore[unresolved-attribute] self.__bind_prototypes(UNICODE_GLK_LIB_API) else: self.__bind_not_implemented_prototypes(UNICODE_GLK_LIB_API) @@ -324,8 +332,7 @@ class GlkLib: support some optional extension of the Glk API.""" def notImplementedFunction(*args, **kwargs): - raise NotImplementedError( "Function not implemented " \ - "by this Glk library." ) + raise NotImplementedError("Function not implemented by this Glk library.") for function_prototype in function_prototypes: _, function_name, _ = function_prototype diff --git a/src/mudlib/zmachine/quetzal.py b/src/mudlib/zmachine/quetzal.py index 2048d9a..a8eb872 100644 --- a/src/mudlib/zmachine/quetzal.py +++ b/src/mudlib/zmachine/quetzal.py @@ -29,435 +29,434 @@ from .zlogging import log # 4-byte chunkname, 4-byte length, length bytes of data # ... + class QuetzalError(Exception): - "General exception for Quetzal classes." - pass + "General exception for Quetzal classes." + + pass + class QuetzalMalformedChunk(QuetzalError): - "Malformed chunk detected." + "Malformed chunk detected." + class QuetzalNoSuchSavefile(QuetzalError): - "Cannot locate save-game file." + "Cannot locate save-game file." + class QuetzalUnrecognizedFileFormat(QuetzalError): - "Not a valid Quetzal file." + "Not a valid Quetzal file." + class QuetzalIllegalChunkOrder(QuetzalError): - "IFhd chunk came after Umem/Cmem/Stks chunks (see section 5.4)." + "IFhd chunk came after Umem/Cmem/Stks chunks (see section 5.4)." + class QuetzalMismatchedFile(QuetzalError): - "Quetzal file dosen't match current game." + "Quetzal file dosen't match current game." + class QuetzalMemoryOutOfBounds(QuetzalError): - "Decompressed dynamic memory has gone out of bounds." + "Decompressed dynamic memory has gone out of bounds." + class QuetzalMemoryMismatch(QuetzalError): - "Savefile's dynamic memory image is incorrectly sized." + "Savefile's dynamic memory image is incorrectly sized." + class QuetzalStackFrameOverflow(QuetzalError): - "Stack frame parsing went beyond bounds of 'Stks' chunk." + "Stack frame parsing went beyond bounds of 'Stks' chunk." class QuetzalParser: - """A class to read a Quetzal save-file and modify a z-machine.""" + """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 __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(f" Found release number {chunk_release}") + log(f" Found serial number {int(chunk_serial)}") + log(f" Found checksum {chunk_checksum}") + log(f" Initial program counter value is {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(f" Dynamic memory length is {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(f" Set byte {memcounter}:{savegame_mem[memcounter]}") + else: + bytecounter += 1 + num_extra_zeros = runlength_bytes[bytecounter] + memcounter += 1 + num_extra_zeros + bytecounter += 1 + log(f" Skipped {1 + num_extra_zeros} unchanged bytes") + 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(f" Dynamic memory length is {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(f" Found {len(local_vars)} 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(f" Found {len(stack_values)} local 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(f"Author of file: {data}") + self._last_loaded_metadata["author"] = data + + def _parse_copyright(self, data): + """Parse a chunk of type (c) . Display the copyright.""" + + log(f"Copyright: (C) {data}") + self._last_loaded_metadata["copyright"] = data + + def _parse_anno(self, data): + """Parse a chunk of type ANNO. Display any annotation""" + + log(f"Annotation: {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(f"Attempting to load saved game from '{savefile_path}'") + self._file = open(savefile_path, "rb") # noqa: SIM115 + + # 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(f"Total length of FORM data is {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(f"** Found chunk ID {chunkname}: length {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.") - 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.""" + """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 __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.""" + 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. + ### 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 + ### 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" + return "0" + def _generate_cmem_chunk(self): + """Return a compressed chunk of data representing the compressed + image of the zmachine's main memory.""" - 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" - ### 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(f"XOR array is {diffarray}") - # 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 - # 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_stks_chunk(self): - """Return a stacks chunk, describing the stack state of the - zmachine at this moment.""" + def _generate_anno_chunk(self): + """Return an annotation chunk, containing metadata about the ZVM + interpreter which created the savefile.""" - ### TODO: write this - return "0" + ### TODO: write this + return "0" + # --------- Public APIs ----------- - def _generate_anno_chunk(self): - """Return an annotation chunk, containing metadata about the ZVM - interpreter which created the savefile.""" + def write(self, savefile_path): + """Write the current zmachine state to a new Quetzal-file at + SAVEFILE_PATH.""" - ### TODO: write this - return "0" + log(f"Attempting to write game-state to '{savefile_path}'") + self._file = open(savefile_path, "w") # noqa: SIM115 + ifhd_chunk = self._generate_ifhd_chunk() + cmem_chunk = self._generate_cmem_chunk() + stks_chunk = self._generate_stks_chunk() + anno_chunk = self._generate_anno_chunk() - #--------- Public APIs ----------- + _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") - 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.") + # Write nested chunks. + for chunk_data in (ifhd_chunk, cmem_chunk, stks_chunk, anno_chunk): + self._file.write(chunk_data) + 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 index a76ac58..43ed118 100644 --- a/src/mudlib/zmachine/trivialzui.py +++ b/src/mudlib/zmachine/trivialzui.py @@ -22,246 +22,248 @@ from .zlogging import log class TrivialAudio(zaudio.ZAudio): - def __init__(self): - zaudio.ZAudio.__init__(self) - self.features = { - "has_more_than_a_bleep": False, - } + 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(f"Invalid bleep_type: {str(bleep_type)}") - 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 + 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 + # Current column of text being printed. 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() + # 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 - if self.buffer_mode: - # This is a hack to get words to wrap properly, based on our - # current cursor position. + def split_window(self, height): + log(f"TODO: split window here to height {height}") - # First, add whitespace padding up to the column of text that - # we're at. - string = (" " * self.__curr_column) + string + def select_window(self, window): + log(f"TODO: select window {window} here") - # Next, word wrap our current string. - string = _word_wrap(string, self._columns-1) + def set_cursor_position(self, x, y): + log(f"TODO: set cursor position to ({x},{y}) here") - # Now remove the whitespace padding. - string = string[self.__curr_column:] + 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(f"\r{' ' * len(MORE_STRING)}\r") + 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) - 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 __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] + 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) + # 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) + 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) - 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 __report_io_error(self, exception): + sys.stdout.write(f"FILESYSTEM: An error occurred: {exception}\n") - def save_game(self, data, suggested_filename=None): - success = False + 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) + sys.stdout.write("Enter a name for the saved game (hit enter to cancel): ") + filename = _read_line(suggested_filename) + if filename: + try: + with open(filename, "wb") as file_obj: + file_obj.write(data) + success = True + except OSError as e: + self.__report_io_error(e) - return success + return success - def restore_game(self): - data = None + 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) + sys.stdout.write( + "Enter the name of the saved game to restore (hit enter to cancel): " + ) + filename = _read_line() + if filename: + try: + with open(filename, "rb") as file_obj: + data = file_obj.read() + except OSError as e: + self.__report_io_error(e) - return data + return data - def open_transcript_file_for_writing(self): - file_obj = None + 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) + 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") # noqa: SIM115 + except OSError as e: + self.__report_io_error(e) - return file_obj + return file_obj - def open_transcript_file_for_reading(self): - file_obj = None + 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) + 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) # noqa: SIM115 + except OSError as e: + self.__report_io_error(e) + + return file_obj - return file_obj def create_zui(): - """Creates and returns a ZUI instance representing a trivial user - interface.""" + """Creates and returns a ZUI instance representing a trivial user + interface.""" - audio = TrivialAudio() - screen = TrivialScreen() - keyboard_input = TrivialKeyboardInputStream(screen) - filesystem = TrivialFilesystem() + audio = TrivialAudio() + screen = TrivialScreen() + keyboard_input = TrivialKeyboardInputStream(screen) + filesystem = TrivialFilesystem() + + return zui.ZUI(audio, screen, keyboard_input, filesystem) - return zui.ZUI( - audio, - screen, - keyboard_input, - filesystem - ) # Keyboard input functions @@ -269,110 +271,121 @@ _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.""" + """Win32-specific function that reads a character of input from the + keyboard and returns it without printing it to the screen.""" - import msvcrt + import msvcrt + + return str(msvcrt.getch()) # type: ignore[possibly-missing-attribute] - 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.""" + """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 + # This code was excised from: + # http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892 - import termios - import tty + 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) - 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.""" + """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 + 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 - 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" + """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').""" - assert isinstance(original_text, str) - assert isinstance(terminating_characters, str) + if original_text is None: + original_text = "" + if not terminating_characters: + terminating_characters = "\r" - chars_entered = len(original_text) - sys.stdout.write(original_text) - string = original_text - finished = False - while not finished: - char = _read_char() + assert isinstance(original_text, str) + assert isinstance(terminating_characters, str) - 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 + chars_entered = len(original_text) + sys.stdout.write(original_text) + string = original_text + finished = False + while not finished: + char = _read_char() - 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 + 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 = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}" + else: + char_to_print = char + + sys.stdout.write(char_to_print) + return string - 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). - """ + """ + 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 + # 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(' ') - ) + return reduce( + lambda line, word, width=width: "{}{}{}".format( + 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 index bd93cb7..fb5253f 100644 --- a/src/mudlib/zmachine/zaudio.py +++ b/src/mudlib/zmachine/zaudio.py @@ -22,55 +22,55 @@ EFFECT_START = 2 EFFECT_STOP = 3 EFFECT_FINISH = 4 + class ZAudio: - def __init__(self): - """Constructor of the audio system.""" + def __init__(self): + """Constructor of the audio system.""" - # Subclasses must define real values for all the features they - # support (or don't support). + # Subclasses must define real values for all the features they + # support (or don't support). - self.features = { - "has_more_than_a_bleep": False, - } + self.features = { + "has_more_than_a_bleep": False, + } - def play_bleep(self, bleep_type): - """Plays a bleep sound of the given type: + 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() + 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. + 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: + 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 + 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 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 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: + 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) + def on_sound_finished(id) - where 'id' is the id of the sound that finished playing. + 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.""" + This method should only be implemented if the + has_more_than_a_bleep feature is enabled.""" - raise NotImplementedError() + raise NotImplementedError() diff --git a/src/mudlib/zmachine/zfilesystem.py b/src/mudlib/zmachine/zfilesystem.py index 2746e7c..c3a742d 100644 --- a/src/mudlib/zmachine/zfilesystem.py +++ b/src/mudlib/zmachine/zfilesystem.py @@ -10,56 +10,54 @@ # root directory of this distribution. # + class ZFilesystem: - """Encapsulates the interactions that the end-user has with the - filesystem.""" + """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. + 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.""" + 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() + 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. - 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.""" - 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() - 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.""" - 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. + raise NotImplementedError() - If an error occurs, or if the user clicks 'cancel' or its - equivalent, return None.""" + 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. - raise NotImplementedError() + If an error occurs, or if the user clicks 'cancel' or its + equivalent, return None.""" - - 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() + raise NotImplementedError() diff --git a/src/mudlib/zmachine/zlexer.py b/src/mudlib/zmachine/zlexer.py index 8360dc4..ad31417 100644 --- a/src/mudlib/zmachine/zlexer.py +++ b/src/mudlib/zmachine/zlexer.py @@ -12,7 +12,8 @@ from .zstring import ZsciiTranslator, ZStringFactory class ZLexerError(Exception): - "General exception for ZLexer class" + "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 @@ -26,117 +27,106 @@ class ZLexerError(Exception): # 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) - def __init__(self, mem): + # 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) - self._memory = mem - self._stringfactory = ZStringFactory(self._memory) - self._zsciitranslator = ZsciiTranslator(self._memory) + 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.""" - # 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) + 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 _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.""" + 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.""" - 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 + # re.findall(r'[,.;]|\w+', 'abc, def') + sep_string = "" + for sep in separators: + sep_string += sep + regex = r"\w+" if sep_string == "" else rf"[{sep_string}]|\w+" - return num_entries, entry_length, separators, addr + return re.findall(regex, string) + # --------- Public APIs ----------- - 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.""" + 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.""" - # 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 + dict = {} - return re.findall(regex, string) + 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 - #--------- Public APIs ----------- + return dict + def parse_input(self, string, dict_addr=None): + """Given a unicode string, parse it into words based on a dictionary. - 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.""" + 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. - dict = {} + 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. - num_entries, entry_length, separators, addr = \ - self._parse_dict_header(address) + Return a list of lists, each list being of the form - for i in range(0, num_entries): - text_key = self._stringfactory.get(addr) - dict[text_key] = addr - addr += entry_length + [word, byte_address_of_word_in_dictionary (or 0 if not in dictionary)] + """ - return dict + 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)) - def parse_input(self, string, dict_addr=None): - """Given a unicode string, parse it into words based on a dictionary. + token_list = self._tokenise_string(string, separators) - 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. + final_list = [] + for word in token_list: + byte_addr = dict.get(word, 0) + final_list.append([word, byte_addr]) - 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 + return final_list diff --git a/src/mudlib/zmachine/zlogging.py b/src/mudlib/zmachine/zlogging.py index 1491f93..f20e3d1 100644 --- a/src/mudlib/zmachine/zlogging.py +++ b/src/mudlib/zmachine/zlogging.py @@ -12,33 +12,35 @@ 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) +mainlog_handler = logging.FileHandler("debug.log", "a") +mainlog_handler.setLevel(logging.DEBUG) +mainlog_handler.setFormatter(logging.Formatter("%(asctime)s: %(message)s")) +logging.getLogger("mainlog").addHandler(mainlog_handler) # 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) +disasm_handler = logging.FileHandler("disasm.log", "a") +disasm_handler.setLevel(logging.DEBUG) +disasm_handler.setFormatter(logging.Formatter("%(message)s")) +logging.getLogger("disasm").addHandler(disasm_handler) + +mainlog = logging.getLogger("mainlog") +mainlog.info("*** Log reopened ***") +disasm = logging.getLogger("disasm") +disasm.info("*** Log reopened ***") -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) + if state: + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.CRITICAL) + def log(msg): - mainlog.debug(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)) + disasm.debug(f"{pc:06x} {opcode_type}:{opcode_num:02x} {opcode_name} {args}") diff --git a/src/mudlib/zmachine/zmachine.py b/src/mudlib/zmachine/zmachine.py index 19abb83..c3826c2 100644 --- a/src/mudlib/zmachine/zmachine.py +++ b/src/mudlib/zmachine/zmachine.py @@ -16,27 +16,34 @@ from .zstring import ZStringFactory class ZMachineError(Exception): - """General exception for ZMachine class""" + """General exception for ZMachine class""" + class ZMachine: - """The Z-Machine black box.""" + """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) + 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 ----------- + # --------- Public APIs ----------- - def run(self): - return self._cpu.run() + def run(self): + return self._cpu.run() diff --git a/src/mudlib/zmachine/zmemory.py b/src/mudlib/zmachine/zmemory.py index f556361..4f0f012 100644 --- a/src/mudlib/zmachine/zmemory.py +++ b/src/mudlib/zmachine/zmemory.py @@ -17,262 +17,324 @@ from .zlogging import log class ZMemoryError(Exception): - "General exception for ZMemory class" - pass + "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) + "Tried to write to a read-only part of memory" + + def __init__(self, address): + super().__init__(f"Illegal write to address {address}") + class ZMemoryBadInitialization(ZMemoryError): - "Failure to initialize ZMemory class" - pass + "Failure to initialize ZMemory class" + + pass + class ZMemoryOutOfBounds(ZMemoryError): - "Accessed an address beyond the bounds of memory." - pass + "Accessed an address beyond the bounds of memory." + + pass + class ZMemoryBadMemoryLayout(ZMemoryError): - "Static plus dynamic memory exceeds 64k" - pass + "Static plus dynamic memory exceeds 64k" + + pass + class ZMemoryBadStoryfileSize(ZMemoryError): - "Story is too large for Z-machine version." - pass + "Story is too large for Z-machine version." + + pass + class ZMemoryUnsupportedVersion(ZMemoryError): - "Unsupported version of Z-story file." - pass + "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. - # 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, + ) - 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 - 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) - # 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) - # 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 - # 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] - # 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 - # 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(f" Dynamic memory: {self._dynamic_start:x} - {self._dynamic_end:x}") + log(f" Static memory: {self._static_start:x} - {self._static_end:x}") + log(f" High memory: {self._high_start:x} - {self._high_end:x}") + log(f" Global variable start: {self._global_variable_start:x}") - 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_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 _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 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 __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 __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 __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 __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 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 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 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 - 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. - # 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 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) - 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. - # 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 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(f"Write {value} to global variable {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] - 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. - # 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) + 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 index 007145c..034c505 100644 --- a/src/mudlib/zmachine/zobjectparser.py +++ b/src/mudlib/zmachine/zobjectparser.py @@ -19,414 +19,405 @@ from .zstring import ZStringFactory class ZObjectError(Exception): - "General exception for ZObject class" - pass + "General exception for ZObject class" + + pass + class ZObjectIllegalObjectNumber(ZObjectError): - "Illegal object number given." - pass + "Illegal object number given." + + pass + class ZObjectIllegalAttributeNumber(ZObjectError): - "Illegal attribute number given." - pass + "Illegal attribute number given." + + pass + class ZObjectIllegalPropertyNumber(ZObjectError): - "Illegal property number given." - pass + "Illegal property number given." + + pass + class ZObjectIllegalPropertySet(ZObjectError): - "Illegal set of a property whose size is not 1 or 2." - pass + "Illegal set of a property whose size is not 1 or 2." + + pass + class ZObjectIllegalVersion(ZObjectError): - "Unsupported z-machine version." - pass + "Unsupported z-machine version." + + pass + class ZObjectIllegalPropLength(ZObjectError): - "Illegal property length." - pass + "Illegal property length." + + pass + class ZObjectMalformedTree(ZObjectError): - "Object tree is malformed." - pass + "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) - def __init__(self, zmem): + 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 - self._memory = zmem - self._propdefaults_addr = zmem.read_word(0x0a) - self._stringfactory = ZStringFactory(self._memory) + def _get_object_addr(self, objectnum): + """Return address of object number OBJECTNUM.""" - 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 + 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(f"error: there is no object {objectnum}") + raise ZObjectIllegalObjectNumber + result = self._objecttree_addr + (14 * (objectnum - 1)) + else: + raise ZObjectIllegalVersion + log(f"address of object {objectnum} is {result}") + return result - def _get_object_addr(self, objectnum): - """Return address of object number OBJECTNUM.""" + def _get_parent_sibling_child(self, objectnum): + """Return [parent, sibling, child] object numbers of object 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 + addr = self._get_object_addr(objectnum) - log("address of object %d is %d" % (objectnum, result)) - return result + result = 0 + if 1 <= self._memory.version <= 3: + addr += 4 # skip past attributes + result = self._memory[addr : addr + 3] - - 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), + 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] + self._memory.read_word(addr + 4), + ] else: - if bf[6]: - size = 2 - else: - size = 1 - if pnum == propnum: - return (addr, size) - addr += size + raise ZObjectIllegalVersion - else: - raise ZObjectIllegalVersion + log( + f"parent/sibling/child of object {objectnum} is " + f"{result[0]}, {result[1]}, {result[2]}" + ) + return result - # 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_proptable_addr(self, objectnum): + """Return address of property table of object OBJECTNUM.""" + addr = self._get_object_addr(objectnum) - 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 + # skip past attributes and relatives + if 1 <= self._memory.version <= 3: + addr += 7 + elif 4 <= self._memory.version <= 5: + addr += 12 else: - if bf[6]: - size = 2 - else: - size = 1 - proplist[pnum] = (addr, size) - addr += size + raise ZObjectIllegalVersion - else: - raise ZObjectIllegalVersion + return self._memory.read_word(addr) - return proplist + def _get_default_property_addr(self, propnum): + """Return address of default value for property PROPNUM.""" + addr = self._propdefaults_addr - 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 + 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 - addr, size = proplist[propnum] - if size == 1: - self._memory[addr] = (value & 0xFF) - elif size == 2: - self._memory.write_word(addr, value) - else: - raise ZObjectIllegalPropertySet + return addr + (2 * (propnum - 1)) + # --------- Public APIs ----------- - def describe_object(self, objectnum): - """For debugging purposes, pretty-print everything known about - object OBJECTNUM.""" + def get_attribute(self, objectnum, attrnum): + """Return value (0 or 1) of attribute number ATTRNUM of object + number 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:") + object_addr = self._get_object_addr(objectnum) - 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() + 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: + size = 2 if bf[6] else 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: + size = 2 if bf[6] else 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(f" [{key:2d}] :", end=" ") + for i in range(0, len): + print(f"{self._memory[addr + i]:02X}", end=" ") + print() diff --git a/src/mudlib/zmachine/zopdecoder.py b/src/mudlib/zmachine/zopdecoder.py index 5e08125..b734934 100644 --- a/src/mudlib/zmachine/zopdecoder.py +++ b/src/mudlib/zmachine/zopdecoder.py @@ -11,8 +11,10 @@ from .zlogging import log class ZOperationError(Exception): - "General exception for ZOperation class" - pass + "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 @@ -27,12 +29,12 @@ 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', - } + OPCODE_0OP: "0OP", + OPCODE_1OP: "1OP", + OPCODE_2OP: "2OP", + OPCODE_VAR: "VAR", + OPCODE_EXT: "EXT", +} # Constants defining the possible operand types. LARGE_CONSTANT = 0x0 @@ -40,195 +42,203 @@ 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 __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_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: + 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, ...]] + [opcode-class, opcode-number, [operand, operand, operand, ...]] - If the opcode has no operands, the operand list is present but empty.""" + If the opcode has no operands, the operand list is present but empty.""" - opcode = self._get_pc() + opcode = self._get_pc() - log("Decode opcode %x" % opcode) + log(f"Decode opcode {opcode:x}") - # Determine the opcode type, and hand off further parsing. - if self._memory.version == 5 and opcode == 0xBE: - # Extended opcode - return self._parse_opcode_extended() + # 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) + 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_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_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 + 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] + opcode_num = opcode[0:5] - # Parse the types byte to retrieve the operands. - operands = self._parse_operands_byte() + # Parse the types byte to retrieve the operands. + operands = self._parse_operands_byte() - # Special case: opcodes 12 and 26 have a second operands byte. - if opcode[0:7] == 0xC or opcode[0:7] == 0x1A: - log("Opcode has second operand byte") - operands += self._parse_operands_byte() + # 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) + return (opcode_type, opcode_num, operands) - def _parse_operand(self, operand_type): - """Read and return an operand of the given type. + def _parse_opcode_extended(self): + """Parse an extended opcode (v5+ feature).""" + raise NotImplementedError("Extended opcodes (v5+) not yet implemented") - This assumes that the operand is in memory, at the address pointed - by the Program Counter.""" - assert operand_type <= 0x3 + def _parse_operand(self, operand_type): + """Read and return an operand of the given type. - 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) + This assumes that the operand is in memory, at the address pointed + by the Program Counter.""" + assert operand_type <= 0x3 - return operand + 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(f"Operand is variable {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(f"Operand value: {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 operand - return operands + 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: + # 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.""" + 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) + 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 + while True: + bf.__init__(self._memory[self.program_counter]) + self.program_counter += 2 + if bf[7] == 1: + break - return start_addr + 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_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 - 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 + log(f"Branch if {branch_if_true} to offset {branch_offset:+d}") + return branch_if_true, branch_offset diff --git a/src/mudlib/zmachine/zscreen.py b/src/mudlib/zmachine/zscreen.py index 2762bdd..d7472fa 100644 --- a/src/mudlib/zmachine/zscreen.py +++ b/src/mudlib/zmachine/zscreen.py @@ -60,244 +60,231 @@ INFINITE_ROWS = 255 class ZScreenObserver: - """Observer that is notified of changes in the state of a ZScreen - object. + """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.""" + 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.""" + def on_screen_size_change(self, zscreen): + """Called when the screen size of a ZScreen changes.""" - pass + pass - def on_font_size_change(self, zscreen): - """Called when the font size of a ZScreen changes.""" + def on_font_size_change(self, zscreen): + """Called when the font size of a ZScreen changes.""" - pass + pass class ZScreen(zstream.ZBufferableOutputStream): - """Subclass of zstream.ZBufferableOutputStream that provides an - abstraction of a computer screen.""" + """Subclass of zstream.ZBufferableOutputStream that provides an + abstraction of a computer screen.""" - def __init__(self): - "Constructor for the screen." + def __init__(self): + "Constructor for the screen." - zstream.ZBufferableOutputStream.__init__(self) + zstream.ZBufferableOutputStream.__init__(self) - # The size of the screen. - self._columns = 79 - self._rows = 24 + # 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 + # 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 = [] + # 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). + # 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, - } + 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].) + # 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].""" + def get_screen_size(self): + """Return the current size of the screen as [rows, columns].""" - return [self._rows, self._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. + 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. + WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER. - This method should only be implemented if the - has_upper_window feature is enabled.""" + This method should only be implemented if the + has_upper_window feature is enabled.""" - raise NotImplementedError() + 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. - 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.""" - This method should only be implemented if the has_upper_window - feature is enabled.""" + raise NotImplementedError() - 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. - 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 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.""" - 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() - 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. - def erase_window(self, window=WINDOW_LOWER, - color=COLOR_CURRENT): - """Erase WINDOW to background COLOR. + If the has_upper_window feature is not supported, WINDOW is + ignored (in such a case, this function should clear the entire + screen). - WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER. + COLOR should be one of the COLOR_* constants. - If the has_upper_window feature is not supported, WINDOW is - ignored (in such a case, this function should clear the entire - screen). + If the has_text_colors feature is not supported, COLOR is ignored.""" - COLOR should be one of the COLOR_* constants. + raise NotImplementedError() - If the has_text_colors feature is not supported, COLOR is ignored.""" + def erase_line(self): + """Erase from the current cursor position to the end of its line + in the current window. - raise NotImplementedError() + 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_line(self): - """Erase from the current cursor position to the end of its line - in the current window. + # 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. - 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.""" + def print_status_score_turns(self, text, score, turns): + """Print a status line in the upper window, as follows: - raise NotImplementedError() + 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. + """ - # 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. + raise NotImplementedError() - def print_status_score_turns(self, text, score, turns): - """Print a status line in the upper window, as follows: + 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 SCORE/TURNS. + 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. - """ + This method should only be implemented if the has_status_line + feature is enabled. + """ - raise NotImplementedError() + raise NotImplementedError() + # Text Appearances + # - def print_status_time(self, hours, minutes): - """Print a status line in the upper window, as follows: + def get_font_size(self): + """Return the current font's size as [width, height].""" - On the left side of the status line, print TEXT. - On the right side of the status line, print HOURS:MINUTES. + return [self._fontwidth, self._fontheight] - This method should only be implemented if the has_status_line - feature is enabled. - """ + def set_font(self, font_number): + """Set the current window's font to one of - raise NotImplementedError() + 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. - # Text Appearances - # + 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.""" - def get_font_size(self): - """Return the current font's size as [width, height].""" + raise NotImplementedError() - return [self._fontwidth, self._fontheight] + 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: - def set_font(self, font_number): - """Set the current window's font to one of + STYLE_ROMAN - Roman + STYLE_REVERSE_VIDEO - Reverse video + STYLE_BOLD - Bold + STYLE_ITALIC - Italic + STYLE_FIXED_PITCH - Fixed-width - FONT_NORMAL - normal font - FONT_PICTURE - picture font (IGNORE, this means nothing) - FONT_CHARACTER_GRAPHICS - character graphics font - FONT_FIXED_WIDTH - fixed-width font + 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. - If a font is not available, return None. Otherwise, set the - new font, and return the number of the *previous* font. + 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. + """ - 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() - 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. + """ - def set_text_style(self, style): - """Set the current text style to the given text style. + raise NotImplementedError() - STYLE is a sequence, each element of which should be one of the - following values: + # Standard output - STYLE_ROMAN - Roman - STYLE_REVERSE_VIDEO - Reverse video - STYLE_BOLD - Bold - STYLE_ITALIC - Italic - STYLE_FIXED_PITCH - Fixed-width + 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.""" - 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() + raise NotImplementedError() diff --git a/src/mudlib/zmachine/zstackmanager.py b/src/mudlib/zmachine/zstackmanager.py index 6993dfb..a986733 100644 --- a/src/mudlib/zmachine/zstackmanager.py +++ b/src/mudlib/zmachine/zstackmanager.py @@ -12,193 +12,189 @@ from .zlogging import log class ZStackError(Exception): - "General exception for stack or routine-related errors" - pass + "General exception for stack or routine-related errors" + + pass + class ZStackUnsupportedVersion(ZStackError): - "Unsupported version of Z-story file." - pass + "Unsupported version of Z-story file." + + pass + class ZStackNoRoutine(ZStackError): - "No routine is being executed." - pass + "No routine is being executed." + + pass + class ZStackNoSuchVariable(ZStackError): - "Trying to access non-existent local variable." - pass + "Trying to access non-existent local variable." + + pass + class ZStackPopError(ZStackError): - "Nothing to pop from stack!" - pass + "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.""" - 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 - 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 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(f"num local vars is {num_local_vars}") + raise ZStackError + self.start_addr += 1 - 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 - # 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] - # 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." - - 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) + log(f"ZRoutine: start address: {self.start_addr}") + log(f"ZRoutine: return value address: {self.return_addr}") + log(f"ZRoutine: program counter: {self.program_counter}") + log(f"ZRoutine: local variables: {self.local_vars}") class ZStackBottom: - - def __init__(self): - self.program_counter = 0 # used as a cache only + 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 __init__(self, zmem): + 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.""" - self._memory = zmem - self._stackbottom = ZStackBottom() - self._call_stack = [self._stackbottom] + if self._call_stack[-1] == self._stackbottom: + raise ZStackNoRoutine + if not 0 <= varnum <= 15: + raise ZStackNoSuchVariable - 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.""" + current_routine = self._call_stack[-1] - if self._call_stack[-1] == self._stackbottom: - raise ZStackNoRoutine + return current_routine.local_vars[varnum] # type: ignore[possibly-missing-attribute] - if not 0 <= varnum <= 15: - raise ZStackNoSuchVariable + 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.""" - current_routine = self._call_stack[-1] + if self._call_stack[1] == self._stackbottom: + raise ZStackNoRoutine - return current_routine.local_vars[varnum] + if not 0 <= varnum <= 15: + raise ZStackNoSuchVariable + current_routine = self._call_stack[-1] - 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.""" + current_routine.local_vars[varnum] = value # type: ignore[possibly-missing-attribute] - if self._call_stack[1] == self._stackbottom: - raise ZStackNoRoutine + def push_stack(self, value): + "Push VALUE onto the top of the current routine's data stack." - if not 0 <= varnum <= 15: - raise ZStackNoSuchVariable + current_routine = self._call_stack[-1] + current_routine.stack.append(value) # type: ignore[possibly-missing-attribute] - current_routine = self._call_stack[-1] + def pop_stack(self): + "Remove and return value from the top of the data stack." - current_routine.local_vars[varnum] = value + current_routine = self._call_stack[-1] + return current_routine.stack.pop() # type: ignore[possibly-missing-attribute] + def get_stack_frame_index(self): + "Return current stack frame number. For use by 'catch' opcode." - def push_stack(self, value): - "Push VALUE onto the top of the current routine's data stack." + return len(self._call_stack) - 1 - current_routine = self._call_stack[-1] - current_routine.stack.append(value) + # 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) - def pop_stack(self): - "Remove and return value from the top of the data stack." + # 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.""" - current_routine = self._call_stack[-1] - return current_routine.stack.pop() + 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 - def get_stack_frame_index(self): - "Return current stack frame number. For use by 'catch' opcode." + # 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.""" - return len(self._call_stack) - 1 + exiting_routine = self._call_stack.pop() + current_routine = self._call_stack[-1] + # Depending on many things, return stuff. + if exiting_routine.return_addr is not None: # type: ignore[possibly-missing-attribute] + if exiting_routine.return_addr == 0: # type: ignore[possibly-missing-attribute] + # Push to stack + self.push_stack(return_value) + elif 0 < exiting_routine.return_addr < 10: # type: ignore[possibly-missing-attribute] + # Store in local var + self.set_local_variable(exiting_routine.return_addr, return_value) # type: ignore[possibly-missing-attribute] + else: + # Store in global var + self._memory.write_global(exiting_routine.return_addr, return_value) # type: ignore[possibly-missing-attribute] - # 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 - + return current_routine.program_counter diff --git a/src/mudlib/zmachine/zstream.py b/src/mudlib/zmachine/zstream.py index 6a34e9a..a41aca1 100644 --- a/src/mudlib/zmachine/zstream.py +++ b/src/mudlib/zmachine/zstream.py @@ -5,94 +5,99 @@ # root directory of this distribution. # + class ZOutputStream: - """Abstract class representing an output stream for a z-machine.""" + """Abstract class representing an output stream for a z-machine.""" - def write(self, string): - """Prints the given unicode string to the output stream.""" + def write(self, string): + """Prints the given unicode string to the output stream.""" - raise NotImplementedError() + 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.""" + """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 + 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.""" + """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). + 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, - } + 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. + 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. + 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. + 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. + 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: + 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) + 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. + 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(). + 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.""" + 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() + 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. + 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(). + 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.""" + 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() + raise NotImplementedError() diff --git a/src/mudlib/zmachine/zstreammanager.py b/src/mudlib/zmachine/zstreammanager.py index 5665961..196d0ec 100644 --- a/src/mudlib/zmachine/zstreammanager.py +++ b/src/mudlib/zmachine/zstreammanager.py @@ -9,9 +9,9 @@ # 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_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 @@ -20,78 +20,81 @@ OUTPUT_PLAYER_INPUT = 4 # contains *only* the player's typed commands INPUT_KEYBOARD = 0 INPUT_FILE = 1 + class ZOutputStreamManager: - """Manages output streams for a Z-Machine.""" + """Manages output streams for a Z-Machine.""" - def __init__(self, zmem, zui): - # TODO: Actually set/create the streams as necessary. + def __init__(self, zmem, zui): + # TODO: Actually set/create the streams as necessary. - self._selectedStreams = [] - self._streams = {} + self._selectedStreams = [] + self._streams = {} - def select(self, stream): - """Selects the given stream ID for output.""" + def select(self, stream): + """Selects the given stream ID for output.""" - if stream not in self._selectedStreams: - self._selectedStreams.append(stream) + if stream not in self._selectedStreams: + self._selectedStreams.append(stream) - def unselect(self, stream): - """Unselects the given stream ID for output.""" + def unselect(self, stream): + """Unselects the given stream ID for output.""" - if stream in self._selectedStreams: - self._selectedStreams.remove(stream) + if stream in self._selectedStreams: + self._selectedStreams.remove(stream) - def get(self, stream): - """Retrieves the given stream ID.""" + def get(self, stream): + """Retrieves the given stream ID.""" - return self._streams[stream] + return self._streams[stream] - def write(self, string): - """Writes the given unicode string to all currently selected output - streams.""" + 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 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.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. + # 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) - for stream in self._selectedStreams: - self._streams[stream].write(string) class ZInputStreamManager: - """Manages input streams for a Z-Machine.""" + """Manages input streams for a Z-Machine.""" - def __init__(self, zui): - # TODO: Actually set/create the streams as necessary. + def __init__(self, zui): + # TODO: Actually set/create the streams as necessary. - self._selectedStream = None - self._streams = {} + self._selectedStream = None + self._streams = {} - def select(self, stream): - """Selects the given stream ID as the currently active input stream.""" + 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. + # 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 + self._selectedStream = stream - def getSelected(self): - """Returns the input stream object for the currently active input - stream.""" + def getSelected(self): + """Returns the input stream object for the currently active input + stream.""" + + return self._streams[self._selectedStream] - return self._streams[self._selectedStream] class ZStreamManager: - def __init__(self, zmem, zui): - self.input = ZInputStreamManager(zui) - self.output = ZOutputStreamManager(zmem, zui) + 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 index 46d6fbe..e04bdd9 100644 --- a/src/mudlib/zmachine/zstring.py +++ b/src/mudlib/zmachine/zstring.py @@ -11,6 +11,7 @@ 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.""" @@ -22,6 +23,7 @@ class ZStringTranslator: def get(self, addr): from .bitfield import BitField + pos = (addr, BitField(self._mem.read_word(addr)), 0) s = [] @@ -34,7 +36,7 @@ class ZStringTranslator: def _read_char(self, pos): offset = (2 - pos[2]) * 5 - return pos[1][offset:offset+5] + return pos[1][offset : offset + 5] def _is_final(self, pos): return pos[1][15] == 1 @@ -50,16 +52,13 @@ class ZStringTranslator: # Kill processing. raise ZStringEndOfString # Get and return the next block. - return (pos[0] + 2, - BitField(self._mem.read_word(pos[0] + 2)), - 0) + 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"] @@ -95,7 +94,7 @@ class ZCharTranslator: return None alph_addr = self._mem.read_word(0x34) - alphabet = self._mem[alph_addr:alph_addr+78] + alphabet = self._mem[alph_addr : alph_addr + 78] return [alphabet[0:26], alphabet[26:52], alphabet[52:78]] def _load_abbrev_tables(self): @@ -109,8 +108,7 @@ class ZCharTranslator: 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)]: + 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) @@ -128,11 +126,12 @@ class ZCharTranslator: """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) + state["zscii"].append(13) def shift_alphabet(state, direction, lock): """Shift the current alphaber up or down. If lock is @@ -140,9 +139,9 @@ class ZCharTranslator: after outputting 1 character. Else, the alphabet will remain unchanged until the next shift. """ - state['curr_alpha'] = (state['curr_alpha'] + direction) % 3 + state["curr_alpha"] = (state["curr_alpha"] + direction) % 3 if lock: - state['prev_alpha'] = state['curr_alpha'] + state["prev_alpha"] = state["curr_alpha"] def abbreviation(state, abbrev): """Insert the given abbreviation from the given table into @@ -152,18 +151,18 @@ class ZCharTranslator: 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'] + 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']: + if not state["allow_abbreviations"]: raise ZStringIllegalAbbrevInString - state['state_handler'] = lambda s,c: write_abbreviation(s, c, - abbrev) + state["state_handler"] = lambda s, c: write_abbreviation(s, c, abbrev) # Register the specials handlers depending on machine version. if self._mem.version == 1: @@ -173,7 +172,7 @@ class ZCharTranslator: 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), @@ -181,44 +180,44 @@ class ZCharTranslator: 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 + } + 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 + 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'] + 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, - } + "curr_alpha": 0, + "prev_alpha": 0, + "zscii": [], + "allow_abbreviations": allow_abbreviations, + } for c in zstr: - if 'state_handler' in list(state.keys()): + 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) + state["state_handler"](state, c) # type: ignore[call-non-callable] 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: + elif state["curr_alpha"] == 2 and c == 6: # Handle the strange A2/6 character - state['state_handler'] = self._special_zscii + state["state_handler"] = self._special_zscii else: # Do the usual Thing: append a zscii code to the # decoded sequence and revert to the "previous" @@ -227,36 +226,97 @@ class ZCharTranslator: if c == 0: # Append a space. z = 32 - elif state['curr_alpha'] == 2: + 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] + 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'] + z = self._alphabet[state["curr_alpha"]][c - 6] + state["zscii"].append(z) + state["curr_alpha"] = state["prev_alpha"] - return state['zscii'] + 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)] + 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 @@ -299,19 +359,14 @@ class ZsciiTranslator: def __init__(self, zmem): self._mem = zmem - self._output_table = { - 0 : "", - 10: "\n" - } - self._input_table = { - "\n": 10 - } + 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)]: + for code, char in [(x, chr(x)) for x in range(32, 127)]: self._output_table[code] = char self._input_table[char] = code @@ -324,8 +379,11 @@ class ZsciiTranslator: # 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('__')]: + 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) @@ -350,18 +408,22 @@ class ZsciiTranslator: # # 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): + if ( + ext_table_addr != 0 + and self._mem.read_word(ext_table_addr) >= 3 + and self._mem.read_word(ext_table_addr + 6) != 0 + ): + # Get the unicode translation table address + utt_addr = self._mem.read_word(ext_table_addr + 6) # The first byte is the number of unicode characters # in the table. - utt_len = self._mem[ext_table_addr] + utt_len = self._mem[utt_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_range = range(utt_addr + 1, utt_addr + 1 + (utt_len * 2), 2) utt = [chr(self._mem.read_word(i)) for i in utt_range] else: utt = self.DEFAULT_UTT @@ -380,7 +442,7 @@ class ZsciiTranslator: try: return self._output_table[index] except KeyError: - raise IndexError("No such ZSCII character") + raise IndexError("No such ZSCII character") from None def utoz(self, char): """Translate the given Unicode code into the corresponding @@ -389,10 +451,10 @@ class ZsciiTranslator: try: return self._input_table[char] except KeyError: - raise IndexError("No such input character") + raise IndexError("No such input character") from None def get(self, zscii): - return ''.join([self.ztou(c) for c in zscii]) + return "".join([self.ztou(c) for c in zscii]) class ZStringFactory: