diff --git a/src/mudlib/zmachine/quetzal.py b/src/mudlib/zmachine/quetzal.py index 7980d21..f0889f7 100644 --- a/src/mudlib/zmachine/quetzal.py +++ b/src/mudlib/zmachine/quetzal.py @@ -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.") diff --git a/src/mudlib/zmachine/zcpu.py b/src/mudlib/zmachine/zcpu.py index e7882fa..7f228b4 100644 --- a/src/mudlib/zmachine/zcpu.py +++ b/src/mudlib/zmachine/zcpu.py @@ -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,9 +558,26 @@ 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. """ - self._branch(False) + 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): """TODO: Write docstring here.""" diff --git a/src/mudlib/zmachine/zmachine.py b/src/mudlib/zmachine/zmachine.py index 4228207..464c290 100644 --- a/src/mudlib/zmachine/zmachine.py +++ b/src/mudlib/zmachine/zmachine.py @@ -44,6 +44,7 @@ class ZMachine: self._stream_manager, self._ui, self._lexer, + zmachine=self, ) # --------- Public APIs ----------- diff --git a/tests/test_zmachine_opcodes.py b/tests/test_zmachine_opcodes.py index df5a003..533394b 100644 --- a/tests/test_zmachine_opcodes.py +++ b/tests/test_zmachine_opcodes.py @@ -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