From 8526e48247527d4b6032d810a22fcb7c9c1d8bd5 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 10 Feb 2026 12:39:40 -0500 Subject: [PATCH] Fix Quetzal Stks field mapping: return_pc to caller, varnum to frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit return_pc for each frame belongs on the caller's program_counter (the resume address when this routine exits). varnum is the store variable that receives the return value, stored as return_addr on the frame itself. Also handle flags bit 4 (discard result → return_addr=None). --- src/mudlib/zmachine/quetzal.py | 48 +++++---- tests/test_quetzal_roundtrip.py | 80 +++++++------- tests/test_quetzal_stks.py | 178 ++++++++++++++++++++++---------- tests/test_quetzal_writer.py | 105 +++++++++---------- 4 files changed, 239 insertions(+), 172 deletions(-) diff --git a/src/mudlib/zmachine/quetzal.py b/src/mudlib/zmachine/quetzal.py index 19e3dd8..c5d66b2 100644 --- a/src/mudlib/zmachine/quetzal.py +++ b/src/mudlib/zmachine/quetzal.py @@ -207,13 +207,17 @@ class QuetzalParser: ptr += 3 flags_bitfield = bitfield.BitField(bytes[ptr]) ptr += 1 - _varnum = bytes[ptr] ### TODO: tells us which variable gets the result + varnum = bytes[ptr] ptr += 1 _argflag = bytes[ptr] ptr += 1 evalstack_size = (bytes[ptr] << 8) + bytes[ptr + 1] ptr += 2 + # Quetzal flags bit 4: if set, routine discards its return value + discard_result = flags_bitfield[4] + store_var = None if discard_result else varnum + # read anywhere from 0 to 15 local vars, pad to 15 num_locals = flags_bitfield[0:4] local_vars = [] @@ -233,16 +237,16 @@ class QuetzalParser: stack_values.append(val) log(f" Found {len(stack_values)} local stack values") - ### Interesting... the reconstructed stack frames have no 'start - ### address'. I guess it doesn't matter, since we only need to - ### pop back to particular return addresses to resume each - ### routine. - - ### TODO: I can exactly which of the 7 args is "supplied", but I - ### don't understand where the args *are*?? + # return_pc belongs on the CALLER frame (previous in the stack). + # When this routine finishes, finish_routine() returns + # caller.program_counter as the resume address. + prev_frame = stackmanager._call_stack[-1] + prev_frame.program_counter = return_pc + # store_var (varnum) is the variable that receives the return + # value when this routine finishes — NOT the return PC. routine = zstackmanager.ZRoutine( - 0, return_pc, self._zmachine._mem, [], local_vars, stack_values + 0, store_var, self._zmachine._mem, [], local_vars, stack_values ) stackmanager.push_routine(routine) log(" Added new frame to stack.") @@ -543,24 +547,28 @@ class QuetzalWriter: call_stack = stackmanager._call_stack # Skip the ZStackBottom sentinel (first element) - for frame in call_stack[1:]: - # Get the actual number of local vars that have non-zero values - # or are explicitly set (we store all 15 always, but only serialize - # the ones that are actually used) + for i, frame in enumerate(call_stack[1:], start=1): num_local_vars = len(frame.local_vars) - # Write return_pc as 24-bit big-endian (3 bytes) - return_pc = frame.return_addr if frame.return_addr else 0 + # Quetzal return_pc = caller's saved program counter. + # The previous frame (caller) stores the resume PC that + # finish_routine() returns when this frame exits. + prev_frame = call_stack[i - 1] + return_pc = prev_frame.program_counter or 0 result.append((return_pc >> 16) & 0xFF) result.append((return_pc >> 8) & 0xFF) result.append(return_pc & 0xFF) - # Write flags byte (bits 0-3 = num local vars) - result.append(num_local_vars & 0x0F) + # Write flags byte (bits 0-3 = num local vars, + # bit 4 = discard return value) + flags = num_local_vars & 0x0F + if frame.return_addr is None: + flags |= 0x10 + result.append(flags) - # Write varnum (which variable gets return value) - # TODO: track this properly, for now use 0 - result.append(0) + # Write varnum (which variable gets the return value) + varnum = frame.return_addr if frame.return_addr is not None else 0 + result.append(varnum & 0xFF) # Write argflag (bitmask of supplied arguments) # TODO: track this properly, for now use 0 diff --git a/tests/test_quetzal_roundtrip.py b/tests/test_quetzal_roundtrip.py index 8218fd4..55e82b0 100644 --- a/tests/test_quetzal_roundtrip.py +++ b/tests/test_quetzal_roundtrip.py @@ -2,6 +2,10 @@ Tests that verify the complete save/restore pipeline works end-to-end by generating save data with QuetzalWriter and restoring it with QuetzalParser. + +Field mapping reminder: +- Quetzal return_pc for frame N → caller (frame N-1) program_counter +- Quetzal varnum → frame.return_addr (store variable for return value) """ @@ -17,7 +21,6 @@ class TestQuetzalRoundTrip: from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager # Create a minimal z-machine story file (V3) - # Based on the Z-machine spec, this is the minimum viable header story_data = bytearray(8192) story_data[0] = 3 # Version 3 story_data[0x02:0x04] = [0x12, 0x34] # Release number @@ -28,7 +31,6 @@ class TestQuetzalRoundTrip: story_data[0x12:0x18] = b"860101" # Serial number story_data[0x1C:0x1E] = [0xAB, 0xCD] # Checksum - # Create pristine and current memory pristine_mem = ZMemory(bytes(story_data)) current_mem = ZMemory(bytes(story_data)) @@ -38,10 +40,11 @@ class TestQuetzalRoundTrip: current_mem[0x200] = 0xFF # Create a stack with one frame + # return_addr=5 means "store return value in local var 4" stack_manager = ZStackManager(current_mem) routine = ZRoutine( start_addr=0x5000, - return_addr=0x1234, + return_addr=5, zmem=current_mem, args=[], local_vars=[0x0001, 0x0002, 0x0003], @@ -55,18 +58,17 @@ class TestQuetzalRoundTrip: zmachine._mem = current_mem zmachine._stackmanager = stack_manager zmachine._cpu = Mock() - zmachine._cpu._program_counter = 0x0850 # Current PC + zmachine._cpu._program_counter = 0x0850 zmachine._opdecoder = Mock() - # SAVE: Generate save data + # SAVE writer = QuetzalWriter(zmachine) save_data = writer.generate_save_data() - # Verify we got valid IFF data assert save_data[:4] == b"FORM" assert save_data[8:12] == b"IFZS" - # MODIFY: Change the zmachine state to different values + # CORRUPT: Change the zmachine state current_mem[0x100] = 0x99 current_mem[0x101] = 0x99 current_mem[0x200] = 0x00 @@ -76,12 +78,11 @@ class TestQuetzalRoundTrip: stack_manager._call_stack.append(ZStackBottom()) zmachine._cpu._program_counter = 0x9999 - # Verify state was actually modified assert current_mem[0x100] == 0x99 - assert len(stack_manager._call_stack) == 1 # Only bottom sentinel + assert len(stack_manager._call_stack) == 1 assert zmachine._cpu._program_counter == 0x9999 - # RESTORE: Load save data + # RESTORE parser = QuetzalParser(zmachine) parser.load_from_bytes(save_data) @@ -90,11 +91,12 @@ class TestQuetzalRoundTrip: assert current_mem[0x101] == 0x43 assert current_mem[0x200] == 0xFF - # VERIFY: Stack was restored (get fresh reference since parser replaces it) + # VERIFY: Stack was restored restored_stack = zmachine._stackmanager assert len(restored_stack._call_stack) == 2 # Bottom + one frame restored_frame = restored_stack._call_stack[1] - assert restored_frame.return_addr == 0x1234 + # return_addr is the store variable (varnum), not a PC + assert restored_frame.return_addr == 5 assert restored_frame.local_vars[:3] == [0x0001, 0x0002, 0x0003] assert restored_frame.stack == [0x1111, 0x2222] @@ -109,35 +111,39 @@ class TestQuetzalRoundTrip: from mudlib.zmachine.zmemory import ZMemory from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager - # Create minimal story story_data = bytearray(8192) - story_data[0] = 3 # Version 3 - story_data[0x02:0x04] = [0x10, 0x00] # Release - story_data[0x04:0x06] = [0x10, 0x00] # High memory - story_data[0x06:0x08] = [0x08, 0x00] # Initial PC - story_data[0x0E:0x10] = [0x04, 0x00] # Static memory - story_data[0x0C:0x0E] = [0x02, 0x00] # Globals - story_data[0x12:0x18] = b"860101" # Serial - story_data[0x1C:0x1E] = [0x00, 0x00] # Checksum + story_data[0] = 3 + story_data[0x02:0x04] = [0x10, 0x00] + story_data[0x04:0x06] = [0x10, 0x00] + story_data[0x06:0x08] = [0x08, 0x00] + story_data[0x0E:0x10] = [0x04, 0x00] + story_data[0x0C:0x0E] = [0x02, 0x00] + story_data[0x12:0x18] = b"860101" + story_data[0x1C:0x1E] = [0x00, 0x00] pristine_mem = ZMemory(bytes(story_data)) current_mem = ZMemory(bytes(story_data)) - # Create nested call frames + # Create nested call frames with proper semantics: + # return_addr = store variable (varnum) + # program_counter = resume PC for when the next frame returns stack_manager = ZStackManager(current_mem) + routine1 = ZRoutine( start_addr=0x5000, - return_addr=0x1000, + return_addr=0, # varnum 0 = push to stack zmem=current_mem, args=[], local_vars=[0xAAAA, 0xBBBB], stack=[0x1111], ) stack_manager.push_routine(routine1) + # resume PC in routine1 after routine2 returns + routine1.program_counter = 0x5123 routine2 = ZRoutine( start_addr=0x6000, - return_addr=0x2000, + return_addr=3, # varnum 3 = local var 2 zmem=current_mem, args=[], local_vars=[0xCCCC], @@ -145,7 +151,6 @@ class TestQuetzalRoundTrip: ) stack_manager.push_routine(routine2) - # Set up zmachine zmachine = Mock() zmachine._pristine_mem = pristine_mem zmachine._mem = current_mem @@ -168,17 +173,19 @@ class TestQuetzalRoundTrip: parser = QuetzalParser(zmachine) parser.load_from_bytes(save_data) - # Verify both frames restored (get fresh reference) + # Verify both frames restored restored_stack = zmachine._stackmanager assert len(restored_stack._call_stack) == 3 # Bottom + two frames frame1 = restored_stack._call_stack[1] - assert frame1.return_addr == 0x1000 + assert frame1.return_addr == 0 # varnum assert frame1.local_vars[:2] == [0xAAAA, 0xBBBB] assert frame1.stack == [0x1111] + # frame1 should have the resume PC for after frame2 returns + assert frame1.program_counter == 0x5123 frame2 = restored_stack._call_stack[2] - assert frame2.return_addr == 0x2000 + assert frame2.return_addr == 3 # varnum assert frame2.local_vars[:1] == [0xCCCC] assert frame2.stack == [0x2222, 0x3333] @@ -190,7 +197,6 @@ class TestQuetzalRoundTrip: from mudlib.zmachine.zmemory import ZMemory from mudlib.zmachine.zstackmanager import ZStackManager - # Create story with specific pattern in dynamic memory story_data = bytearray(8192) story_data[0] = 3 story_data[0x02:0x04] = [0x10, 0x00] @@ -201,14 +207,12 @@ class TestQuetzalRoundTrip: story_data[0x12:0x18] = b"860101" story_data[0x1C:0x1E] = [0x00, 0x00] - # Set a pattern in dynamic memory for i in range(0x100, 0x200): story_data[i] = i & 0xFF pristine_mem = ZMemory(bytes(story_data)) current_mem = ZMemory(bytes(story_data)) - # Only modify a few bytes current_mem[0x150] = 0xFF current_mem[0x180] = 0xAA @@ -222,18 +226,15 @@ class TestQuetzalRoundTrip: zmachine._cpu._program_counter = 0x0800 zmachine._opdecoder = Mock() - # Save and restore writer = QuetzalWriter(zmachine) save_data = writer.generate_save_data() - # Corrupt all dynamic memory for i in range(0x100, 0x200): current_mem[i] = 0x00 parser = QuetzalParser(zmachine) parser.load_from_bytes(save_data) - # Verify all bytes restored correctly for i in range(0x100, 0x200): if i == 0x150: assert current_mem[i] == 0xFF @@ -248,7 +249,7 @@ class TestQuetzalRoundTrip: from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter from mudlib.zmachine.zmemory import ZMemory - from mudlib.zmachine.zstackmanager import ZStackManager + from mudlib.zmachine.zstackmanager import ZRoutine, ZStackManager story_data = bytearray(8192) story_data[0] = 3 @@ -274,16 +275,13 @@ class TestQuetzalRoundTrip: zmachine._cpu._program_counter = 0x0800 zmachine._opdecoder = Mock() - # Save with empty stack writer = QuetzalWriter(zmachine) save_data = writer.generate_save_data() - # Add a dummy frame - from mudlib.zmachine.zstackmanager import ZRoutine - + # Add a dummy frame to verify it gets cleared dummy = ZRoutine( start_addr=0x5000, - return_addr=0x1000, + return_addr=1, zmem=current_mem, args=[], local_vars=[0x9999], @@ -291,11 +289,9 @@ class TestQuetzalRoundTrip: ) stack_manager.push_routine(dummy) - # Restore parser = QuetzalParser(zmachine) parser.load_from_bytes(save_data) - # Should have only bottom sentinel (get fresh reference) restored_stack = zmachine._stackmanager assert len(restored_stack._call_stack) == 1 assert current_mem[0x100] == 0x42 diff --git a/tests/test_quetzal_stks.py b/tests/test_quetzal_stks.py index f4790a4..67f6192 100644 --- a/tests/test_quetzal_stks.py +++ b/tests/test_quetzal_stks.py @@ -2,8 +2,8 @@ Tests for QuetzalWriter._generate_stks_chunk() serialization. The Stks chunk serializes the Z-machine call stack. Each frame has: -- Bytes 0-2: return_pc (24-bit big-endian) -- Byte 3: flags (bits 0-3 = num local vars) +- Bytes 0-2: return_pc (24-bit big-endian) — caller's resume PC +- Byte 3: flags (bits 0-3 = num local vars, bit 4 = discard result) - Byte 4: varnum (which variable gets return value) - Byte 5: argflag (bitmask of supplied arguments) - Bytes 6-7: eval_stack_size (16-bit big-endian) @@ -11,6 +11,10 @@ The Stks chunk serializes the Z-machine call stack. Each frame has: - Next (eval_stack_size * 2) bytes: evaluation stack values All multi-byte values are big-endian. Bottom frame has return_pc=0. + +Field mapping to runtime: +- return_pc for frame N → stored as program_counter on frame N-1 (the caller) +- varnum → stored as return_addr on the frame (the store variable) """ from unittest import TestCase @@ -49,10 +53,14 @@ class QuetzalStksTests(TestCase): def test_single_frame_serialization(self): """Single routine frame should serialize correctly.""" - # Create a routine with known values + # Set up caller resume PC on ZStackBottom + sentinel = self.zmachine._stackmanager._call_stack[0] + sentinel.program_counter = 0x4200 + + # Create a routine with return_addr = store variable (varnum 5) routine = ZRoutine( start_addr=0x5000, - return_addr=0x4200, + return_addr=5, zmem=self._mem, args=[], local_vars=[0x1234, 0x5678, 0xABCD], @@ -62,21 +70,15 @@ class QuetzalStksTests(TestCase): chunk = self.writer._generate_stks_chunk() - # Expected bytes for this frame: - # return_pc (0x4200): 0x00, 0x42, 0x00 - # flags (3 local vars): 0x03 - # varnum (0): 0x00 - # argflag (0): 0x00 - # eval_stack_size (2): 0x00, 0x02 - # local_vars: 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD - # stack: 0x11, 0x11, 0x22, 0x22 + # return_pc comes from ZStackBottom.program_counter (0x4200) + # varnum is frame.return_addr (5) expected = bytes( [ 0x00, 0x42, - 0x00, # return_pc + 0x00, # return_pc (from caller's program_counter) 0x03, # flags (3 local vars) - 0x00, # varnum + 0x05, # varnum (store variable) 0x00, # argflag 0x00, 0x02, # eval_stack_size = 2 @@ -96,21 +98,26 @@ class QuetzalStksTests(TestCase): def test_multiple_frames_serialization(self): """Multiple nested frames should serialize in order.""" - # Frame 1: outer routine + sentinel = self.zmachine._stackmanager._call_stack[0] + sentinel.program_counter = 0 # main routine has no caller + + # Frame 1: outer routine (varnum=0 means push result to stack) routine1 = ZRoutine( start_addr=0x1000, - return_addr=0, # bottom frame + return_addr=0, zmem=self._mem, args=[], local_vars=[0x0001], stack=[], ) self.zmachine._stackmanager.push_routine(routine1) + # Set routine1's resume PC (where to go after frame2 returns) + routine1.program_counter = 0x1050 - # Frame 2: inner routine + # Frame 2: inner routine (varnum=3) routine2 = ZRoutine( start_addr=0x2000, - return_addr=0x1050, + return_addr=3, zmem=self._mem, args=[], local_vars=[0x0002, 0x0003], @@ -120,15 +127,14 @@ class QuetzalStksTests(TestCase): chunk = self.writer._generate_stks_chunk() - # Expected: frame1 bytes + frame2 bytes - # Frame 1: return_pc=0, 1 local var, 0 stack + # Frame 1: return_pc from sentinel.pc (0), varnum=0 frame1 = bytes( [ 0x00, 0x00, - 0x00, # return_pc = 0 + 0x00, # return_pc = 0 (from sentinel) 0x01, # flags (1 local var) - 0x00, # varnum + 0x00, # varnum = 0 (push to stack) 0x00, # argflag 0x00, 0x00, # eval_stack_size = 0 @@ -136,14 +142,14 @@ class QuetzalStksTests(TestCase): 0x01, # local_vars[0] ] ) - # Frame 2: return_pc=0x1050, 2 local vars, 1 stack + # Frame 2: return_pc from routine1.pc (0x1050), varnum=3 frame2 = bytes( [ 0x00, 0x10, - 0x50, # return_pc + 0x50, # return_pc (from routine1.program_counter) 0x02, # flags (2 local vars) - 0x00, # varnum + 0x03, # varnum = 3 0x00, # argflag 0x00, 0x01, # eval_stack_size = 1 @@ -160,9 +166,12 @@ class QuetzalStksTests(TestCase): def test_frame_with_no_locals_or_stack(self): """Frame with no local vars or stack values should serialize correctly.""" + sentinel = self.zmachine._stackmanager._call_stack[0] + sentinel.program_counter = 0x2500 + routine = ZRoutine( start_addr=0x3000, - return_addr=0x2500, + return_addr=1, zmem=self._mem, args=[], local_vars=[], @@ -176,9 +185,9 @@ class QuetzalStksTests(TestCase): [ 0x00, 0x25, - 0x00, # return_pc + 0x00, # return_pc (from sentinel.program_counter) 0x00, # flags (0 local vars) - 0x00, # varnum + 0x01, # varnum = 1 0x00, # argflag 0x00, 0x00, # eval_stack_size = 0 @@ -186,10 +195,49 @@ class QuetzalStksTests(TestCase): ) self.assertEqual(chunk, expected) + def test_discard_result_sets_flags_bit4(self): + """Frame with return_addr=None should set bit 4 in flags byte.""" + sentinel = self.zmachine._stackmanager._call_stack[0] + sentinel.program_counter = 0 + + routine = ZRoutine( + start_addr=0x1000, + return_addr=None, + zmem=self._mem, + args=[], + local_vars=[0x0001, 0x0002], + stack=[], + ) + self.zmachine._stackmanager.push_routine(routine) + + chunk = self.writer._generate_stks_chunk() + + # flags = 0x02 (2 locals) | 0x10 (discard) = 0x12 + expected = bytes( + [ + 0x00, + 0x00, + 0x00, # return_pc + 0x12, # flags (2 locals + discard bit) + 0x00, # varnum (0 when discarding) + 0x00, # argflag + 0x00, + 0x00, # eval_stack_size = 0 + 0x00, + 0x01, # local_vars[0] + 0x00, + 0x02, # local_vars[1] + ] + ) + self.assertEqual(chunk, expected) + def test_round_trip_with_parser(self): """Generated stks bytes should parse back identically.""" from mudlib.zmachine.quetzal import QuetzalParser + sentinel = self.zmachine._stackmanager._call_stack[0] + sentinel.program_counter = 0 # main routine caller + # Create a complex stack state routine1 = ZRoutine( start_addr=0x1000, @@ -200,10 +248,11 @@ class QuetzalStksTests(TestCase): stack=[0xAAAA, 0xBBBB], ) self.zmachine._stackmanager.push_routine(routine1) + routine1.program_counter = 0x1234 # resume PC for after routine2 routine2 = ZRoutine( start_addr=0x2000, - return_addr=0x1234, + return_addr=5, zmem=self._mem, args=[], local_vars=[0x4444], @@ -220,41 +269,38 @@ class QuetzalStksTests(TestCase): # Verify the stack was reconstructed correctly # Parser creates a new stack manager, skip bottom sentinel - frames = self.zmachine._stackmanager._call_stack[1:] + call_stack = self.zmachine._stackmanager._call_stack + frames = call_stack[1:] self.assertEqual(len(frames), 2) - # Check frame 1 + # Check frame 1: return_addr = varnum, caller PC on sentinel assert isinstance(frames[0], ZRoutine) self.assertEqual(frames[0].return_addr, 0) self.assertEqual(frames[0].local_vars[:3], [0x1111, 0x2222, 0x3333]) self.assertEqual(frames[0].stack, [0xAAAA, 0xBBBB]) + # Sentinel should have frame1's return_pc (0) + self.assertEqual(call_stack[0].program_counter, 0) - # Check frame 2 + # Check frame 2: return_addr = varnum, caller PC on frame1 assert isinstance(frames[1], ZRoutine) - self.assertEqual(frames[1].return_addr, 0x1234) + self.assertEqual(frames[1].return_addr, 5) self.assertEqual(frames[1].local_vars[:1], [0x4444]) self.assertEqual(frames[1].stack, [0xCCCC, 0xDDDD, 0xEEEE]) + # Frame1 should have frame2's return_pc (0x1234) + self.assertEqual(frames[0].program_counter, 0x1234) - def test_parse_return_pc_third_byte(self): - """Parser should correctly read all 3 bytes of return_pc (regression test).""" + def test_parse_return_pc_goes_to_caller(self): + """Parser should put return_pc on the caller frame's program_counter.""" from mudlib.zmachine.quetzal import QuetzalParser # Construct a minimal stack frame with return_pc=0x123456 - # where the third byte (0x56) is non-zero and would be wrong if skipped. - # - # Stack frame format: - # - Bytes 0-2: return_pc (0x12, 0x34, 0x56) - # - Byte 3: flags (0 local vars) - # - Byte 4: varnum (0) - # - Byte 5: argflag (0) - # - Bytes 6-7: eval_stack_size (0) stks_bytes = bytes( [ 0x12, 0x34, 0x56, # return_pc = 0x123456 0x00, # flags (0 local vars) - 0x00, # varnum + 0x07, # varnum = 7 0x00, # argflag 0x00, 0x00, # eval_stack_size = 0 @@ -264,18 +310,46 @@ class QuetzalStksTests(TestCase): parser = QuetzalParser(self.zmachine) parser._parse_stks(stks_bytes) - # The parser should have reconstructed the stack with correct return_pc - frames = self.zmachine._stackmanager._call_stack[1:] # skip sentinel + call_stack = self.zmachine._stackmanager._call_stack + frames = call_stack[1:] # skip sentinel self.assertEqual(len(frames), 1) - # If bug exists, it would read bytes[0], bytes[1], bytes[3] = 0x12, 0x34, 0x00 - # giving return_pc = 0x123400 instead of 0x123456 + + # return_pc goes to caller (sentinel) program_counter + self.assertEqual(call_stack[0].program_counter, 0x123456) + # varnum goes to frame's return_addr assert isinstance(frames[0], ZRoutine) - self.assertEqual( - frames[0].return_addr, - 0x123456, - "Parser should read bytes[ptr+2], not bytes[ptr+3]", + self.assertEqual(frames[0].return_addr, 7) + + def test_parse_discard_bit_restores_none(self): + """Parser should set return_addr=None when flags bit 4 is set.""" + from mudlib.zmachine.quetzal import QuetzalParser + + # flags = 0x12 = 2 locals + discard bit + stks_bytes = bytes( + [ + 0x00, + 0x00, + 0x00, # return_pc = 0 + 0x12, # flags (2 locals + discard) + 0x00, # varnum (ignored when discarding) + 0x00, # argflag + 0x00, + 0x00, # eval_stack_size = 0 + 0x00, + 0x01, # local_vars[0] + 0x00, + 0x02, # local_vars[1] + ] ) + parser = QuetzalParser(self.zmachine) + parser._parse_stks(stks_bytes) + + frames = self.zmachine._stackmanager._call_stack[1:] + self.assertEqual(len(frames), 1) + self.assertIsNone(frames[0].return_addr) + self.assertEqual(frames[0].local_vars[:2], [1, 2]) + @property def _mem(self): """Helper to get mock memory.""" diff --git a/tests/test_quetzal_writer.py b/tests/test_quetzal_writer.py index 6acccd0..d57c837 100644 --- a/tests/test_quetzal_writer.py +++ b/tests/test_quetzal_writer.py @@ -1,4 +1,9 @@ -"""Tests for QuetzalWriter Stks chunk generation.""" +"""Tests for QuetzalWriter Stks chunk generation. + +Field mapping: +- Quetzal return_pc → previous frame's program_counter (caller resume PC) +- Quetzal varnum → frame.return_addr (store variable for return value) +""" class TestStksChunkGeneration: @@ -11,7 +16,6 @@ class TestStksChunkGeneration: from mudlib.zmachine.quetzal import QuetzalWriter from mudlib.zmachine.zstackmanager import ZStackManager - # Setup: create a mock zmachine with only the bottom sentinel zmachine = Mock() zmachine._mem = Mock() zmachine._mem.version = 5 @@ -21,7 +25,6 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Empty stack should serialize to empty bytes (no frames) assert result == b"" def test_single_frame_no_locals_no_stack(self): @@ -36,10 +39,12 @@ class TestStksChunkGeneration: zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) - # Create a routine with no locals and no stack values + # Set caller resume PC on sentinel + stack_manager._call_stack[0].program_counter = 0x1234 + routine = ZRoutine( start_addr=0x5000, - return_addr=0x1234, + return_addr=7, # varnum: store to local var 6 zmem=zmachine._mem, args=[], local_vars=[], @@ -51,19 +56,13 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Expected format: - # Bytes 0-2: return_pc = 0x1234 (24-bit big-endian) - # Byte 3: flags = 0 (0 local vars) - # Byte 4: varnum = 0 (which variable gets return value) - # Byte 5: argflag = 0 - # Bytes 6-7: eval_stack_size = 0 expected = bytes( [ 0x00, 0x12, - 0x34, # return_pc (24-bit) + 0x34, # return_pc (from sentinel.program_counter) 0x00, # flags (0 locals) - 0x00, # varnum + 0x07, # varnum (frame.return_addr) 0x00, # argflag 0x00, 0x00, # eval_stack_size = 0 @@ -83,11 +82,11 @@ class TestStksChunkGeneration: zmachine._mem = Mock() zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) + stack_manager._call_stack[0].program_counter = 0x2000 - # Create a routine with 3 local variables routine = ZRoutine( start_addr=0x5000, - return_addr=0x2000, + return_addr=0x10, # varnum: store to global var 0x10 zmem=zmachine._mem, args=[], local_vars=[0x1111, 0x2222, 0x3333], @@ -99,20 +98,13 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Expected format: - # Bytes 0-2: return_pc = 0x2000 - # Byte 3: flags = 3 (3 local vars in bits 0-3) - # Byte 4: varnum = 0x00 - # Byte 5: argflag = 0 - # Bytes 6-7: eval_stack_size = 0 - # Bytes 8-13: three local vars (0x1111, 0x2222, 0x3333) expected = bytes( [ 0x00, 0x20, - 0x00, # return_pc + 0x00, # return_pc (from sentinel.program_counter) 0x03, # flags (3 locals) - 0x00, # varnum + 0x10, # varnum (frame.return_addr) 0x00, # argflag 0x00, 0x00, # eval_stack_size = 0 @@ -138,11 +130,11 @@ class TestStksChunkGeneration: zmachine._mem = Mock() zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) + stack_manager._call_stack[0].program_counter = 0x3000 - # Create a routine with stack values but no locals routine = ZRoutine( start_addr=0x5000, - return_addr=0x3000, + return_addr=0, # varnum 0: push result to eval stack zmem=zmachine._mem, args=[], local_vars=[], @@ -154,20 +146,13 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Expected format: - # Bytes 0-2: return_pc = 0x3000 - # Byte 3: flags = 0 (0 locals) - # Byte 4: varnum = 0x00 - # Byte 5: argflag = 0 - # Bytes 6-7: eval_stack_size = 2 - # Bytes 8-11: two stack values expected = bytes( [ 0x00, 0x30, 0x00, # return_pc 0x00, # flags (0 locals) - 0x00, # varnum + 0x00, # varnum (push to stack) 0x00, # argflag 0x00, 0x02, # eval_stack_size = 2 @@ -191,11 +176,11 @@ class TestStksChunkGeneration: zmachine._mem = Mock() zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) + stack_manager._call_stack[0].program_counter = 0x4567 - # Create a routine with 2 locals and 3 stack values routine = ZRoutine( start_addr=0x5000, - return_addr=0x4567, + return_addr=2, # varnum: store to local var 1 zmem=zmachine._mem, args=[], local_vars=[0x0001, 0x0002], @@ -211,9 +196,9 @@ class TestStksChunkGeneration: [ 0x00, 0x45, - 0x67, # return_pc + 0x67, # return_pc (from sentinel.program_counter) 0x02, # flags (2 locals) - 0x00, # varnum + 0x02, # varnum 0x00, # argflag 0x00, 0x03, # eval_stack_size = 3 @@ -244,21 +229,24 @@ class TestStksChunkGeneration: zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) - # Create first routine + # Sentinel has return PC for frame 1 + stack_manager._call_stack[0].program_counter = 0x1000 + routine1 = ZRoutine( start_addr=0x5000, - return_addr=0x1000, + return_addr=0, # varnum: push to stack zmem=zmachine._mem, args=[], local_vars=[0xAAAA], stack=[0xBBBB], ) stack_manager.push_routine(routine1) + # Frame1 has return PC for frame 2 + routine1.program_counter = 0x2000 - # Create second routine (nested) routine2 = ZRoutine( start_addr=0x6000, - return_addr=0x2000, + return_addr=5, # varnum: store to local var 4 zmem=zmachine._mem, args=[], local_vars=[0xCCCC], @@ -271,10 +259,9 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Expected: two frames concatenated expected = bytes( [ - # Frame 1 + # Frame 1: return_pc from sentinel (0x1000), varnum=0 0x00, 0x10, 0x00, # return_pc @@ -287,12 +274,12 @@ class TestStksChunkGeneration: 0xAA, # local_var[0] 0xBB, 0xBB, # stack[0] - # Frame 2 + # Frame 2: return_pc from routine1 (0x2000), varnum=5 0x00, 0x20, 0x00, # return_pc 0x01, # flags (1 local) - 0x00, # varnum + 0x05, # varnum 0x00, # argflag 0x00, 0x01, # eval_stack_size = 1 @@ -316,11 +303,12 @@ class TestStksChunkGeneration: zmachine._mem = Mock() zmachine._mem.version = 5 stack_manager = ZStackManager(zmachine._mem) + # Sentinel PC = 0 (main routine has no caller) + stack_manager._call_stack[0].program_counter = 0 - # Create a routine with return_addr=0 (main routine) routine = ZRoutine( start_addr=0x5000, - return_addr=0x0000, + return_addr=0, # varnum: push to stack zmem=zmachine._mem, args=[], local_vars=[0x1234], @@ -332,12 +320,11 @@ class TestStksChunkGeneration: writer = QuetzalWriter(zmachine) result = writer._generate_stks_chunk() - # Expected format with return_pc = 0 expected = bytes( [ 0x00, 0x00, - 0x00, # return_pc = 0 (main routine) + 0x00, # return_pc = 0 0x01, # flags (1 local) 0x00, # varnum 0x00, # argflag @@ -365,22 +352,24 @@ class TestStksRoundTrip: zmachine._mem = Mock() zmachine._mem.version = 5 - # Create original stack with multiple frames original_stack = ZStackManager(zmachine._mem) + # Sentinel has return PC for frame 1 + original_stack._call_stack[0].program_counter = 0 routine1 = ZRoutine( start_addr=0x5000, - return_addr=0x1234, + return_addr=5, # varnum: store to local var 4 zmem=zmachine._mem, args=[], local_vars=[0x0001, 0x0002, 0x0003], stack=[0x1111, 0x2222], ) original_stack.push_routine(routine1) + routine1.program_counter = 0x5678 # resume PC for after routine2 routine2 = ZRoutine( start_addr=0x6000, - return_addr=0x5678, + return_addr=3, # varnum: store to local var 2 zmem=zmachine._mem, args=[], local_vars=[0xAAAA], @@ -398,20 +387,20 @@ class TestStksRoundTrip: parser = QuetzalParser(zmachine) parser._parse_stks(stks_data) - # Verify the deserialized stack matches restored_stack = zmachine._stackmanager - # Should have 2 frames plus the bottom sentinel assert len(restored_stack._call_stack) == 3 - # Check frame 1 + # Check frame 1: return_addr is varnum frame1 = restored_stack._call_stack[1] - assert frame1.return_addr == 0x1234 + assert frame1.return_addr == 5 assert frame1.local_vars[:3] == [0x0001, 0x0002, 0x0003] assert frame1.stack == [0x1111, 0x2222] + # Frame1's program_counter was set from frame2's return_pc + assert frame1.program_counter == 0x5678 # Check frame 2 frame2 = restored_stack._call_stack[2] - assert frame2.return_addr == 0x5678 + assert frame2.return_addr == 3 assert frame2.local_vars[:1] == [0xAAAA] assert frame2.stack == [0xBBBB, 0xCCCC, 0xDDDD]