mud/src/mudlib/zmachine/trivialzui.py
Jared Miller 311a67e80a
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.
2026-02-09 21:36:30 -05:00

405 lines
12 KiB
Python

#
# A trivial user interface for a Z-Machine that uses (mostly) stdio for
# everything and supports little to no optional features.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# TODO: There are a few edge-cases in this UI implementation in
# regards to word-wrapping. For example, if keyboard input doesn't
# terminate in a newline, then word-wrapping can be temporarily thrown
# off; the text I/O performed by the audio and filesystem doesn't
# really communicate with the screen object, which means that
# operations performed by them can temporarily throw off word-wrapping
# as well.
import sys
from functools import reduce
from . import zaudio, zfilesystem, zscreen, zstream, zui
from .zlogging import log
class TrivialAudio(zaudio.ZAudio):
def __init__(self):
zaudio.ZAudio.__init__(self)
self.features = {
"has_more_than_a_bleep": False,
}
def play_bleep(self, bleep_type):
if bleep_type == zaudio.BLEEP_HIGH:
sys.stdout.write("AUDIO: high-pitched bleep\n")
elif bleep_type == zaudio.BLEEP_LOW:
sys.stdout.write("AUDIO: low-pitched bleep\n")
else:
raise AssertionError(f"Invalid bleep_type: {str(bleep_type)}")
class TrivialScreen(zscreen.ZScreen):
def __init__(self):
zscreen.ZScreen.__init__(self)
self.__styleIsAllUppercase = False
# Current column of text being printed.
self.__curr_column = 0
# Number of rows displayed since we last took input; needed to
# keep track of when we need to display the [MORE] prompt.
self.__rows_since_last_input = 0
def split_window(self, height):
log(f"TODO: split window here to height {height}")
def select_window(self, window):
log(f"TODO: select window {window} here")
def set_cursor_position(self, x, y):
log(f"TODO: set cursor position to ({x},{y}) here")
def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT):
for _row in range(self._rows):
sys.stdout.write("\n")
self.__curr_column = 0
self.__rows_since_last_input = 0
def set_font(self, font_number):
if font_number == zscreen.FONT_NORMAL:
return font_number
else:
# We aren't going to support anything but the normal font.
return None
def set_text_style(self, style):
# We're pretty much limited to stdio here; even if we might be
# able to use terminal hackery under Unix, supporting styled text
# in a Windows console is problematic [1]. The closest thing we
# can do is have our "bold" style be all-caps, so we'll do that.
#
# [1] http://mail.python.org/pipermail/tutor/2004-February/028474.html
if style == zscreen.STYLE_BOLD:
self.__styleIsAllUppercase = True
else:
self.__styleIsAllUppercase = False
def __show_more_prompt(self):
"""Display a [MORE] prompt, wait for the user to press a key, and
then erase the [MORE] prompt, leaving the cursor at the same
position that it was at before the call was made."""
assert self.__curr_column == 0, "Precondition: current column must be zero."
MORE_STRING = "[MORE]"
sys.stdout.write(MORE_STRING)
_read_char()
# Erase the [MORE] prompt and reset the cursor position.
sys.stdout.write(f"\r{' ' * len(MORE_STRING)}\r")
self.__rows_since_last_input = 0
def on_input_occurred(self, newline_occurred=False):
"""Callback function that should be called whenever keyboard input
has occurred; this is so we can keep track of when we need to
display a [MORE] prompt."""
self.__rows_since_last_input = 0
if newline_occurred:
self.__curr_column = 0
def __unbuffered_write(self, string):
"""Write the given string, inserting newlines at the end of
columns as appropriate, and displaying [MORE] prompts when
appropriate. This function does not perform word-wrapping."""
for char in string:
newline_printed = False
sys.stdout.write(char)
sys.stdout.flush()
if char == "\n":
newline_printed = True
else:
self.__curr_column += 1
if self.__curr_column == self._columns:
sys.stdout.write("\n")
newline_printed = True
if newline_printed:
self.__rows_since_last_input += 1
self.__curr_column = 0
if (
self.__rows_since_last_input == self._rows
and self._rows != zscreen.INFINITE_ROWS
):
self.__show_more_prompt()
def write(self, string):
if self.__styleIsAllUppercase:
# Apply our fake "bold" transformation.
string = string.upper()
if self.buffer_mode:
# This is a hack to get words to wrap properly, based on our
# current cursor position.
# First, add whitespace padding up to the column of text that
# we're at.
string = (" " * self.__curr_column) + string
# Next, word wrap our current string.
string = _word_wrap(string, self._columns - 1)
# Now remove the whitespace padding.
string = string[self.__curr_column :]
self.__unbuffered_write(string)
class TrivialKeyboardInputStream(zstream.ZInputStream):
def __init__(self, screen):
zstream.ZInputStream.__init__(self)
self.__screen = screen
self.features = {
"has_timed_input": False,
}
def read_line(
self,
original_text=None,
max_length=0,
terminating_characters=None,
timed_input_routine=None,
timed_input_interval=0,
):
result = _read_line(original_text, terminating_characters)
if max_length > 0:
result = result[:max_length]
# TODO: The value of 'newline_occurred' here is not accurate,
# because terminating_characters may include characters other than
# carriage return.
self.__screen.on_input_occurred(newline_occurred=True)
return str(result)
def read_char(self, timed_input_routine=None, timed_input_interval=0):
result = _read_char()
self.__screen.on_input_occurred()
return ord(result)
class TrivialFilesystem(zfilesystem.ZFilesystem):
def __report_io_error(self, exception):
sys.stdout.write(f"FILESYSTEM: An error occurred: {exception}\n")
def save_game(self, data, suggested_filename=None):
success = False
sys.stdout.write("Enter a name for the saved game (hit enter to cancel): ")
filename = _read_line(suggested_filename)
if filename:
try:
with open(filename, "wb") as file_obj:
file_obj.write(data)
success = True
except OSError as e:
self.__report_io_error(e)
return success
def restore_game(self):
data = None
sys.stdout.write(
"Enter the name of the saved game to restore (hit enter to cancel): "
)
filename = _read_line()
if filename:
try:
with open(filename, "rb") as file_obj:
data = file_obj.read()
except OSError as e:
self.__report_io_error(e)
return data
def open_transcript_file_for_writing(self):
file_obj = None
sys.stdout.write("Enter a name for the transcript file (hit enter to cancel): ")
filename = _read_line()
if filename:
try:
file_obj = open(filename, "w") # noqa: SIM115
except OSError as e:
self.__report_io_error(e)
return file_obj
def open_transcript_file_for_reading(self):
file_obj = None
sys.stdout.write(
"Enter the name of the transcript file to read (hit enter to cancel): "
)
filename = _read_line()
if filename:
try:
file_obj = open(filename) # noqa: SIM115
except OSError as e:
self.__report_io_error(e)
return file_obj
def create_zui():
"""Creates and returns a ZUI instance representing a trivial user
interface."""
audio = TrivialAudio()
screen = TrivialScreen()
keyboard_input = TrivialKeyboardInputStream(screen)
filesystem = TrivialFilesystem()
return zui.ZUI(audio, screen, keyboard_input, filesystem)
# Keyboard input functions
_INTERRUPT_CHAR = chr(3)
_BACKSPACE_CHAR = chr(8)
_DELETE_CHAR = chr(127)
def _win32_read_char():
"""Win32-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
import msvcrt
return str(msvcrt.getch()) # type: ignore[possibly-missing-attribute]
def _unix_read_char():
"""Unix-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
# This code was excised from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892
import termios
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())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return str(ch)
def _read_char():
"""Reads a character of input from the keyboard and returns it
without printing it to the screen."""
if sys.platform == "win32":
_platform_read_char = _win32_read_char
else:
# We're not running on Windows, so assume we're running on Unix.
_platform_read_char = _unix_read_char
char = _platform_read_char()
if char == _INTERRUPT_CHAR:
raise KeyboardInterrupt()
else:
return char
def _read_line(original_text=None, terminating_characters=None):
"""Reads a line of input with the given unicode string of original
text, which is editable, and the given unicode string of terminating
characters (used to terminate text input). By default,
terminating_characters is a string containing the carriage return
character ('\r')."""
if original_text is None:
original_text = ""
if not terminating_characters:
terminating_characters = "\r"
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
finished = False
while not finished:
char = _read_char()
if char in (_BACKSPACE_CHAR, _DELETE_CHAR):
if chars_entered > 0:
chars_entered -= 1
string = string[:-1]
else:
continue
elif char in terminating_characters:
finished = True
else:
string += char
chars_entered += 1
if char == "\r":
char_to_print = "\n"
elif char == _BACKSPACE_CHAR:
char_to_print = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}"
else:
char_to_print = char
sys.stdout.write(char_to_print)
return string
# Word wrapping helper function
def _word_wrap(text, width):
"""
A word-wrap function that preserves existing line breaks
and most spaces in the text. Expects that existing line
breaks are posix newlines (\n).
"""
# This code was taken from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/148061
return reduce(
lambda line, word, width=width: "{}{}{}".format(
line,
" \n"[
(
len(line) - line.rfind("\n") - 1 + len(word.split("\n", 1)[0])
>= width
)
],
word,
),
text.split(" "),
)