Add undo command support
This commit is contained in:
parent
8288b2535a
commit
c91d6a4993
2 changed files with 321 additions and 6 deletions
|
|
@ -65,6 +65,7 @@ class ZCpu:
|
||||||
self._lexer = zlexer
|
self._lexer = zlexer
|
||||||
self._zmachine = zmachine
|
self._zmachine = zmachine
|
||||||
self._trace = deque(maxlen=20)
|
self._trace = deque(maxlen=20)
|
||||||
|
self._undo_snapshot = None
|
||||||
self._dispatch = self._build_dispatch_table()
|
self._dispatch = self._build_dispatch_table()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -1181,16 +1182,85 @@ class ZCpu:
|
||||||
self._write_result(0) # unsupported font
|
self._write_result(0) # unsupported font
|
||||||
|
|
||||||
def op_save_undo(self, *args):
|
def op_save_undo(self, *args):
|
||||||
"""Save undo state. Store -1 if not available (V5+).
|
"""Save undo state (V5+, EXT:9).
|
||||||
|
|
||||||
Stores 1 on success, 0 on failure, -1 if not available.
|
Captures a snapshot of dynamic memory, call stack, and PC.
|
||||||
Real undo support deferred; return -1 for now.
|
Stores 1 on success. After restore_undo, execution resumes
|
||||||
|
here with result 2 (like fork() returning different values).
|
||||||
"""
|
"""
|
||||||
self._write_result(self._unmake_signed(-1))
|
from .zstackmanager import ZRoutine, ZStackBottom
|
||||||
|
|
||||||
|
# Read store address first — advances PC past the store byte
|
||||||
|
store_addr = self._opdecoder.get_store_address()
|
||||||
|
|
||||||
|
# Capture dynamic memory
|
||||||
|
mem = self._memory
|
||||||
|
dynamic_copy = bytearray(mem._memory[mem._dynamic_start : mem._dynamic_end + 1])
|
||||||
|
|
||||||
|
# Deep copy call stack
|
||||||
|
stack_copy = []
|
||||||
|
for frame in self._stackmanager._call_stack:
|
||||||
|
if isinstance(frame, ZStackBottom):
|
||||||
|
bottom = ZStackBottom()
|
||||||
|
bottom.program_counter = frame.program_counter
|
||||||
|
bottom.stack = frame.stack[:]
|
||||||
|
bottom.local_vars = frame.local_vars[:]
|
||||||
|
stack_copy.append(bottom)
|
||||||
|
else:
|
||||||
|
new_frame = ZRoutine(
|
||||||
|
frame.start_addr,
|
||||||
|
frame.return_addr,
|
||||||
|
self._memory,
|
||||||
|
[],
|
||||||
|
local_vars=frame.local_vars[:],
|
||||||
|
stack=frame.stack[:],
|
||||||
|
)
|
||||||
|
new_frame.program_counter = frame.program_counter
|
||||||
|
new_frame.arg_count = frame.arg_count
|
||||||
|
stack_copy.append(new_frame)
|
||||||
|
|
||||||
|
self._undo_snapshot = (
|
||||||
|
self._opdecoder.program_counter,
|
||||||
|
store_addr,
|
||||||
|
dynamic_copy,
|
||||||
|
stack_copy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store 1 = success
|
||||||
|
self._write_result(1, store_addr=store_addr)
|
||||||
|
|
||||||
def op_restore_undo(self, *args):
|
def op_restore_undo(self, *args):
|
||||||
"""Restore undo state. Store 0 on failure (V5+)."""
|
"""Restore undo state (V5+, EXT:10).
|
||||||
self._write_result(0)
|
|
||||||
|
Restores dynamic memory, call stack, and PC from snapshot.
|
||||||
|
Stores 0 on failure. On success, execution resumes at the
|
||||||
|
save_undo call site with result 2.
|
||||||
|
"""
|
||||||
|
if self._undo_snapshot is None:
|
||||||
|
self._write_result(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
pc, store_addr, dynamic_copy, stack_copy = self._undo_snapshot
|
||||||
|
self._undo_snapshot = None
|
||||||
|
|
||||||
|
# Restore dynamic memory
|
||||||
|
mem = self._memory
|
||||||
|
mem._memory[mem._dynamic_start : mem._dynamic_end + 1] = dynamic_copy
|
||||||
|
|
||||||
|
# Restore call stack: keep the live ZStackBottom identity,
|
||||||
|
# but restore its state from the snapshot
|
||||||
|
live_bottom = self._stackmanager._stackbottom
|
||||||
|
saved_bottom = stack_copy[0]
|
||||||
|
live_bottom.program_counter = saved_bottom.program_counter
|
||||||
|
live_bottom.stack = saved_bottom.stack[:]
|
||||||
|
live_bottom.local_vars = saved_bottom.local_vars[:]
|
||||||
|
self._stackmanager._call_stack[:] = [live_bottom] + stack_copy[1:]
|
||||||
|
|
||||||
|
# Restore PC
|
||||||
|
self._opdecoder.program_counter = pc
|
||||||
|
|
||||||
|
# Store 2 at save_undo's store location (not restore_undo's)
|
||||||
|
self._write_result(2, store_addr=store_addr)
|
||||||
|
|
||||||
def op_print_unicode(self, char_code):
|
def op_print_unicode(self, char_code):
|
||||||
"""Print a Unicode character (V5+, EXT:11)."""
|
"""Print a Unicode character (V5+, EXT:11)."""
|
||||||
|
|
|
||||||
245
tests/test_zmachine_undo.py
Normal file
245
tests/test_zmachine_undo.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
"""Tests for Z-machine save_undo / restore_undo opcodes (V5+, EXT:9/10)."""
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from mudlib.zmachine.zcpu import ZCpu
|
||||||
|
from mudlib.zmachine.zmemory import ZMemory
|
||||||
|
from mudlib.zmachine.zstackmanager import ZStackManager
|
||||||
|
|
||||||
|
|
||||||
|
class MockOpDecoder:
|
||||||
|
"""Mock opcode decoder for undo tests."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.program_counter = 0x800
|
||||||
|
self.store_address = None
|
||||||
|
self.branch_condition = True
|
||||||
|
self.branch_offset = 2
|
||||||
|
|
||||||
|
def get_store_address(self):
|
||||||
|
return self.store_address
|
||||||
|
|
||||||
|
def get_branch_offset(self):
|
||||||
|
return (self.branch_condition, self.branch_offset)
|
||||||
|
|
||||||
|
|
||||||
|
class MockUI:
|
||||||
|
def __init__(self):
|
||||||
|
self.screen = Mock()
|
||||||
|
self.keyboard_input = Mock()
|
||||||
|
self.filesystem = Mock()
|
||||||
|
|
||||||
|
|
||||||
|
def make_v8_story(static_start=0x0800, globals_start=0x0400):
|
||||||
|
"""Create a minimal V8 story with proper memory layout."""
|
||||||
|
size = max(static_start + 512, 2048)
|
||||||
|
story = bytearray(size)
|
||||||
|
story[0] = 8 # V8
|
||||||
|
story[0x04] = (static_start >> 8) & 0xFF # high memory start
|
||||||
|
story[0x05] = static_start & 0xFF
|
||||||
|
story[0x0C] = (globals_start >> 8) & 0xFF
|
||||||
|
story[0x0D] = globals_start & 0xFF
|
||||||
|
story[0x0E] = (static_start >> 8) & 0xFF
|
||||||
|
story[0x0F] = static_start & 0xFF
|
||||||
|
return story
|
||||||
|
|
||||||
|
|
||||||
|
class SaveUndoTests(TestCase):
|
||||||
|
"""Tests for op_save_undo and op_restore_undo."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
story = make_v8_story()
|
||||||
|
self.memory = ZMemory(bytes(story))
|
||||||
|
self.stack = ZStackManager(self.memory)
|
||||||
|
self.decoder = MockOpDecoder()
|
||||||
|
self.ui = MockUI()
|
||||||
|
|
||||||
|
self.cpu = ZCpu(
|
||||||
|
self.memory,
|
||||||
|
self.decoder,
|
||||||
|
self.stack,
|
||||||
|
Mock(), # objects
|
||||||
|
Mock(), # string
|
||||||
|
Mock(), # stream manager
|
||||||
|
self.ui,
|
||||||
|
Mock(), # lexer
|
||||||
|
zmachine=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_undo_stores_1(self):
|
||||||
|
"""save_undo stores 1 (success), not -1 (not available)."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
self.assertEqual(self.memory.read_global(0x10), 1)
|
||||||
|
|
||||||
|
def test_restore_undo_no_snapshot_stores_0(self):
|
||||||
|
"""restore_undo with no prior save stores 0."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
self.assertEqual(self.memory.read_global(0x10), 0)
|
||||||
|
|
||||||
|
def test_save_then_restore_stores_2(self):
|
||||||
|
"""After save + restore, save_undo's store location holds 2."""
|
||||||
|
# save_undo stores result in global var 0x10
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
self.assertEqual(self.memory.read_global(0x10), 1)
|
||||||
|
|
||||||
|
# restore — use a different store address for restore_undo itself
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.decoder.program_counter = 0xA00
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
# save_undo's store var (0x10) should hold 2
|
||||||
|
self.assertEqual(self.memory.read_global(0x10), 2)
|
||||||
|
|
||||||
|
def test_restore_reverts_dynamic_memory(self):
|
||||||
|
"""Memory changes after save_undo are reverted by restore_undo."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
|
||||||
|
# Set initial memory state in dynamic region
|
||||||
|
self.memory[0x100] = 0xAA
|
||||||
|
self.memory[0x200] = 0xBB
|
||||||
|
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Modify memory after save
|
||||||
|
self.memory[0x100] = 0x11
|
||||||
|
self.memory[0x200] = 0x22
|
||||||
|
self.memory[0x300] = 0x33
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
# Memory should be reverted to save-time state
|
||||||
|
self.assertEqual(self.memory[0x100], 0xAA)
|
||||||
|
self.assertEqual(self.memory[0x200], 0xBB)
|
||||||
|
self.assertEqual(self.memory[0x300], 0x00)
|
||||||
|
|
||||||
|
def test_restore_reverts_program_counter(self):
|
||||||
|
"""PC is restored to save_undo's save point."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Move PC forward
|
||||||
|
self.decoder.program_counter = 0xB00
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
self.assertEqual(self.decoder.program_counter, 0x900)
|
||||||
|
|
||||||
|
def test_restore_reverts_call_stack(self):
|
||||||
|
"""Call stack changes after save_undo are reverted."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
|
||||||
|
# Set up a routine header at 0x500: 2 local vars (V8 = zero-init)
|
||||||
|
self.memory._memory[0x500] = 2
|
||||||
|
|
||||||
|
# Start a routine before saving
|
||||||
|
self.stack.start_routine(0x500, 0x01, 0x900, [42, 99])
|
||||||
|
self.assertEqual(len(self.stack._call_stack), 2)
|
||||||
|
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Push another routine after save
|
||||||
|
self.memory._memory[0x600] = 1
|
||||||
|
self.stack.start_routine(0x600, 0x02, 0xA00, [7])
|
||||||
|
self.assertEqual(len(self.stack._call_stack), 3)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
# Should be back to 2 frames
|
||||||
|
self.assertEqual(len(self.stack._call_stack), 2)
|
||||||
|
|
||||||
|
def test_multiple_saves_keeps_latest(self):
|
||||||
|
"""Second save_undo overwrites the first snapshot."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
|
||||||
|
# First save
|
||||||
|
self.memory[0x100] = 0xAA
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Modify and save again
|
||||||
|
self.memory[0x100] = 0xBB
|
||||||
|
self.decoder.program_counter = 0x950
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Modify again
|
||||||
|
self.memory[0x100] = 0xCC
|
||||||
|
|
||||||
|
# Restore — should go to second save (0xBB), not first (0xAA)
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
self.assertEqual(self.memory[0x100], 0xBB)
|
||||||
|
self.assertEqual(self.decoder.program_counter, 0x950)
|
||||||
|
|
||||||
|
def test_snapshot_is_deep_copy_memory(self):
|
||||||
|
"""Modifying memory after save doesn't corrupt the snapshot."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
|
||||||
|
self.memory[0x100] = 0xAA
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Overwrite the same address
|
||||||
|
self.memory[0x100] = 0xFF
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
# Should be AA, not FF
|
||||||
|
self.assertEqual(self.memory[0x100], 0xAA)
|
||||||
|
|
||||||
|
def test_snapshot_is_deep_copy_stack(self):
|
||||||
|
"""Modifying stack frames after save doesn't corrupt the snapshot."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
|
||||||
|
# Start a routine with known local vars
|
||||||
|
self.memory._memory[0x500] = 2
|
||||||
|
self.stack.start_routine(0x500, 0x01, 0x900, [42, 99])
|
||||||
|
self.stack.push_stack(555)
|
||||||
|
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# Mutate the live stack frame
|
||||||
|
self.stack.set_local_variable(0, 0)
|
||||||
|
self.stack.push_stack(999)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
|
||||||
|
# Local var 0 should be restored to 42
|
||||||
|
self.assertEqual(self.stack.get_local_variable(0), 42)
|
||||||
|
# Eval stack should have the original 555 (not 999)
|
||||||
|
self.assertEqual(self.stack.pop_stack(), 555)
|
||||||
|
|
||||||
|
def test_restore_consumes_snapshot(self):
|
||||||
|
"""After restore, the snapshot is consumed (no double undo)."""
|
||||||
|
self.decoder.store_address = 0x10
|
||||||
|
self.decoder.program_counter = 0x900
|
||||||
|
self.cpu.op_save_undo()
|
||||||
|
|
||||||
|
# First restore succeeds
|
||||||
|
self.decoder.store_address = 0x11
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
self.assertEqual(self.memory.read_global(0x10), 2)
|
||||||
|
|
||||||
|
# Second restore fails (no snapshot)
|
||||||
|
self.decoder.store_address = 0x12
|
||||||
|
self.cpu.op_restore_undo()
|
||||||
|
self.assertEqual(self.memory.read_global(0x12), 0)
|
||||||
Loading…
Reference in a new issue