Add round-trip save/restore integration test

Verifies complete save/restore pipeline by generating save data with
QuetzalWriter and restoring it with QuetzalParser. Tests cover:
- Basic round-trip with memory, stack, and PC restoration
- Multiple nested call frames
- Preservation of unchanged memory bytes (run-length encoding)
- Empty stack (no routine frames)

Each test confirms that state modified after save is correctly
restored to original values from the save data.
This commit is contained in:
Jared Miller 2026-02-10 10:06:21 -05:00
parent b0fb9b5e2c
commit 1ffc4e14c2
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -0,0 +1,301 @@
"""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.
"""
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)
# Based on the Z-machine spec, this is the minimum viable header
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
# Create pristine and current memory
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
stack_manager = ZStackManager(current_mem)
routine = ZRoutine(
start_addr=0x5000,
return_addr=0x1234,
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 # Current PC
zmachine._opdecoder = Mock()
# SAVE: Generate save data
writer = QuetzalWriter(zmachine)
save_data = writer.generate_save_data()
# Verify we got valid IFF data
assert save_data[:4] == b"FORM"
assert save_data[8:12] == b"IFZS"
# MODIFY: Change the zmachine state to different values
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
# Verify state was actually modified
assert current_mem[0x100] == 0x99
assert len(stack_manager._call_stack) == 1 # Only bottom sentinel
assert zmachine._cpu._program_counter == 0x9999
# RESTORE: Load save data
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 (get fresh reference since parser replaces it)
restored_stack = zmachine._stackmanager
assert len(restored_stack._call_stack) == 2 # Bottom + one frame
restored_frame = restored_stack._call_stack[1]
assert restored_frame.return_addr == 0x1234
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
# Create minimal story
story_data = bytearray(8192)
story_data[0] = 3 # Version 3
story_data[0x02:0x04] = [0x10, 0x00] # Release
story_data[0x04:0x06] = [0x10, 0x00] # High memory
story_data[0x06:0x08] = [0x08, 0x00] # Initial PC
story_data[0x0E:0x10] = [0x04, 0x00] # Static memory
story_data[0x0C:0x0E] = [0x02, 0x00] # Globals
story_data[0x12:0x18] = b"860101" # Serial
story_data[0x1C:0x1E] = [0x00, 0x00] # Checksum
pristine_mem = ZMemory(bytes(story_data))
current_mem = ZMemory(bytes(story_data))
# Create nested call frames
stack_manager = ZStackManager(current_mem)
routine1 = ZRoutine(
start_addr=0x5000,
return_addr=0x1000,
zmem=current_mem,
args=[],
local_vars=[0xAAAA, 0xBBBB],
stack=[0x1111],
)
stack_manager.push_routine(routine1)
routine2 = ZRoutine(
start_addr=0x6000,
return_addr=0x2000,
zmem=current_mem,
args=[],
local_vars=[0xCCCC],
stack=[0x2222, 0x3333],
)
stack_manager.push_routine(routine2)
# Set up zmachine
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 (get fresh reference)
restored_stack = zmachine._stackmanager
assert len(restored_stack._call_stack) == 3 # Bottom + two frames
frame1 = restored_stack._call_stack[1]
assert frame1.return_addr == 0x1000
assert frame1.local_vars[:2] == [0xAAAA, 0xBBBB]
assert frame1.stack == [0x1111]
frame2 = restored_stack._call_stack[2]
assert frame2.return_addr == 0x2000
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
# Create story with specific pattern in dynamic memory
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]
# Set a pattern in dynamic memory
for i in range(0x100, 0x200):
story_data[i] = i & 0xFF
pristine_mem = ZMemory(bytes(story_data))
current_mem = ZMemory(bytes(story_data))
# Only modify a few bytes
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()
# Save and restore
writer = QuetzalWriter(zmachine)
save_data = writer.generate_save_data()
# Corrupt all dynamic memory
for i in range(0x100, 0x200):
current_mem[i] = 0x00
parser = QuetzalParser(zmachine)
parser.load_from_bytes(save_data)
# Verify all bytes restored correctly
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 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()
# Save with empty stack
writer = QuetzalWriter(zmachine)
save_data = writer.generate_save_data()
# Add a dummy frame
from mudlib.zmachine.zstackmanager import ZRoutine
dummy = ZRoutine(
start_addr=0x5000,
return_addr=0x1000,
zmem=current_mem,
args=[],
local_vars=[0x9999],
stack=[],
)
stack_manager.push_routine(dummy)
# Restore
parser = QuetzalParser(zmachine)
parser.load_from_bytes(save_data)
# Should have only bottom sentinel (get fresh reference)
restored_stack = zmachine._stackmanager
assert len(restored_stack._call_stack) == 1
assert current_mem[0x100] == 0x42