Fix bugs found running Zork 1 through hybrid interpreter

Spec fixes: implement op_test (bitwise AND branch), add missing branch
to op_get_child, handle call-to-address-0 as no-op in op_call/_call.
I/O fixes: correct keyboard API (keyboard_input.read_line), non-TTY
fallbacks in trivialzui, stdout flush for immediate output. Graceful
handling of unmapped ZSCII characters. Add instruction trace buffer
for debugging.
This commit is contained in:
Jared Miller 2026-02-09 21:36:30 -05:00
parent 8d749fbb7e
commit 311a67e80a
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 75 additions and 14 deletions

View file

@ -115,6 +115,7 @@ class TrivialScreen(zscreen.ZScreen):
for char in string:
newline_printed = False
sys.stdout.write(char)
sys.stdout.flush()
if char == "\n":
newline_printed = True
@ -292,6 +293,11 @@ def _unix_read_char():
import tty
fd = sys.stdin.fileno()
# Check if stdin is a TTY - if not, use simple read
if not sys.stdin.isatty():
return sys.stdin.read(1)
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
@ -333,6 +339,14 @@ def _read_line(original_text=None, terminating_characters=None):
assert isinstance(original_text, str)
assert isinstance(terminating_characters, str)
# If stdin is not a TTY, use simple line reading
if not sys.stdin.isatty():
line = sys.stdin.readline()
if not line: # EOF
raise EOFError("End of input")
# Strip newline but keep the content
return line.rstrip("\n\r")
chars_entered = len(original_text)
sys.stdout.write(original_text)
string = original_text

View file

