Implement V5+ save/restore opcodes and handle in-game saves on restore

- op_save_v5: generates Quetzal save, stores 1 on success / 0 on failure
- op_restore_v5: loads Quetzal save, stores 2 ("restored") via store byte
- _try_restore: detect V5+ in-game saves (0xBE 0x00 before PC) and process
  the store byte with result 2, matching the V3 branch-on-restore pattern
This commit is contained in:
Jared Miller 2026-02-10 14:18:42 -05:00
parent 602da45ac2
commit e55294af78
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 73 additions and 13 deletions

View file

@ -46,6 +46,14 @@ class EmbeddedIFSession:
Must be called before the interpreter thread is launched.
Returns True if state was restored successfully.
Handles two save origins:
- In-game save (V3: opcode 0xB5, V5+: EXT 0xBE/0x00): PC points at
branch data (V3) or store byte (V5+). Process them so execution
resumes at the next instruction.
- MUD-level _do_save during sread/aread: PC points past the read
instruction. No post-processing needed (phantom output suppressed
in start()).
"""
if not self.save_path.exists():
return False
@ -53,17 +61,24 @@ class EmbeddedIFSession:
save_data = self.save_path.read_bytes()
parser = QuetzalParser(self._zmachine)
parser.load_from_bytes(save_data)
# In V1-3, the saved PC points to branch data after the save
# instruction. Process the branch as "save succeeded" so the
# PC advances past it. Detect by checking for save opcode (0xB5)
# immediately before the restored PC.
pc = self._zmachine._opdecoder.program_counter
if (
self._zmachine._mem.version <= 3
and pc > 0
and self._zmachine._mem[pc - 1] == 0xB5
):
mem = self._zmachine._mem
if mem.version <= 3 and pc > 0 and mem[pc - 1] == 0xB5:
# V3 in-game save: PC at branch data after save opcode (0xB5).
# Process the branch as "save succeeded".
self._zmachine._cpu._branch(True)
elif (
mem.version >= 5
and pc >= 3
and mem[pc - 3] == 0xBE
and mem[pc - 2] == 0x00
):
# V5+ in-game save: PC at store byte after EXT save opcode.
# Read store byte and write 2 ("restored") to that variable.
self._zmachine._cpu._write_result(2)
return True
except Exception as e:
logger.debug(f"Restore failed: {e}")

View file

@ -1034,12 +1034,57 @@ class ZCpu:
## EXT opcodes (opcodes 256-284)
def op_save_v5(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
"""Save game state to file (V5+ - stores result).
Generates Quetzal save data and writes via filesystem.
Stores 1 on success, 0 on failure. On restore, the game
will see 2 stored in the same variable.
"""
if self._zmachine is None:
self._write_result(0)
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._write_result(1 if success else 0)
except Exception as e:
log(f"Save failed with exception: {e}")
self._write_result(0)
def op_restore_v5(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
"""Restore game state from file (V5+ - stores result).
Loads Quetzal save data and restores memory/stack/PC.
The restored PC points at the store byte of the original save
instruction. We read it and write 2 (meaning "restored") to
the indicated variable.
Stores 0 on failure (in the current, un-restored state).
"""
if self._zmachine is None:
self._write_result(0)
return
from .quetzal import QuetzalParser
try:
save_data = self._ui.filesystem.restore_game()
if save_data is None:
self._write_result(0)
return
parser = QuetzalParser(self._zmachine)
parser.load_from_bytes(save_data)
# Restored PC points at the store byte of the save instruction.
# Read it and write 2 ("restored") to that variable.
self._write_result(2)
except Exception as e:
log(f"Restore failed with exception: {e}")
self._write_result(0)
def op_log_shift(self, number, places):
"""Logical shift: positive places = left, negative = right (V5+).