diff --git a/tests/test_quetzal_roundtrip.py b/tests/test_quetzal_roundtrip.py new file mode 100644 index 0000000..8218fd4 --- /dev/null +++ b/tests/test_quetzal_roundtrip.py @@ -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