Re-copy fixed repos/zvm source into src/mudlib/zmachine

Copies the cleaned-up zvm source (ruff-compliant, ty-clean) back into
the zmachine module. Adds __init__.py with proper exports and updates
.gitignore for debug.log/disasm.log.
This commit is contained in:
Jared Miller 2026-02-09 20:00:42 -05:00
parent dcc952d4c5
commit 5ea030a0ac
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
20 changed files with 2345 additions and 2209 deletions

View file

@ -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

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ data
.worktrees
.testmondata
*.z*
debug.log
disasm.log

View file

@ -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.
"""

View file

@ -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)])

View file

@ -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

View file

@ -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.")

View file

@ -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(" "),
)

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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}")

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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: