"""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