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:
Jared Miller 2026-02-10 10:00:19 -05:00
parent 69b1ef8a59
commit a5053e10f2
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 219 additions and 25 deletions

View file

@ -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.")

View file

@ -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):

View file

@ -44,6 +44,7 @@ class ZMachine:
self._stream_manager,
self._ui,
self._lexer,
zmachine=self,
)
# --------- Public APIs -----------

View file

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