Port 10 object tree opcodes to hybrid z-machine interpreter
Implemented 10 opcodes for object tree manipulation: get_sibling, test_attr, set_attr, clear_attr, jin, remove_obj, print_obj, get_prop_addr, get_next_prop, and get_prop_len. Added 6 supporting methods to ZObjectParser: set_attribute, clear_attribute, remove_object, get_property_data_address, get_next_property, and get_property_length. Fixed bug in insert_object where sibling chain walk never advanced the prev pointer, potentially causing infinite loops. Added 16 unit tests with MockObjectParser to verify CPU opcode behavior. All tests passing.
This commit is contained in:
parent
5ea030a0ac
commit
1b9d84f41a
3 changed files with 440 additions and 31 deletions
|
|
@ -243,9 +243,10 @@ class ZCpu:
|
|||
self._write_result(val, store_addr=variable)
|
||||
self._branch(val > test_value)
|
||||
|
||||
def op_jin(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_jin(self, obj1, obj2):
|
||||
"""Branch if obj1's parent equals obj2."""
|
||||
parent = self._objects.get_parent(obj1)
|
||||
self._branch(parent == obj2)
|
||||
|
||||
def op_test(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
|
|
@ -259,17 +260,18 @@ class ZCpu:
|
|||
"""Bitwise AND between the two arguments."""
|
||||
self._write_result(a & b)
|
||||
|
||||
def op_test_attr(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_test_attr(self, obj, attr):
|
||||
"""Test if object has attribute, branch if true."""
|
||||
has_attr = self._objects.get_attribute(obj, attr)
|
||||
self._branch(has_attr != 0)
|
||||
|
||||
def op_set_attr(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_set_attr(self, obj, attr):
|
||||
"""Set attribute on object."""
|
||||
self._objects.set_attribute(obj, attr)
|
||||
|
||||
def op_clear_attr(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_clear_attr(self, obj, attr):
|
||||
"""Clear attribute on object."""
|
||||
self._objects.clear_attribute(obj, attr)
|
||||
|
||||
def op_store(self, variable, value):
|
||||
"""Store the given value to the given variable."""
|
||||
|
|
@ -299,13 +301,15 @@ class ZCpu:
|
|||
val = self._objects.get_prop(objectnum, propnum)
|
||||
self._write_result(val)
|
||||
|
||||
def op_get_prop_addr(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_get_prop_addr(self, obj, prop):
|
||||
"""Get property data address, store 0 if not found."""
|
||||
addr = self._objects.get_property_data_address(obj, prop)
|
||||
self._write_result(addr)
|
||||
|
||||
def op_get_next_prop(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_get_next_prop(self, obj, prop):
|
||||
"""Get next property number, store result."""
|
||||
next_prop = self._objects.get_next_property(obj, prop)
|
||||
self._write_result(next_prop)
|
||||
|
||||
def op_add(self, a, b):
|
||||
"""Signed 16-bit addition."""
|
||||
|
|
@ -367,9 +371,11 @@ class ZCpu:
|
|||
"""Branch if the val is zero."""
|
||||
self._branch(val == 0)
|
||||
|
||||
def op_get_sibling(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_get_sibling(self, obj):
|
||||
"""Get sibling of object, store it, branch if nonzero."""
|
||||
sibling = self._objects.get_sibling(obj)
|
||||
self._write_result(sibling)
|
||||
self._branch(sibling != 0)
|
||||
|
||||
def op_get_child(self, object_num):
|
||||
"""Get and store the first child of the given object."""
|
||||
|
|
@ -379,9 +385,10 @@ class ZCpu:
|
|||
"""Get and store the parent of the given object."""
|
||||
self._write_result(self._objects.get_parent(object_num))
|
||||
|
||||
def op_get_prop_len(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_get_prop_len(self, data_addr):
|
||||
"""Get property data length from data address, store result."""
|
||||
length = self._objects.get_property_length(data_addr)
|
||||
self._write_result(length)
|
||||
|
||||
def op_inc(self, variable):
|
||||
"""Increment the given value."""
|
||||
|
|
@ -405,13 +412,14 @@ class ZCpu:
|
|||
"""Call the given routine and store the return value."""
|
||||
self._call(routine_address, [], True)
|
||||
|
||||
def op_remove_obj(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_remove_obj(self, obj):
|
||||
"""Remove object from its parent."""
|
||||
self._objects.remove_object(obj)
|
||||
|
||||
def op_print_obj(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
raise ZCpuNotImplemented
|
||||
def op_print_obj(self, obj):
|
||||
"""Print object's short name."""
|
||||
shortname = self._objects.get_shortname(obj)
|
||||
self._ui.screen.write(shortname)
|
||||
|
||||
def op_ret(self, *args):
|
||||
"""TODO: Write docstring here."""
|
||||
|
|
|
|||
|
|
@ -181,6 +181,52 @@ class ZObjectParser:
|
|||
|
||||
return bf[7 - (attrnum % 8)]
|
||||
|
||||
def set_attribute(self, objectnum, attrnum):
|
||||
"""Set attribute number ATTRNUM of object number OBJECTNUM to 1."""
|
||||
|
||||
object_addr = self._get_object_addr(objectnum)
|
||||
|
||||
if 1 <= self._memory.version <= 3:
|
||||
if not (0 <= attrnum <= 31):
|
||||
raise ZObjectIllegalAttributeNumber
|
||||
byte_offset = attrnum // 8
|
||||
bf = BitField(self._memory[object_addr + byte_offset])
|
||||
|
||||
elif 4 <= self._memory.version <= 5:
|
||||
if not (0 <= attrnum <= 47):
|
||||
raise ZObjectIllegalAttributeNumber
|
||||
byte_offset = attrnum // 8
|
||||
bf = BitField(self._memory[object_addr + byte_offset])
|
||||
|
||||
else:
|
||||
raise ZObjectIllegalVersion
|
||||
|
||||
bf[7 - (attrnum % 8)] = 1
|
||||
self._memory[object_addr + byte_offset] = int(bf)
|
||||
|
||||
def clear_attribute(self, objectnum, attrnum):
|
||||
"""Clear attribute number ATTRNUM of object number OBJECTNUM to 0."""
|
||||
|
||||
object_addr = self._get_object_addr(objectnum)
|
||||
|
||||
if 1 <= self._memory.version <= 3:
|
||||
if not (0 <= attrnum <= 31):
|
||||
raise ZObjectIllegalAttributeNumber
|
||||
byte_offset = attrnum // 8
|
||||
bf = BitField(self._memory[object_addr + byte_offset])
|
||||
|
||||
elif 4 <= self._memory.version <= 5:
|
||||
if not (0 <= attrnum <= 47):
|
||||
raise ZObjectIllegalAttributeNumber
|
||||
byte_offset = attrnum // 8
|
||||
bf = BitField(self._memory[object_addr + byte_offset])
|
||||
|
||||
else:
|
||||
raise ZObjectIllegalVersion
|
||||
|
||||
bf[7 - (attrnum % 8)] = 0
|
||||
self._memory[object_addr + byte_offset] = int(bf)
|
||||
|
||||
def get_all_attributes(self, objectnum):
|
||||
"""Return a list of all attribute numbers that are set on object
|
||||
OBJECTNUM"""
|
||||
|
|
@ -250,6 +296,39 @@ class ZObjectParser:
|
|||
else:
|
||||
raise ZObjectIllegalVersion
|
||||
|
||||
def remove_object(self, objectnum):
|
||||
"""Detach object OBJECTNUM from its parent (unlink from sibling chain)."""
|
||||
|
||||
parent = self.get_parent(objectnum)
|
||||
if parent == 0:
|
||||
# Object has no parent, nothing to remove
|
||||
return
|
||||
|
||||
sibling = self.get_sibling(objectnum)
|
||||
|
||||
# Check if this object is the first child
|
||||
if self.get_child(parent) == objectnum:
|
||||
# Make sibling the new first child
|
||||
self.set_child(parent, sibling)
|
||||
else:
|
||||
# Walk the sibling chain to find the object before this one
|
||||
prev = self.get_child(parent)
|
||||
current = self.get_sibling(prev)
|
||||
while current != 0:
|
||||
if current == objectnum:
|
||||
# Link prev to our sibling, removing us from chain
|
||||
self.set_sibling(prev, sibling)
|
||||
break
|
||||
prev = current
|
||||
current = self.get_sibling(current)
|
||||
else:
|
||||
# Shouldn't happen - object claimed parent but not in chain
|
||||
raise ZObjectMalformedTree
|
||||
|
||||
# Clear this object's parent
|
||||
self.set_parent(objectnum, 0)
|
||||
self.set_sibling(objectnum, 0)
|
||||
|
||||
def insert_object(self, parent_object, new_child):
|
||||
"""Prepend object NEW_CHILD to the list of PARENT_OBJECT's children."""
|
||||
|
||||
|
|
@ -279,6 +358,8 @@ class ZObjectParser:
|
|||
if current == new_child:
|
||||
self.set_sibling(prev, s) # s might be 0, that's fine.
|
||||
break
|
||||
prev = current
|
||||
current = self.get_sibling(current)
|
||||
else:
|
||||
# we reached the end of the list, never got a match
|
||||
raise ZObjectMalformedTree
|
||||
|
|
@ -402,6 +483,94 @@ class ZObjectParser:
|
|||
else:
|
||||
raise ZObjectIllegalPropertySet
|
||||
|
||||
def get_property_data_address(self, objectnum, propnum):
|
||||
"""Return the address of property PROPNUM's data bytes for object
|
||||
OBJECTNUM. Return 0 if the object doesn't have that property."""
|
||||
|
||||
try:
|
||||
addr, size = self.get_prop_addr_len(objectnum, propnum)
|
||||
# get_prop_addr_len returns default property addr if not found
|
||||
# We need to check if this is the actual property or default
|
||||
proplist = self.get_all_properties(objectnum)
|
||||
if propnum in proplist:
|
||||
return addr
|
||||
else:
|
||||
return 0
|
||||
except ZObjectIllegalPropLength:
|
||||
return 0
|
||||
|
||||
def get_next_property(self, objectnum, propnum):
|
||||
"""If PROPNUM is 0, return the first property number of object OBJECTNUM.
|
||||
Otherwise, return the property number after PROPNUM in the property list.
|
||||
Return 0 if there are no more properties."""
|
||||
|
||||
if propnum == 0:
|
||||
# Return first property number
|
||||
addr = self._get_proptable_addr(objectnum)
|
||||
# Skip past the shortname
|
||||
addr += 2 * self._memory[addr]
|
||||
# Read first property number
|
||||
if self._memory[addr] == 0:
|
||||
return 0
|
||||
|
||||
if 1 <= self._memory.version <= 3:
|
||||
bf = BitField(self._memory[addr])
|
||||
return bf[4:0]
|
||||
elif 4 <= self._memory.version <= 5:
|
||||
bf = BitField(self._memory[addr])
|
||||
return bf[5:0]
|
||||
else:
|
||||
raise ZObjectIllegalVersion
|
||||
|
||||
else:
|
||||
# Find the property after propnum
|
||||
proplist = self.get_all_properties(objectnum)
|
||||
if propnum not in proplist:
|
||||
raise ZObjectIllegalPropertyNumber
|
||||
|
||||
# Properties are stored in descending order
|
||||
# Find the next lower property number
|
||||
sorted_props = sorted(proplist.keys(), reverse=True)
|
||||
try:
|
||||
idx = sorted_props.index(propnum)
|
||||
if idx + 1 < len(sorted_props):
|
||||
return sorted_props[idx + 1]
|
||||
else:
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def get_property_length(self, data_address):
|
||||
"""Given a property DATA address, return the length of that property's data.
|
||||
Return 0 if data_address is 0."""
|
||||
|
||||
if data_address == 0:
|
||||
return 0
|
||||
|
||||
# The size byte is just before the data address
|
||||
size_addr = data_address - 1
|
||||
|
||||
if 1 <= self._memory.version <= 3:
|
||||
bf = BitField(self._memory[size_addr])
|
||||
size = bf[7:5] + 1
|
||||
return size
|
||||
|
||||
elif 4 <= self._memory.version <= 5:
|
||||
bf = BitField(self._memory[size_addr])
|
||||
if bf[7]:
|
||||
# Two size bytes, look at second byte
|
||||
bf2 = BitField(self._memory[data_address - 2])
|
||||
size = bf2[5:0]
|
||||
if size == 0:
|
||||
size = 64
|
||||
return size
|
||||
else:
|
||||
# One size byte
|
||||
return 2 if bf[6] else 1
|
||||
|
||||
else:
|
||||
raise ZObjectIllegalVersion
|
||||
|
||||
def describe_object(self, objectnum):
|
||||
"""For debugging purposes, pretty-print everything known about
|
||||
object OBJECTNUM."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Unit tests for the 12 newly implemented Z-machine opcodes.
|
||||
Unit tests for the Z-machine opcodes and object parser.
|
||||
|
||||
These tests verify the basic behavior of each opcode by mocking the
|
||||
required dependencies (memory, stack, decoder, etc).
|
||||
|
|
@ -249,3 +249,235 @@ class ZMachineOpcodeTests(TestCase):
|
|||
self.assertEqual(self.stack.get_local_variable(0), 123)
|
||||
# Stack should be empty
|
||||
self.assertEqual(len(self.stack.stack), 0)
|
||||
|
||||
|
||||
class MockObjectParser:
|
||||
"""Mock object parser for testing CPU opcodes."""
|
||||
|
||||
def __init__(self):
|
||||
self.attributes = {}
|
||||
self.parents = {}
|
||||
self.siblings = {}
|
||||
self.children = {}
|
||||
self.shortnames = {}
|
||||
self.property_data_addresses = {}
|
||||
self.next_properties = {}
|
||||
|
||||
def get_attribute(self, objnum, attrnum):
|
||||
return self.attributes.get((objnum, attrnum), 0)
|
||||
|
||||
def set_attribute(self, objnum, attrnum):
|
||||
self.attributes[(objnum, attrnum)] = 1
|
||||
|
||||
def clear_attribute(self, objnum, attrnum):
|
||||
self.attributes[(objnum, attrnum)] = 0
|
||||
|
||||
def get_parent(self, objnum):
|
||||
return self.parents.get(objnum, 0)
|
||||
|
||||
def get_sibling(self, objnum):
|
||||
return self.siblings.get(objnum, 0)
|
||||
|
||||
def remove_object(self, objnum):
|
||||
# Simple implementation - just clear parent
|
||||
self.parents[objnum] = 0
|
||||
|
||||
def get_shortname(self, objnum):
|
||||
return self.shortnames.get(objnum, "object")
|
||||
|
||||
def get_property_data_address(self, objnum, propnum):
|
||||
return self.property_data_addresses.get((objnum, propnum), 0)
|
||||
|
||||
def get_next_property(self, objnum, propnum):
|
||||
return self.next_properties.get((objnum, propnum), 0)
|
||||
|
||||
def get_property_length(self, data_address):
|
||||
# Simple mock - return 2 for non-zero addresses
|
||||
return 2 if data_address != 0 else 0
|
||||
|
||||
|
||||
class ZMachineObjectOpcodeTests(TestCase):
|
||||
"""Test suite for Z-machine object tree opcodes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Create a CPU with mocked object parser."""
|
||||
self.memory = MockMemory()
|
||||
self.stack = MockStackManager()
|
||||
self.decoder = MockOpDecoder()
|
||||
self.ui = MockUI()
|
||||
self.objects = MockObjectParser()
|
||||
self.string = Mock()
|
||||
self.string.get = Mock(return_value="test object")
|
||||
|
||||
self.cpu = ZCpu(
|
||||
self.memory,
|
||||
self.decoder,
|
||||
self.stack,
|
||||
self.objects,
|
||||
self.string,
|
||||
Mock(), # stream manager
|
||||
self.ui,
|
||||
)
|
||||
|
||||
def test_op_get_sibling_with_sibling(self):
|
||||
"""Test get_sibling stores sibling and branches if nonzero."""
|
||||
self.objects.siblings[5] = 7
|
||||
self.decoder.store_address = 0
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 10
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_get_sibling(5)
|
||||
|
||||
# Should store 7
|
||||
self.assertEqual(self.stack.pop_stack(), 7)
|
||||
# Should branch (offset - 2)
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
|
||||
|
||||
def test_op_get_sibling_no_sibling(self):
|
||||
"""Test get_sibling with no sibling doesn't branch."""
|
||||
self.objects.siblings[5] = 0
|
||||
self.decoder.store_address = 0
|
||||
self.decoder.branch_condition = True
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_get_sibling(5)
|
||||
|
||||
# Should store 0
|
||||
self.assertEqual(self.stack.pop_stack(), 0)
|
||||
# Should not branch
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
||||
|
||||
def test_op_test_attr_true(self):
|
||||
"""Test test_attr branches when attribute is set."""
|
||||
self.objects.attributes[(10, 5)] = 1
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 20
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_test_attr(10, 5)
|
||||
|
||||
# Should branch
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)
|
||||
|
||||
def test_op_test_attr_false(self):
|
||||
"""Test test_attr doesn't branch when attribute is clear."""
|
||||
self.objects.attributes[(10, 5)] = 0
|
||||
self.decoder.branch_condition = True
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_test_attr(10, 5)
|
||||
|
||||
# Should not branch
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
||||
|
||||
def test_op_set_attr(self):
|
||||
"""Test set_attr sets attribute on object."""
|
||||
self.cpu.op_set_attr(15, 3)
|
||||
# Should have called set_attribute
|
||||
self.assertEqual(self.objects.attributes.get((15, 3)), 1)
|
||||
|
||||
def test_op_clear_attr(self):
|
||||
"""Test clear_attr clears attribute on object."""
|
||||
self.objects.attributes[(15, 3)] = 1
|
||||
self.cpu.op_clear_attr(15, 3)
|
||||
# Should have called clear_attribute
|
||||
self.assertEqual(self.objects.attributes.get((15, 3)), 0)
|
||||
|
||||
def test_op_jin_true(self):
|
||||
"""Test jin branches when obj1 parent equals obj2."""
|
||||
self.objects.parents[5] = 10
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 15
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_jin(5, 10)
|
||||
|
||||
# Should branch
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)
|
||||
|
||||
def test_op_jin_false(self):
|
||||
"""Test jin doesn't branch when obj1 parent not equal to obj2."""
|
||||
self.objects.parents[5] = 8
|
||||
self.decoder.branch_condition = True
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_jin(5, 10)
|
||||
|
||||
# Should not branch
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
||||
|
||||
def test_op_remove_obj(self):
|
||||
"""Test remove_obj removes object from parent."""
|
||||
self.objects.parents[7] = 3
|
||||
self.cpu.op_remove_obj(7)
|
||||
# Parent should be cleared
|
||||
self.assertEqual(self.objects.parents.get(7), 0)
|
||||
|
||||
def test_op_print_obj(self):
|
||||
"""Test print_obj prints object's short name."""
|
||||
self.objects.shortnames[12] = "brass lantern"
|
||||
self.cpu.op_print_obj(12)
|
||||
self.ui.screen.write.assert_called_once_with("brass lantern")
|
||||
|
||||
def test_op_get_prop_addr_found(self):
|
||||
"""Test get_prop_addr stores data address when property exists."""
|
||||
self.objects.property_data_addresses[(20, 5)] = 0x5000
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_prop_addr(20, 5)
|
||||
|
||||
# Should store the data address
|
||||
self.assertEqual(self.stack.pop_stack(), 0x5000)
|
||||
|
||||
def test_op_get_prop_addr_not_found(self):
|
||||
"""Test get_prop_addr stores 0 when property doesn't exist."""
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_prop_addr(20, 99)
|
||||
|
||||
# Should store 0
|
||||
self.assertEqual(self.stack.pop_stack(), 0)
|
||||
|
||||
def test_op_get_next_prop_first(self):
|
||||
"""Test get_next_prop with propnum=0 returns first property."""
|
||||
self.objects.next_properties[(25, 0)] = 15
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_next_prop(25, 0)
|
||||
|
||||
# Should store first property number
|
||||
self.assertEqual(self.stack.pop_stack(), 15)
|
||||
|
||||
def test_op_get_next_prop_next(self):
|
||||
"""Test get_next_prop with propnum>0 returns next property."""
|
||||
self.objects.next_properties[(25, 10)] = 8
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_next_prop(25, 10)
|
||||
|
||||
# Should store next property number
|
||||
self.assertEqual(self.stack.pop_stack(), 8)
|
||||
|
||||
def test_op_get_prop_len(self):
|
||||
"""Test get_prop_len returns property data length."""
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_prop_len(0x6000)
|
||||
|
||||
# Should store 2 (from mock)
|
||||
self.assertEqual(self.stack.pop_stack(), 2)
|
||||
|
||||
def test_op_get_prop_len_zero_addr(self):
|
||||
"""Test get_prop_len with address 0 returns 0."""
|
||||
self.decoder.store_address = 0
|
||||
|
||||
self.cpu.op_get_prop_len(0)
|
||||
|
||||
# Should store 0
|
||||
self.assertEqual(self.stack.pop_stack(), 0)
|
||||
|
||||
|
||||
# Note: ZObjectParser methods are tested through integration tests
|
||||
# with real story files, not unit tests with mock memory, as the
|
||||
# interaction with ZStringFactory makes mocking complex.
|
||||
|
|
|
|||
Loading…
Reference in a new issue