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).
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""Integration tests for Quetzal save/restore round-trip.
|
|
|
|
Tests that verify the complete save/restore pipeline works end-to-end by
|
|
generating save data with QuetzalWriter and restoring it with QuetzalParser.
|
|
|
|
Field mapping reminder:
|
|
- Quetzal return_pc for frame N → caller (frame N-1) program_counter
|
|
- Quetzal varnum → frame.return_addr (store variable for return value)
|
|
"""
|
|
|
|
|
|
class TestQuetzalRoundTrip:
|
|
"""Test complete save/restore cycle with real zmachine state."""
|
|
|
|
def test_basic_round_trip(self):
|
|
"""Test saving and restoring basic zmachine state."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
|
|
from mudlib.zmachine.zmemory import ZMemory
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
# Create a minimal z-machine story file (V3)
|
|
story_data = bytearray(8192)
|
|
story_data[0] = 3 # Version 3
|
|
story_data[0x02:0x04] = [0x12, 0x34] # Release number
|
|
story_data[0x04:0x06] = [0x10, 0x00] # High memory start
|
|
story_data[0x06:0x08] = [0x08, 0x00] # Initial PC
|
|
story_data[0x0E:0x10] = [0x04, 0x00] # Static memory start
|
|
story_data[0x0C:0x0E] = [0x02, 0x00] # Global variables start
|
|
story_data[0x12:0x18] = b"860101" # Serial number
|
|
story_data[0x1C:0x1E] = [0xAB, 0xCD] # Checksum
|
|
|
|
pristine_mem = ZMemory(bytes(story_data))
|
|
current_mem = ZMemory(bytes(story_data))
|
|
|
|
# Modify some bytes in dynamic memory (before static start at 0x400)
|
|
current_mem[0x100] = 0x42
|
|
current_mem[0x101] = 0x43
|
|
current_mem[0x200] = 0xFF
|
|
|
|
# Create a stack with one frame
|
|
# return_addr=5 means "store return value in local var 4"
|
|
stack_manager = ZStackManager(current_mem)
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=5,
|
|
zmem=current_mem,
|
|
args=[],
|
|
local_vars=[0x0001, 0x0002, 0x0003],
|
|
stack=[0x1111, 0x2222],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
|
|
# Set up mock zmachine
|
|
zmachine = Mock()
|
|
zmachine._pristine_mem = pristine_mem
|
|
zmachine._mem = current_mem
|
|
zmachine._stackmanager = stack_manager
|
|
zmachine._cpu = Mock()
|
|
zmachine._cpu._program_counter = 0x0850
|
|
zmachine._opdecoder = Mock()
|
|
|
|
# SAVE
|
|
writer = QuetzalWriter(zmachine)
|
|
save_data = writer.generate_save_data()
|
|
|
|
assert save_data[:4] == b"FORM"
|
|
assert save_data[8:12] == b"IFZS"
|
|
|
|
# CORRUPT: Change the zmachine state
|
|
current_mem[0x100] = 0x99
|
|
current_mem[0x101] = 0x99
|
|
current_mem[0x200] = 0x00
|
|
stack_manager._call_stack.clear()
|
|
from mudlib.zmachine.zstackmanager import ZStackBottom
|
|
|
|
stack_manager._call_stack.append(ZStackBottom())
|
|
zmachine._cpu._program_counter = 0x9999
|
|
|
|
assert current_mem[0x100] == 0x99
|
|
assert len(stack_manager._call_stack) == 1
|
|
assert zmachine._cpu._program_counter == 0x9999
|
|
|
|
# RESTORE
|
|
parser = QuetzalParser(zmachine)
|
|
parser.load_from_bytes(save_data)
|
|
|
|
# VERIFY: Memory was restored
|
|
assert current_mem[0x100] == 0x42
|
|
assert current_mem[0x101] == 0x43
|
|
assert current_mem[0x200] == 0xFF
|
|
|
|
# VERIFY: Stack was restored
|
|
restored_stack = zmachine._stackmanager
|
|
assert len(restored_stack._call_stack) == 2 # Bottom + one frame
|
|
restored_frame = restored_stack._call_stack[1]
|
|
# return_addr is the store variable (varnum), not a PC
|
|
assert restored_frame.return_addr == 5
|
|
assert restored_frame.local_vars[:3] == [0x0001, 0x0002, 0x0003]
|
|
assert restored_frame.stack == [0x1111, 0x2222]
|
|
|
|
# VERIFY: Program counter was restored
|
|
assert zmachine._opdecoder.program_counter == 0x0850
|
|
|
|
def test_round_trip_with_multiple_frames(self):
|
|
"""Test save/restore with nested call frames."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
|
|
from mudlib.zmachine.zmemory import ZMemory
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
story_data = bytearray(8192)
|
|
story_data[0] = 3
|
|
story_data[0x02:0x04] = [0x10, 0x00]
|
|
story_data[0x04:0x06] = [0x10, 0x00]
|
|
story_data[0x06:0x08] = [0x08, 0x00]
|
|
story_data[0x0E:0x10] = [0x04, 0x00]
|
|
story_data[0x0C:0x0E] = [0x02, 0x00]
|
|
story_data[0x12:0x18] = b"860101"
|
|
story_data[0x1C:0x1E] = [0x00, 0x00]
|
|
|
|
pristine_mem = ZMemory(bytes(story_data))
|
|
current_mem = ZMemory(bytes(story_data))
|
|
|
|
# Create nested call frames with proper semantics:
|
|
# return_addr = store variable (varnum)
|
|
# program_counter = resume PC for when the next frame returns
|
|
stack_manager = ZStackManager(current_mem)
|
|
|
|
routine1 = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0, # varnum 0 = push to stack
|
|
zmem=current_mem,
|
|
args=[],
|
|
local_vars=[0xAAAA, 0xBBBB],
|
|
stack=[0x1111],
|
|
)
|
|
stack_manager.push_routine(routine1)
|
|
# resume PC in routine1 after routine2 returns
|
|
routine1.program_counter = 0x5123
|
|
|
|
routine2 = ZRoutine(
|
|
start_addr=0x6000,
|
|
return_addr=3, # varnum 3 = local var 2
|
|
zmem=current_mem,
|
|
args=[],
|
|
local_vars=[0xCCCC],
|
|
stack=[0x2222, 0x3333],
|
|
)
|
|
stack_manager.push_routine(routine2)
|
|
|
|
zmachine = Mock()
|
|
zmachine._pristine_mem = pristine_mem
|
|
zmachine._mem = current_mem
|
|
zmachine._stackmanager = stack_manager
|
|
zmachine._cpu = Mock()
|
|
zmachine._cpu._program_counter = 0x0900
|
|
zmachine._opdecoder = Mock()
|
|
|
|
# Save
|
|
writer = QuetzalWriter(zmachine)
|
|
save_data = writer.generate_save_data()
|
|
|
|
# Clear stack
|
|
stack_manager._call_stack.clear()
|
|
from mudlib.zmachine.zstackmanager import ZStackBottom
|
|
|
|
stack_manager._call_stack.append(ZStackBottom())
|
|
|
|
# Restore
|
|
parser = QuetzalParser(zmachine)
|
|
parser.load_from_bytes(save_data)
|
|
|
|
# Verify both frames restored
|
|
restored_stack = zmachine._stackmanager
|
|
assert len(restored_stack._call_stack) == 3 # Bottom + two frames
|
|
|
|
frame1 = restored_stack._call_stack[1]
|
|
assert frame1.return_addr == 0 # varnum
|
|
assert frame1.local_vars[:2] == [0xAAAA, 0xBBBB]
|
|
assert frame1.stack == [0x1111]
|
|
# frame1 should have the resume PC for after frame2 returns
|
|
assert frame1.program_counter == 0x5123
|
|
|
|
frame2 = restored_stack._call_stack[2]
|
|
assert frame2.return_addr == 3 # varnum
|
|
assert frame2.local_vars[:1] == [0xCCCC]
|
|
assert frame2.stack == [0x2222, 0x3333]
|
|
|
|
def test_round_trip_preserves_unchanged_memory(self):
|
|
"""Test that unchanged memory bytes are preserved correctly."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
|
|
from mudlib.zmachine.zmemory import ZMemory
|
|
from mudlib.zmachine.zstackmanager import ZStackManager
|
|
|
|
story_data = bytearray(8192)
|
|
story_data[0] = 3
|
|
story_data[0x02:0x04] = [0x10, 0x00]
|
|
story_data[0x04:0x06] = [0x10, 0x00]
|
|
story_data[0x06:0x08] = [0x08, 0x00]
|
|
story_data[0x0E:0x10] = [0x04, 0x00]
|
|
story_data[0x0C:0x0E] = [0x02, 0x00]
|
|
story_data[0x12:0x18] = b"860101"
|
|
story_data[0x1C:0x1E] = [0x00, 0x00]
|
|
|
|
for i in range(0x100, 0x200):
|
|
story_data[i] = i & 0xFF
|
|
|
|
pristine_mem = ZMemory(bytes(story_data))
|
|
current_mem = ZMemory(bytes(story_data))
|
|
|
|
current_mem[0x150] = 0xFF
|
|
current_mem[0x180] = 0xAA
|
|
|
|
stack_manager = ZStackManager(current_mem)
|
|
|
|
zmachine = Mock()
|
|
zmachine._pristine_mem = pristine_mem
|
|
zmachine._mem = current_mem
|
|
zmachine._stackmanager = stack_manager
|
|
zmachine._cpu = Mock()
|
|
zmachine._cpu._program_counter = 0x0800
|
|
zmachine._opdecoder = Mock()
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
save_data = writer.generate_save_data()
|
|
|
|
for i in range(0x100, 0x200):
|
|
current_mem[i] = 0x00
|
|
|
|
parser = QuetzalParser(zmachine)
|
|
parser.load_from_bytes(save_data)
|
|
|
|
for i in range(0x100, 0x200):
|
|
if i == 0x150:
|
|
assert current_mem[i] == 0xFF
|
|
elif i == 0x180:
|
|
assert current_mem[i] == 0xAA
|
|
else:
|
|
assert current_mem[i] == (i & 0xFF)
|
|
|
|
def test_round_trip_empty_stack(self):
|
|
"""Test save/restore with no routine frames (just bottom sentinel)."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
|
|
from mudlib.zmachine.zmemory import ZMemory
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
story_data = bytearray(8192)
|
|
story_data[0] = 3
|
|
story_data[0x02:0x04] = [0x10, 0x00]
|
|
story_data[0x04:0x06] = [0x10, 0x00]
|
|
story_data[0x06:0x08] = [0x08, 0x00]
|
|
story_data[0x0E:0x10] = [0x04, 0x00]
|
|
story_data[0x0C:0x0E] = [0x02, 0x00]
|
|
story_data[0x12:0x18] = b"860101"
|
|
story_data[0x1C:0x1E] = [0x00, 0x00]
|
|
|
|
pristine_mem = ZMemory(bytes(story_data))
|
|
current_mem = ZMemory(bytes(story_data))
|
|
current_mem[0x100] = 0x42
|
|
|
|
stack_manager = ZStackManager(current_mem)
|
|
|
|
zmachine = Mock()
|
|
zmachine._pristine_mem = pristine_mem
|
|
zmachine._mem = current_mem
|
|
zmachine._stackmanager = stack_manager
|
|
zmachine._cpu = Mock()
|
|
zmachine._cpu._program_counter = 0x0800
|
|
zmachine._opdecoder = Mock()
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
save_data = writer.generate_save_data()
|
|
|
|
# Add a dummy frame to verify it gets cleared
|
|
dummy = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=1,
|
|
zmem=current_mem,
|
|
args=[],
|
|
local_vars=[0x9999],
|
|
stack=[],
|
|
)
|
|
stack_manager.push_routine(dummy)
|
|
|
|
parser = QuetzalParser(zmachine)
|
|
parser.load_from_bytes(save_data)
|
|
|
|
restored_stack = zmachine._stackmanager
|
|
assert len(restored_stack._call_stack) == 1
|
|
assert current_mem[0x100] == 0x42
|