Wire op_save to QuetzalWriter and filesystem
Implement full save functionality for V3 z-machine: - Fixed QuetzalWriter._generate_anno_chunk() to return bytes - Added QuetzalWriter.generate_save_data() to produce IFF container - Updated QuetzalWriter.write() to use new method and binary mode - Added zmachine reference to ZCpu for QuetzalWriter access - Added _program_counter property to ZCpu for Quetzal access - Implemented op_save to call QuetzalWriter and filesystem - Updated tests for op_save (success, failure, IFF validation) - Added filesystem mock to MockUI for testing - Added _call_stack to MockStackManager for QuetzalWriter All tests pass. Save now generates valid IFF/FORM/IFZS data with IFhd, CMem, Stks, and ANNO chunks.
This commit is contained in:
parent
69b1ef8a59
commit
a5053e10f2
4 changed files with 219 additions and 25 deletions
|
|
@ -514,34 +514,67 @@ class QuetzalWriter:
|
|||
interpreter which created the savefile."""
|
||||
|
||||
### TODO: write this
|
||||
return "0"
|
||||
return b"0"
|
||||
|
||||
# --------- Public APIs -----------
|
||||
|
||||
def generate_save_data(self):
|
||||
"""Generate complete Quetzal save data as bytes (IFF/FORM/IFZS container).
|
||||
|
||||
Returns bytes representing the complete save file in IFF format.
|
||||
"""
|
||||
log("Generating Quetzal save data")
|
||||
|
||||
# Generate all chunks
|
||||
ifhd_chunk = self._generate_ifhd_chunk()
|
||||
cmem_chunk = self._generate_cmem_chunk()
|
||||
stks_chunk = self._generate_stks_chunk()
|
||||
anno_chunk = self._generate_anno_chunk()
|
||||
|
||||
# Build IFF container with proper chunk headers
|
||||
result = bytearray()
|
||||
|
||||
# Helper to write a chunk with its header
|
||||
def write_chunk(chunk_id, chunk_data):
|
||||
result.extend(chunk_id.encode("ascii"))
|
||||
size = len(chunk_data)
|
||||
result.append((size >> 24) & 0xFF)
|
||||
result.append((size >> 16) & 0xFF)
|
||||
result.append((size >> 8) & 0xFF)
|
||||
result.append(size & 0xFF)
|
||||
result.extend(chunk_data)
|
||||
log(f" Added {chunk_id} chunk ({size} bytes)")
|
||||
|
||||
# Write nested chunks
|
||||
write_chunk("IFhd", ifhd_chunk)
|
||||
write_chunk("CMem", cmem_chunk)
|
||||
write_chunk("Stks", stks_chunk)
|
||||
write_chunk("ANNO", anno_chunk)
|
||||
|
||||
# Calculate total size (everything after FORM header + size field)
|
||||
total_size = len(result) + 4 # +4 for "IFZS"
|
||||
|
||||
# Build final FORM container
|
||||
container = bytearray()
|
||||
container.extend(b"FORM")
|
||||
container.append((total_size >> 24) & 0xFF)
|
||||
container.append((total_size >> 16) & 0xFF)
|
||||
container.append((total_size >> 8) & 0xFF)
|
||||
container.append(total_size & 0xFF)
|
||||
container.extend(b"IFZS")
|
||||
container.extend(result)
|
||||
|
||||
log(f"Generated {len(container)} bytes of save data")
|
||||
return bytes(container)
|
||||
|
||||
def write(self, savefile_path):
|
||||
"""Write the current zmachine state to a new Quetzal-file at
|
||||
SAVEFILE_PATH."""
|
||||
|
||||
log(f"Attempting to write game-state to '{savefile_path}'")
|
||||
self._file = open(savefile_path, "w") # noqa: SIM115
|
||||
data = self.generate_save_data()
|
||||
|
||||
ifhd_chunk = self._generate_ifhd_chunk()
|
||||
cmem_chunk = self._generate_cmem_chunk()
|
||||
stks_chunk = self._generate_stks_chunk()
|
||||
anno_chunk = self._generate_anno_chunk()
|
||||
with open(savefile_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
_total_chunk_size = (
|
||||
len(ifhd_chunk) + len(cmem_chunk) + len(stks_chunk) + len(anno_chunk)
|
||||
)
|
||||
|
||||
# Write main FORM chunk to hold other chunks
|
||||
self._file.write("FORM")
|
||||
### TODO: self._file_write(total_chunk_size) -- spread it over 4 bytes
|
||||
self._file.write("IFZS")
|
||||
|
||||
# Write nested chunks.
|
||||
for chunk_data in (ifhd_chunk, cmem_chunk, stks_chunk, anno_chunk):
|
||||
self._file.write(chunk_data)
|
||||
log("Wrote a chunk.")
|
||||
self._file.close()
|
||||
log("Done writing game-state to savefile.")
|
||||
|
|
|
|||
|
|
@ -44,7 +44,16 @@ class ZCpuRestart(ZCpuError):
|
|||
|
||||
class ZCpu:
|
||||
def __init__(
|
||||
self, zmem, zopdecoder, zstack, zobjects, zstring, zstreammanager, zui, zlexer
|
||||
self,
|
||||
zmem,
|
||||
zopdecoder,
|
||||
zstack,
|
||||
zobjects,
|
||||
zstring,
|
||||
zstreammanager,
|
||||
zui,
|
||||
zlexer,
|
||||
zmachine=None,
|
||||
):
|
||||
self._memory = zmem
|
||||
self._opdecoder = zopdecoder
|
||||
|
|
@ -54,8 +63,14 @@ class ZCpu:
|
|||
self._streammanager = zstreammanager
|
||||
self._ui = zui
|
||||
self._lexer = zlexer
|
||||
self._zmachine = zmachine
|
||||
self._trace = deque(maxlen=20)
|
||||
|
||||
@property
|
||||
def _program_counter(self):
|
||||
"""Return the current program counter value."""
|
||||
return self._opdecoder.program_counter
|
||||
|
||||
def _get_handler(self, opcode_class, opcode_number):
|
||||
try:
|
||||
opcode_decl = self.opcodes[opcode_class][opcode_number]
|
||||
|
|
@ -543,8 +558,25 @@ class ZCpu:
|
|||
def op_save(self, *args):
|
||||
"""Save game state to file (V3 - branch on success).
|
||||
|
||||
Currently always fails because QuetzalWriter is not yet functional.
|
||||
Uses QuetzalWriter to generate save data in IFF/FORM/IFZS format,
|
||||
then calls the filesystem to write it. Branches true on success,
|
||||
false on failure.
|
||||
"""
|
||||
if self._zmachine is None:
|
||||
# Can't save without zmachine reference
|
||||
self._branch(False)
|
||||
return
|
||||
|
||||
from .quetzal import QuetzalWriter
|
||||
|
||||
try:
|
||||
writer = QuetzalWriter(self._zmachine)
|
||||
save_data = writer.generate_save_data()
|
||||
success = self._ui.filesystem.save_game(save_data)
|
||||
self._branch(success)
|
||||
except Exception as e:
|
||||
# Any error during save process = failure
|
||||
log(f"Save failed with exception: {e}")
|
||||
self._branch(False)
|
||||
|
||||
def op_save_v4(self, *args):
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class ZMachine:
|
|||
self._stream_manager,
|
||||
self._ui,
|
||||
self._lexer,
|
||||
zmachine=self,
|
||||
)
|
||||
|
||||
# --------- Public APIs -----------
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ class MockStackManager:
|
|||
def __init__(self):
|
||||
self.stack = []
|
||||
self.locals = [0] * 15
|
||||
# For QuetzalWriter support - empty call stack
|
||||
from mudlib.zmachine.zstackmanager import ZStackBottom
|
||||
|
||||
self._call_stack = [ZStackBottom()]
|
||||
|
||||
def push_stack(self, value):
|
||||
self.stack.append(value)
|
||||
|
|
@ -92,6 +96,7 @@ class MockUI:
|
|||
self.screen.write = Mock()
|
||||
self.keyboard_input = Mock()
|
||||
self.keyboard_input.read_line = Mock()
|
||||
self.filesystem = Mock()
|
||||
|
||||
|
||||
class ZMachineOpcodeTests(TestCase):
|
||||
|
|
@ -114,6 +119,7 @@ class ZMachineOpcodeTests(TestCase):
|
|||
Mock(), # stream manager
|
||||
self.ui,
|
||||
Mock(), # lexer
|
||||
zmachine=None,
|
||||
)
|
||||
|
||||
def test_op_nop(self):
|
||||
|
|
@ -451,6 +457,7 @@ class ZMachineObjectOpcodeTests(TestCase):
|
|||
Mock(), # stream manager
|
||||
self.ui,
|
||||
Mock(), # lexer
|
||||
zmachine=None,
|
||||
)
|
||||
|
||||
def test_op_get_sibling_with_sibling(self):
|
||||
|
|
@ -661,6 +668,7 @@ class ZMachineComplexOpcodeTests(TestCase):
|
|||
Mock(), # stream manager
|
||||
self.ui,
|
||||
Mock(), # lexer
|
||||
zmachine=None,
|
||||
)
|
||||
|
||||
def test_op_sread_v3_basic_input(self):
|
||||
|
|
@ -746,8 +754,27 @@ class ZMachineComplexOpcodeTests(TestCase):
|
|||
# Should have called show_status once
|
||||
self.assertEqual(call_count[0], 1)
|
||||
|
||||
def test_op_save_v3_branches_false(self):
|
||||
"""Test save (V3) branches false (QuetzalWriter not functional)."""
|
||||
def test_op_save_v3_branches_false_when_filesystem_fails(self):
|
||||
"""Test save (V3) branches false when filesystem returns False."""
|
||||
# Need a valid zmachine for the test to proceed
|
||||
from mudlib.zmachine.zmemory import ZMemory
|
||||
|
||||
story = bytearray(1024)
|
||||
story[0] = 3
|
||||
story[0x0E] = 0x04
|
||||
story[0x0F] = 0x00
|
||||
|
||||
zmachine_mock = Mock()
|
||||
zmachine_mock._mem = ZMemory(bytes(story))
|
||||
zmachine_mock._pristine_mem = ZMemory(bytes(story))
|
||||
zmachine_mock._cpu = self.cpu
|
||||
zmachine_mock._stackmanager = self.stack
|
||||
|
||||
self.cpu._zmachine = zmachine_mock
|
||||
|
||||
# Mock filesystem to fail
|
||||
self.ui.filesystem.save_game = Mock(return_value=False)
|
||||
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 100
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
|
@ -768,6 +795,107 @@ class ZMachineComplexOpcodeTests(TestCase):
|
|||
# Should not have branched (test is false)
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
||||
|
||||
def test_op_save_v3_branches_true_on_success(self):
|
||||
"""Test save (V3) branches true when filesystem succeeds."""
|
||||
# Create minimal zmachine mock
|
||||
from mudlib.zmachine.zmemory import ZMemory
|
||||
|
||||
story = bytearray(1024)
|
||||
story[0] = 3 # version 3
|
||||
story[0x0E] = 0x04 # static memory starts at 0x0400
|
||||
story[0x0F] = 0x00
|
||||
|
||||
zmachine_mock = Mock()
|
||||
zmachine_mock._mem = ZMemory(bytes(story))
|
||||
zmachine_mock._pristine_mem = ZMemory(bytes(story))
|
||||
zmachine_mock._cpu = self.cpu
|
||||
zmachine_mock._stackmanager = self.stack
|
||||
|
||||
# Attach zmachine to cpu
|
||||
self.cpu._zmachine = zmachine_mock
|
||||
|
||||
# Mock filesystem to succeed
|
||||
self.ui.filesystem.save_game = Mock(return_value=True)
|
||||
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 10
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_save()
|
||||
|
||||
# Should have branched (test is true)
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
|
||||
# Filesystem should have been called with bytes
|
||||
self.assertTrue(self.ui.filesystem.save_game.called)
|
||||
call_args = self.ui.filesystem.save_game.call_args[0]
|
||||
self.assertIsInstance(call_args[0], bytes)
|
||||
|
||||
def test_op_save_v3_generates_valid_iff_data(self):
|
||||
"""Test save generates valid IFF/FORM/IFZS container."""
|
||||
from mudlib.zmachine.zmemory import ZMemory
|
||||
|
||||
story = bytearray(1024)
|
||||
story[0] = 3
|
||||
story[0x0E] = 0x04
|
||||
story[0x0F] = 0x00
|
||||
# Set header values
|
||||
story[0x02] = 0x12 # release high byte
|
||||
story[0x03] = 0x34 # release low byte
|
||||
for i, byte in enumerate(b"860509"):
|
||||
story[0x12 + i] = byte
|
||||
story[0x1C] = 0xAB # checksum high
|
||||
story[0x1D] = 0xCD # checksum low
|
||||
|
||||
zmachine_mock = Mock()
|
||||
zmachine_mock._mem = ZMemory(bytes(story))
|
||||
zmachine_mock._pristine_mem = ZMemory(bytes(story))
|
||||
zmachine_mock._cpu = self.cpu
|
||||
zmachine_mock._stackmanager = self.stack
|
||||
|
||||
self.cpu._zmachine = zmachine_mock
|
||||
|
||||
# Mock filesystem to capture data
|
||||
captured_data = []
|
||||
|
||||
def capture_save(data):
|
||||
captured_data.append(data)
|
||||
return True
|
||||
|
||||
self.ui.filesystem.save_game = Mock(side_effect=capture_save)
|
||||
|
||||
self.decoder.branch_condition = True
|
||||
self.decoder.branch_offset = 10
|
||||
|
||||
self.cpu.op_save()
|
||||
|
||||
# Verify we got data
|
||||
self.assertEqual(len(captured_data), 1)
|
||||
data = captured_data[0]
|
||||
|
||||
# Verify IFF structure
|
||||
self.assertEqual(data[0:4], b"FORM") # FORM header
|
||||
# Bytes 4-7 are the size (big-endian 32-bit)
|
||||
self.assertEqual(data[8:12], b"IFZS") # IFZS type
|
||||
|
||||
# Verify chunks are present
|
||||
self.assertIn(b"IFhd", data)
|
||||
self.assertIn(b"CMem", data)
|
||||
self.assertIn(b"Stks", data)
|
||||
self.assertIn(b"ANNO", data)
|
||||
|
||||
def test_op_save_v3_without_zmachine_branches_false(self):
|
||||
"""Test save fails gracefully when zmachine is not set."""
|
||||
# Don't set zmachine
|
||||
self.cpu._zmachine = None
|
||||
|
||||
self.decoder.branch_condition = True
|
||||
old_pc = self.cpu._opdecoder.program_counter
|
||||
|
||||
self.cpu.op_save()
|
||||
|
||||
# Should not have branched
|
||||
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
||||
|
||||
def test_op_input_stream_is_noop(self):
|
||||
"""Test input_stream is a no-op stub."""
|
||||
# Should not raise
|
||||
|
|
|
|||
Loading…
Reference in a new issue