return_pc for each frame belongs on the caller's program_counter (the resume address when this routine exits). varnum is the store variable that receives the return value, stored as return_addr on the frame itself. Also handle flags bit 4 (discard result → return_addr=None).
356 lines
12 KiB
Python
356 lines
12 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) — caller's resume PC
|
|
- Byte 3: flags (bits 0-3 = num local vars, bit 4 = discard result)
|
|
- 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.
|
|
|
|
Field mapping to runtime:
|
|
- return_pc for frame N → stored as program_counter on frame N-1 (the caller)
|
|
- varnum → stored as return_addr on the frame (the store variable)
|
|
"""
|
|
|
|
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."""
|
|
# Set up caller resume PC on ZStackBottom
|
|
sentinel = self.zmachine._stackmanager._call_stack[0]
|
|
sentinel.program_counter = 0x4200
|
|
|
|
# Create a routine with return_addr = store variable (varnum 5)
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=5,
|
|
zmem=self._mem,
|
|
args=[],
|
|
local_vars=[0x1234, 0x5678, 0xABCD],
|
|
stack=[0x1111, 0x2222],
|
|
)
|
|
self.zmachine._stackmanager.push_routine(routine)
|
|
|
|
chunk = self.writer._generate_stks_chunk()
|
|
|
|
# return_pc comes from ZStackBottom.program_counter (0x4200)
|
|
# varnum is frame.return_addr (5)
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x42,
|
|
0x00, # return_pc (from caller's program_counter)
|
|
0x03, # flags (3 local vars)
|
|
0x05, # varnum (store variable)
|
|
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."""
|
|
sentinel = self.zmachine._stackmanager._call_stack[0]
|
|
sentinel.program_counter = 0 # main routine has no caller
|
|
|
|
# Frame 1: outer routine (varnum=0 means push result to stack)
|
|
routine1 = ZRoutine(
|
|
start_addr=0x1000,
|
|
return_addr=0,
|
|
zmem=self._mem,
|
|
args=[],
|
|
local_vars=[0x0001],
|
|
stack=[],
|
|
)
|
|
self.zmachine._stackmanager.push_routine(routine1)
|
|
# Set routine1's resume PC (where to go after frame2 returns)
|
|
routine1.program_counter = 0x1050
|
|
|
|
# Frame 2: inner routine (varnum=3)
|
|
routine2 = ZRoutine(
|
|
start_addr=0x2000,
|
|
return_addr=3,
|
|
zmem=self._mem,
|
|
args=[],
|
|
local_vars=[0x0002, 0x0003],
|
|
stack=[0xAAAA],
|
|
)
|
|
self.zmachine._stackmanager.push_routine(routine2)
|
|
|
|
chunk = self.writer._generate_stks_chunk()
|
|
|
|
# Frame 1: return_pc from sentinel.pc (0), varnum=0
|
|
frame1 = bytes(
|
|
[
|
|
0x00,
|
|
0x00,
|
|
0x00, # return_pc = 0 (from sentinel)
|
|
0x01, # flags (1 local var)
|
|
0x00, # varnum = 0 (push to stack)
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
0x00,
|
|
0x01, # local_vars[0]
|
|
]
|
|
)
|
|
# Frame 2: return_pc from routine1.pc (0x1050), varnum=3
|
|
frame2 = bytes(
|
|
[
|
|
0x00,
|
|
0x10,
|
|
0x50, # return_pc (from routine1.program_counter)
|
|
0x02, # flags (2 local vars)
|
|
0x03, # varnum = 3
|
|
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."""
|
|
sentinel = self.zmachine._stackmanager._call_stack[0]
|
|
sentinel.program_counter = 0x2500
|
|
|
|
routine = ZRoutine(
|
|
start_addr=0x3000,
|
|
return_addr=1,
|
|
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 (from sentinel.program_counter)
|
|
0x00, # flags (0 local vars)
|
|
0x01, # varnum = 1
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
]
|
|
)
|
|
self.assertEqual(chunk, expected)
|
|
|
|
def test_discard_result_sets_flags_bit4(self):
|
|
"""Frame with return_addr=None should set bit 4 in flags byte."""
|
|
sentinel = self.zmachine._stackmanager._call_stack[0]
|
|
sentinel.program_counter = 0
|
|
|
|
routine = ZRoutine(
|
|
start_addr=0x1000,
|
|
return_addr=None,
|
|
zmem=self._mem,
|
|
args=[],
|
|
local_vars=[0x0001, 0x0002],
|
|
stack=[],
|
|
)
|
|
self.zmachine._stackmanager.push_routine(routine)
|
|
|
|
chunk = self.writer._generate_stks_chunk()
|
|
|
|
# flags = 0x02 (2 locals) | 0x10 (discard) = 0x12
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x00,
|
|
0x00, # return_pc
|
|
0x12, # flags (2 locals + discard bit)
|
|
0x00, # varnum (0 when discarding)
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
0x00,
|
|
0x01, # local_vars[0]
|
|
0x00,
|
|
0x02, # local_vars[1]
|
|
]
|
|
)
|
|
self.assertEqual(chunk, expected)
|
|
|
|
def test_round_trip_with_parser(self):
|
|
"""Generated stks bytes should parse back identically."""
|
|
from mudlib.zmachine.quetzal import QuetzalParser
|
|
|
|
sentinel = self.zmachine._stackmanager._call_stack[0]
|
|
sentinel.program_counter = 0 # main routine caller
|
|
|
|
# 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)
|
|
routine1.program_counter = 0x1234 # resume PC for after routine2
|
|
|
|
routine2 = ZRoutine(
|
|
start_addr=0x2000,
|
|
return_addr=5,
|
|
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
|
|
call_stack = self.zmachine._stackmanager._call_stack
|
|
frames = call_stack[1:]
|
|
self.assertEqual(len(frames), 2)
|
|
|
|
# Check frame 1: return_addr = varnum, caller PC on sentinel
|
|
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])
|
|
# Sentinel should have frame1's return_pc (0)
|
|
self.assertEqual(call_stack[0].program_counter, 0)
|
|
|
|
# Check frame 2: return_addr = varnum, caller PC on frame1
|
|
assert isinstance(frames[1], ZRoutine)
|
|
self.assertEqual(frames[1].return_addr, 5)
|
|
self.assertEqual(frames[1].local_vars[:1], [0x4444])
|
|
self.assertEqual(frames[1].stack, [0xCCCC, 0xDDDD, 0xEEEE])
|
|
# Frame1 should have frame2's return_pc (0x1234)
|
|
self.assertEqual(frames[0].program_counter, 0x1234)
|
|
|
|
def test_parse_return_pc_goes_to_caller(self):
|
|
"""Parser should put return_pc on the caller frame's program_counter."""
|
|
from mudlib.zmachine.quetzal import QuetzalParser
|
|
|
|
# Construct a minimal stack frame with return_pc=0x123456
|
|
stks_bytes = bytes(
|
|
[
|
|
0x12,
|
|
0x34,
|
|
0x56, # return_pc = 0x123456
|
|
0x00, # flags (0 local vars)
|
|
0x07, # varnum = 7
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
]
|
|
)
|
|
|
|
parser = QuetzalParser(self.zmachine)
|
|
parser._parse_stks(stks_bytes)
|
|
|
|
call_stack = self.zmachine._stackmanager._call_stack
|
|
frames = call_stack[1:] # skip sentinel
|
|
self.assertEqual(len(frames), 1)
|
|
|
|
# return_pc goes to caller (sentinel) program_counter
|
|
self.assertEqual(call_stack[0].program_counter, 0x123456)
|
|
# varnum goes to frame's return_addr
|
|
assert isinstance(frames[0], ZRoutine)
|
|
self.assertEqual(frames[0].return_addr, 7)
|
|
|
|
def test_parse_discard_bit_restores_none(self):
|
|
"""Parser should set return_addr=None when flags bit 4 is set."""
|
|
from mudlib.zmachine.quetzal import QuetzalParser
|
|
|
|
# flags = 0x12 = 2 locals + discard bit
|
|
stks_bytes = bytes(
|
|
[
|
|
0x00,
|
|
0x00,
|
|
0x00, # return_pc = 0
|
|
0x12, # flags (2 locals + discard)
|
|
0x00, # varnum (ignored when discarding)
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
0x00,
|
|
0x01, # local_vars[0]
|
|
0x00,
|
|
0x02, # local_vars[1]
|
|
]
|
|
)
|
|
|
|
parser = QuetzalParser(self.zmachine)
|
|
parser._parse_stks(stks_bytes)
|
|
|
|
frames = self.zmachine._stackmanager._call_stack[1:]
|
|
self.assertEqual(len(frames), 1)
|
|
self.assertIsNone(frames[0].return_addr)
|
|
self.assertEqual(frames[0].local_vars[:2], [1, 2])
|
|
|
|
@property
|
|
def _mem(self):
|
|
"""Helper to get mock memory."""
|
|
return self.zmachine._mem
|