mud/tests/test_quetzal_stks.py

282 lines
8.8 KiB
Python

"""
Tests for QuetzalWriter._generate_stks_chunk() serialization.
The Stks chunk serializes the Z-machine call stack. Each frame has:
- Bytes 0-2: return_pc (24-bit big-endian)
- Byte 3: flags (bits 0-3 = num local vars)
- Byte 4: varnum (which variable gets return value)
- Byte 5: argflag (bitmask of supplied arguments)
- Bytes 6-7: eval_stack_size (16-bit big-endian)
- Next (num_local_vars * 2) bytes: local variables
- Next (eval_stack_size * 2) bytes: evaluation stack values
All multi-byte values are big-endian. Bottom frame has return_pc=0.
"""
from unittest import TestCase
from mudlib.zmachine.quetzal import QuetzalWriter
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
class MockMemory:
"""Mock memory for testing."""
def __init__(self):
self.version = 5
class MockZMachine:
"""Mock z-machine with stack manager."""
def __init__(self):
self._mem = MockMemory()
self._stackmanager = ZStackManager(self._mem)
class QuetzalStksTests(TestCase):
"""Test suite for Stks chunk generation."""
def setUp(self):
self.zmachine = MockZMachine()
self.writer = QuetzalWriter(self.zmachine)
def test_empty_call_stack_generates_empty_chunk(self):
"""With only the sentinel bottom, should generate empty bytes."""
# Call stack has only ZStackBottom sentinel
chunk = self.writer._generate_stks_chunk()
self.assertEqual(chunk, b"")
def test_single_frame_serialization(self):
"""Single routine frame should serialize correctly."""
# Create a routine with known values
routine = ZRoutine(
start_addr=0x5000,
return_addr=0x4200,
zmem=self._mem,
args=[],
local_vars=[0x1234, 0x5678, 0xABCD],
stack=[0x1111, 0x2222],
)
self.zmachine._stackmanager.push_routine(routine)
chunk = self.writer._generate_stks_chunk()
# Expected bytes for this frame:
# return_pc (0x4200): 0x00, 0x42, 0x00
# flags (3 local vars): 0x03
# varnum (0): 0x00
# argflag (0): 0x00
# eval_stack_size (2): 0x00, 0x02
# local_vars: 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD
# stack: 0x11, 0x11, 0x22, 0x22
expected = bytes(
[
0x00,
0x42,
0x00, # return_pc
0x03, # flags (3 local vars)
0x00, # varnum
0x00, # argflag
0x00,
0x02, # eval_stack_size = 2
0x12,
0x34, # local_vars[0]
0x56,
0x78, # local_vars[1]
0xAB,
0xCD, # local_vars[2]
0x11,
0x11, # stack[0]
0x22,
0x22, # stack[1]
]
)
self.assertEqual(chunk, expected)
def test_multiple_frames_serialization(self):
"""Multiple nested frames should serialize in order."""
# Frame 1: outer routine
routine1 = ZRoutine(
start_addr=0x1000,
return_addr=0, # bottom frame
zmem=self._mem,
args=[],
local_vars=[0x0001],
stack=[],
)
self.zmachine._stackmanager.push_routine(routine1)
# Frame 2: inner routine
routine2 = ZRoutine(
start_addr=0x2000,
return_addr=0x1050,
zmem=self._mem,
args=[],
local_vars=[0x0002, 0x0003],
stack=[0xAAAA],
)
self.zmachine._stackmanager.push_routine(routine2)
chunk = self.writer._generate_stks_chunk()
# Expected: frame1 bytes + frame2 bytes
# Frame 1: return_pc=0, 1 local var, 0 stack
frame1 = bytes(
[
0x00,
0x00,
0x00, # return_pc = 0
0x01, # flags (1 local var)
0x00, # varnum
0x00, # argflag
0x00,
0x00, # eval_stack_size = 0
0x00,
0x01, # local_vars[0]
]
)
# Frame 2: return_pc=0x1050, 2 local vars, 1 stack
frame2 = bytes(
[
0x00,
0x10,
0x50, # return_pc
0x02, # flags (2 local vars)
0x00, # varnum
0x00, # argflag
0x00,
0x01, # eval_stack_size = 1
0x00,
0x02, # local_vars[0]
0x00,
0x03, # local_vars[1]
0xAA,
0xAA, # stack[0]
]
)
expected = frame1 + frame2
self.assertEqual(chunk, expected)
def test_frame_with_no_locals_or_stack(self):
"""Frame with no local vars or stack values should serialize correctly."""
routine = ZRoutine(
start_addr=0x3000,
return_addr=0x2500,
zmem=self._mem,
args=[],
local_vars=[],
stack=[],
)
self.zmachine._stackmanager.push_routine(routine)
chunk = self.writer._generate_stks_chunk()
expected = bytes(
[
0x00,
0x25,
0x00, # return_pc
0x00, # flags (0 local vars)
0x00, # varnum
0x00, # argflag
0x00,
0x00, # eval_stack_size = 0
]
)
self.assertEqual(chunk, expected)
def test_round_trip_with_parser(self):
"""Generated stks bytes should parse back identically."""
from mudlib.zmachine.quetzal import QuetzalParser
# Create a complex stack state
routine1 = ZRoutine(
start_addr=0x1000,
return_addr=0,
zmem=self._mem,
args=[],
local_vars=[0x1111, 0x2222, 0x3333],
stack=[0xAAAA, 0xBBBB],
)
self.zmachine._stackmanager.push_routine(routine1)
routine2 = ZRoutine(
start_addr=0x2000,
return_addr=0x1234,
zmem=self._mem,
args=[],
local_vars=[0x4444],
stack=[0xCCCC, 0xDDDD, 0xEEEE],
)
self.zmachine._stackmanager.push_routine(routine2)
# Generate the stks chunk
stks_bytes = self.writer._generate_stks_chunk()
# Parse it back
parser = QuetzalParser(self.zmachine)
parser._parse_stks(stks_bytes)
# Verify the stack was reconstructed correctly
# Parser creates a new stack manager, skip bottom sentinel
frames = self.zmachine._stackmanager._call_stack[1:]
self.assertEqual(len(frames), 2)
# Check frame 1
assert isinstance(frames[0], ZRoutine)
self.assertEqual(frames[0].return_addr, 0)
self.assertEqual(frames[0].local_vars[:3], [0x1111, 0x2222, 0x3333])
self.assertEqual(frames[0].stack, [0xAAAA, 0xBBBB])
# Check frame 2
assert isinstance(frames[1], ZRoutine)
self.assertEqual(frames[1].return_addr, 0x1234)
self.assertEqual(frames[1].local_vars[:1], [0x4444])
self.assertEqual(frames[1].stack, [0xCCCC, 0xDDDD, 0xEEEE])
def test_parse_return_pc_third_byte(self):
"""Parser should correctly read all 3 bytes of return_pc (regression test)."""
from mudlib.zmachine.quetzal import QuetzalParser
# Construct a minimal stack frame with return_pc=0x123456
# where the third byte (0x56) is non-zero and would be wrong if skipped.
#
# Stack frame format:
# - Bytes 0-2: return_pc (0x12, 0x34, 0x56)
# - Byte 3: flags (0 local vars)
# - Byte 4: varnum (0)
# - Byte 5: argflag (0)
# - Bytes 6-7: eval_stack_size (0)
stks_bytes = bytes(
[
0x12,
0x34,
0x56, # return_pc = 0x123456
0x00, # flags (0 local vars)
0x00, # varnum
0x00, # argflag
0x00,
0x00, # eval_stack_size = 0
]
)
parser = QuetzalParser(self.zmachine)
parser._parse_stks(stks_bytes)
# The parser should have reconstructed the stack with correct return_pc
frames = self.zmachine._stackmanager._call_stack[1:] # skip sentinel
self.assertEqual(len(frames), 1)
# If bug exists, it would read bytes[0], bytes[1], bytes[3] = 0x12, 0x34, 0x00
# giving return_pc = 0x123400 instead of 0x123456
assert isinstance(frames[0], ZRoutine)
self.assertEqual(
frames[0].return_addr,
0x123456,
"Parser should read bytes[ptr+2], not bytes[ptr+3]",
)
@property
def _mem(self):
"""Helper to get mock memory."""
return self.zmachine._mem