mud/src/mudlib/zmachine/zmemory.py
Jared Miller 5ea030a0ac
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.
2026-02-09 20:44:21 -05:00

340 lines
11 KiB
Python

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