Implement V3 restore opcode: - Add QuetzalParser.load_from_bytes() to parse save data from memory - Wire op_restore to call filesystem.restore_game() and parse result - Validate IFhd matches current story (release/serial/checksum) - Restore dynamic memory, call stack, and program counter - Branch true on success, false on failure/cancellation Fix IFF chunk padding bug: - Add padding byte to odd-length chunks in QuetzalWriter - Ensures proper chunk alignment for parser compatibility Add comprehensive tests: - Branch false when filesystem returns None - Branch false without zmachine reference - Branch true on successful restore - Verify memory state matches saved values - Handle malformed save data gracefully
417 lines
12 KiB
Python
417 lines
12 KiB
Python
"""Tests for QuetzalWriter Stks chunk generation."""
|
|
|
|
|
|
class TestStksChunkGeneration:
|
|
"""Test Stks chunk generation and serialization."""
|
|
|
|
def test_empty_stack_serialization(self):
|
|
"""Test serializing an empty stack (just the bottom sentinel)."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZStackManager
|
|
|
|
# Setup: create a mock zmachine with only the bottom sentinel
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Empty stack should serialize to empty bytes (no frames)
|
|
assert result == b""
|
|
|
|
def test_single_frame_no_locals_no_stack(self):
|
|
"""Test serializing a single routine frame with no locals or stack values."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create a routine with no locals and no stack values
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x1234,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[],
|
|
stack=[],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Expected format:
|
|
# Bytes 0-2: return_pc = 0x1234 (24-bit big-endian)
|
|
# Byte 3: flags = 0 (0 local vars)
|
|
# Byte 4: varnum = 0 (which variable gets return value)
|
|
# Byte 5: argflag = 0
|
|
# Bytes 6-7: eval_stack_size = 0
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x12,
|
|
0x34, # return_pc (24-bit)
|
|
0x00, # flags (0 locals)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
def test_single_frame_with_locals(self):
|
|
"""Test serializing a frame with local variables."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create a routine with 3 local variables
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x2000,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0x1111, 0x2222, 0x3333],
|
|
stack=[],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Expected format:
|
|
# Bytes 0-2: return_pc = 0x2000
|
|
# Byte 3: flags = 3 (3 local vars in bits 0-3)
|
|
# Byte 4: varnum = 0x00
|
|
# Byte 5: argflag = 0
|
|
# Bytes 6-7: eval_stack_size = 0
|
|
# Bytes 8-13: three local vars (0x1111, 0x2222, 0x3333)
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x20,
|
|
0x00, # return_pc
|
|
0x03, # flags (3 locals)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
0x11,
|
|
0x11, # local_var[0]
|
|
0x22,
|
|
0x22, # local_var[1]
|
|
0x33,
|
|
0x33, # local_var[2]
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
def test_single_frame_with_stack_values(self):
|
|
"""Test serializing a frame with evaluation stack values."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create a routine with stack values but no locals
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x3000,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[],
|
|
stack=[0xABCD, 0xEF01],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Expected format:
|
|
# Bytes 0-2: return_pc = 0x3000
|
|
# Byte 3: flags = 0 (0 locals)
|
|
# Byte 4: varnum = 0x00
|
|
# Byte 5: argflag = 0
|
|
# Bytes 6-7: eval_stack_size = 2
|
|
# Bytes 8-11: two stack values
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x30,
|
|
0x00, # return_pc
|
|
0x00, # flags (0 locals)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x02, # eval_stack_size = 2
|
|
0xAB,
|
|
0xCD, # stack[0]
|
|
0xEF,
|
|
0x01, # stack[1]
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
def test_single_frame_full(self):
|
|
"""Test serializing a frame with both locals and stack values."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create a routine with 2 locals and 3 stack values
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x4567,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0x0001, 0x0002],
|
|
stack=[0x1000, 0x2000, 0x3000],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x45,
|
|
0x67, # return_pc
|
|
0x02, # flags (2 locals)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x03, # eval_stack_size = 3
|
|
0x00,
|
|
0x01, # local_var[0]
|
|
0x00,
|
|
0x02, # local_var[1]
|
|
0x10,
|
|
0x00, # stack[0]
|
|
0x20,
|
|
0x00, # stack[1]
|
|
0x30,
|
|
0x00, # stack[2]
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
def test_multiple_nested_frames(self):
|
|
"""Test serializing multiple nested routine frames."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create first routine
|
|
routine1 = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x1000,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0xAAAA],
|
|
stack=[0xBBBB],
|
|
)
|
|
stack_manager.push_routine(routine1)
|
|
|
|
# Create second routine (nested)
|
|
routine2 = ZRoutine(
|
|
start_addr=0x6000,
|
|
return_addr=0x2000,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0xCCCC],
|
|
stack=[0xDDDD],
|
|
)
|
|
stack_manager.push_routine(routine2)
|
|
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Expected: two frames concatenated
|
|
expected = bytes(
|
|
[
|
|
# Frame 1
|
|
0x00,
|
|
0x10,
|
|
0x00, # return_pc
|
|
0x01, # flags (1 local)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x01, # eval_stack_size = 1
|
|
0xAA,
|
|
0xAA, # local_var[0]
|
|
0xBB,
|
|
0xBB, # stack[0]
|
|
# Frame 2
|
|
0x00,
|
|
0x20,
|
|
0x00, # return_pc
|
|
0x01, # flags (1 local)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x01, # eval_stack_size = 1
|
|
0xCC,
|
|
0xCC, # local_var[0]
|
|
0xDD,
|
|
0xDD, # stack[0]
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
def test_bottom_frame_zero_return_pc(self):
|
|
"""Test that a bottom/dummy frame with return_pc=0 is handled correctly."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
stack_manager = ZStackManager(zmachine._mem)
|
|
|
|
# Create a routine with return_addr=0 (main routine)
|
|
routine = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x0000,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0x1234],
|
|
stack=[],
|
|
)
|
|
stack_manager.push_routine(routine)
|
|
zmachine._stackmanager = stack_manager
|
|
|
|
writer = QuetzalWriter(zmachine)
|
|
result = writer._generate_stks_chunk()
|
|
|
|
# Expected format with return_pc = 0
|
|
expected = bytes(
|
|
[
|
|
0x00,
|
|
0x00,
|
|
0x00, # return_pc = 0 (main routine)
|
|
0x01, # flags (1 local)
|
|
0x00, # varnum
|
|
0x00, # argflag
|
|
0x00,
|
|
0x00, # eval_stack_size = 0
|
|
0x12,
|
|
0x34, # local_var[0]
|
|
]
|
|
)
|
|
|
|
assert result == expected
|
|
|
|
|
|
class TestStksRoundTrip:
|
|
"""Test that Stks serialization/deserialization is symmetrical."""
|
|
|
|
def test_round_trip_serialization(self):
|
|
"""Test that we can serialize and deserialize frames correctly."""
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
|
|
from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager
|
|
|
|
zmachine = Mock()
|
|
zmachine._mem = Mock()
|
|
zmachine._mem.version = 5
|
|
|
|
# Create original stack with multiple frames
|
|
original_stack = ZStackManager(zmachine._mem)
|
|
|
|
routine1 = ZRoutine(
|
|
start_addr=0x5000,
|
|
return_addr=0x1234,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0x0001, 0x0002, 0x0003],
|
|
stack=[0x1111, 0x2222],
|
|
)
|
|
original_stack.push_routine(routine1)
|
|
|
|
routine2 = ZRoutine(
|
|
start_addr=0x6000,
|
|
return_addr=0x5678,
|
|
zmem=zmachine._mem,
|
|
args=[],
|
|
local_vars=[0xAAAA],
|
|
stack=[0xBBBB, 0xCCCC, 0xDDDD],
|
|
)
|
|
original_stack.push_routine(routine2)
|
|
|
|
zmachine._stackmanager = original_stack
|
|
|
|
# Serialize
|
|
writer = QuetzalWriter(zmachine)
|
|
stks_data = writer._generate_stks_chunk()
|
|
|
|
# Deserialize
|
|
parser = QuetzalParser(zmachine)
|
|
parser._parse_stks(stks_data)
|
|
|
|
# Verify the deserialized stack matches
|
|
restored_stack = zmachine._stackmanager
|
|
|
|
# Should have 2 frames plus the bottom sentinel
|
|
assert len(restored_stack._call_stack) == 3
|
|
|
|
# Check frame 1
|
|
frame1 = restored_stack._call_stack[1]
|
|
assert frame1.return_addr == 0x1234
|
|
assert frame1.local_vars[:3] == [0x0001, 0x0002, 0x0003]
|
|
assert frame1.stack == [0x1111, 0x2222]
|
|
|
|
# Check frame 2
|
|
frame2 = restored_stack._call_stack[2]
|
|
assert frame2.return_addr == 0x5678
|
|
assert frame2.local_vars[:1] == [0xAAAA]
|
|
assert frame2.stack == [0xBBBB, 0xCCCC, 0xDDDD]
|