mud/tests/test_quetzal_roundtrip.py
Jared Miller 8526e48247
Fix Quetzal Stks field mapping: return_pc to caller, varnum to frame
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).
2026-02-10 12:39:40 -05:00

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