@ -8,6 +8,7 @@
import random
import time
from collections import deque
from . import bitfield, zopdecoder, zscreen
from .zlogging import log, log_disasm
@ -53,6 +54,7 @@ class ZCpu:
self._streammanager = zstreammanager
self._ui = zui
self._lexer = zlexer
self._trace = deque(maxlen=20)
def _get_handler(self, opcode_class, opcode_number):
try:
@ -60,6 +62,8 @@ class ZCpu:
except IndexError:
opcode_decl = None
if not opcode_decl:
cls, num = opcode_class, opcode_number
print(f"DEBUG: Illegal opcode class={cls} num={num}")
raise ZCpuIllegalInstruction
# If the opcode declaration is a sequence, we have extra
@ -80,6 +84,8 @@ class ZCpu:
elif opcode_decl[1] <= self._memory.version:
opcode_func = opcode_decl[0]
else:
cls, num = opcode_class, opcode_number
print(f"DEBUG: Illegal opcode class={cls} num={num}")
raise ZCpuIllegalInstruction
# The following is a hack, based on our policy of only
@ -143,6 +149,11 @@ class ZCpu:
"""Set up a function call to the given routine address,
passing the given arguments. If store_return_value is True,
the routine's return value will be stored."""
# Calling address 0 is a no-op that returns false (0).
if routine_address == 0:
if store_return_value:
self._write_result(0)
return
addr = self._memory.packed_address(routine_address)
if store_return_value:
return_value = self._opdecoder.get_store_address()
@ -168,12 +179,34 @@ class ZCpu:
log(f"Jump to offset {branch_offset:+d}")
self._opdecoder.program_counter += branch_offset - 2
def _dump_trace(self):
"""Print the last N instructions for debugging."""
print("\n=== INSTRUCTION TRACE (last 20) ===")
for entry in self._trace:
print(entry)
print("===================================\n")
def step(self):
"""Execute a single instruction. Returns True if execution should continue."""
current_pc = self._opdecoder.program_counter
log(f"Reading next opcode at address {current_pc:x}")
(opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction()
# Record raw byte at this PC for trace
cls_str = zopdecoder.OPCODE_STRINGS.get(opcode_class, f"?{opcode_class}")
trace_entry = f" {current_pc:06x} {cls_str}:{opcode_number:02x}"
try:
implemented, func = self._get_handler(opcode_class, opcode_number)
except ZCpuIllegalInstruction:
trace_entry += f" ILLEGAL (raw byte: {self._memory[current_pc]:02x})"
self._trace.append(trace_entry)
self._dump_trace()
raise
trace_entry += f" {func.__name__}({', '.join(str(x) for x in operands)})"
self._trace.append(trace_entry)
log_disasm(
current_pc,
zopdecoder.OPCODE_STRINGS[opcode_class],
@ -256,9 +289,9 @@ class ZCpu:
parent = self._objects.get_parent(obj1)
self._branch(parent == obj2)
def op_test(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_test(self, bitmap, flags):
"""Test if all bits in flags are set in bitmap, branch if true."""
self._branch((bitmap & flags) == flags)
def op_or(self, a, b):
"""Bitwise OR between the two arguments."""
@ -386,8 +419,10 @@ class ZCpu:
self._branch(sibling != 0)
def op_get_child(self, object_num):
"""Get and store the first child of the given object."""
self._write_result(self._objects.get_child(object_num))
"""Get first child of object, store it, branch if nonzero."""
child = self._objects.get_child(object_num)
self._write_result(child)
self._branch(child != 0)
def op_get_parent(self, object_num):
"""Get and store the parent of the given object."""
@ -574,6 +609,10 @@ class ZCpu:
# call in v1-3
def op_call(self, routine_addr, *args):
"""Call the routine r1, passing it any of r2, r3, r4 if defined."""
# Calling address 0 is a no-op that returns false (0).
if routine_addr == 0:
self._write_result(0)
return
addr = self._memory.packed_address(routine_addr)
return_addr = self._opdecoder.get_store_address()
current_addr = self._opdecoder.program_counter
@ -614,7 +653,7 @@ class ZCpu:
self.op_show_status()
# Read input from keyboard
text = self._ui.keyboard.readline()
text = self._ui.keyboard_input.read_line()
text = text.lower().strip("\n\r")
# Store in text buffer

View file

@ -442,7 +442,14 @@ class ZsciiTranslator:
try:
return self._output_table[index]
except KeyError:
raise IndexError("No such ZSCII character") from None
# Handle undefined ZSCII characters
# 0-31 (except 0, 10): control characters, return empty string
# 128-154, 252-254: undefined, return placeholder
# 155-251: extended characters, should have Unicode table but don't
if index < 32:
return ""
# For undefined or unmapped characters, return a placeholder
return "?"
def utoz(self, char):
"""Translate the given Unicode code into the corresponding

View file

@ -85,7 +85,8 @@ class MockUI:
def __init__(self):
self.screen = Mock()
self.screen.write = Mock()
self.keyboard = Mock()
self.keyboard_input = Mock()
self.keyboard_input.read_line = Mock()
class ZMachineOpcodeTests(TestCase):
@ -548,7 +549,7 @@ class ZMachineComplexOpcodeTests(TestCase):
self.memory[text_buffer_addr] = 20 # max length
# Mock keyboard input
self.ui.keyboard.readline = Mock(return_value="hello world\n")
self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")
# Call sread with text buffer only (no parse buffer)
self.cpu.op_sread(text_buffer_addr, 0)
@ -566,7 +567,7 @@ class ZMachineComplexOpcodeTests(TestCase):
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 5 # max length of 5
self.ui.keyboard.readline = Mock(return_value="hello world\n")
self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")
self.cpu.op_sread(text_buffer_addr, 0)
@ -581,7 +582,7 @@ class ZMachineComplexOpcodeTests(TestCase):
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard.readline = Mock(return_value="HELLO World\n")
self.ui.keyboard_input.read_line = Mock(return_value="HELLO World\n")
self.cpu.op_sread(text_buffer_addr, 0)
@ -594,7 +595,7 @@ class ZMachineComplexOpcodeTests(TestCase):
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard.readline = Mock(return_value="test\r\n")
self.ui.keyboard_input.read_line = Mock(return_value="test\r\n")
self.cpu.op_sread(text_buffer_addr, 0)
@ -607,7 +608,7 @@ class ZMachineComplexOpcodeTests(TestCase):
"""Test sread calls op_show_status for V3."""
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard.readline = Mock(return_value="test\n")
self.ui.keyboard_input.read_line = Mock(return_value="test\n")
# Track if show_status was called
call_count = [0]