Wire embedded z-machine interpreter into MUD mode stack

EmbeddedIFSession runs the hybrid interpreter in a daemon thread,
bridged to the async MUD loop via threading.Event synchronization.
.z3 files use the embedded path; other formats fall back to dfrotz.

- MUD ZUI components: MudScreen (buffered output), MudInputStream
  (thread-safe input), MudFilesystem (quetzal saves), NullAudio
- save/restore via QuetzalWriter/QuetzalParser and :: escape commands
- state inspection: get_location_name(), get_room_objects()
- error reporting for interpreter crashes
- fix quetzal parser bit slice bug: _parse_stks used [0:3] (3 bits,
  max 7 locals) instead of [0:4] (4 bits, max 15) — Zork uses 15
This commit is contained in:
Jared Miller 2026-02-10 11:18:16 -05:00
parent 5b7cb252b5
commit 7c1d1efcdb
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 336 additions and 6 deletions

View file

@ -3,6 +3,7 @@
import pathlib
from mudlib.commands import CommandDefinition, register
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.if_session import IFSession, broadcast_to_spectators
from mudlib.player import Player
@ -60,8 +61,20 @@ async def cmd_play(player: Player, args: str) -> None:
await player.send(msg)
return
# Create and start IF session
session = IFSession(player, str(story_path), game_name)
# Ensure story_path is a Path object (for mocking compatibility)
if not isinstance(story_path, pathlib.Path):
story_path = pathlib.Path(story_path)
# Use embedded interpreter for z3 files, dfrotz for others
if story_path.suffix == ".z3":
try:
session = EmbeddedIFSession(player, str(story_path), game_name)
except (FileNotFoundError, OSError) as e:
await player.send(f"error starting game: {e}\r\n")
return
else:
session = IFSession(player, str(story_path), game_name)
try:
intro = await session.start()
except FileNotFoundError:
@ -78,8 +91,8 @@ async def cmd_play(player: Player, args: str) -> None:
await player.send("(type ::help for escape commands)\r\n")
# Check for saved game
if session.save_path.exists():
# Check for saved game (both session types now support _do_restore)
if hasattr(session, "_do_restore") and session.save_path.exists():
await player.send("restoring saved game...\r\n")
restored_text = await session._do_restore()
if restored_text:

View file

@ -0,0 +1,166 @@
import asyncio
import logging
import re
import threading
from pathlib import Path
from typing import TYPE_CHECKING
from mudlib.if_session import IFResponse
from mudlib.zmachine.mud_ui import create_mud_ui
from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter
from mudlib.zmachine.zcpu import ZCpuQuit, ZCpuRestart
from mudlib.zmachine.zmachine import ZMachine
if TYPE_CHECKING:
from mudlib.player import Player
logger = logging.getLogger(__name__)
class EmbeddedIFSession:
"""Wraps z-machine interpreter for MUD integration."""
def __init__(self, player: "Player", story_path: str, game_name: str = ""):
self.player = player
self.story_path = story_path
self.game_name = game_name or Path(story_path).stem
self._data_dir = Path(__file__).resolve().parents[2] / "data"
self._thread: threading.Thread | None = None
self._done = False
self._error: str | None = None
story_bytes = Path(story_path).read_bytes()
save_path = self.save_path
self._ui, self._screen, self._keyboard = create_mud_ui(save_path)
self._zmachine = ZMachine(story_bytes, self._ui)
self._filesystem = self._ui.filesystem
@property
def save_path(self) -> Path:
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name)
return self._data_dir / "if_saves" / safe_name / f"{self.game_name}.qzl"
async def start(self) -> str:
self._thread = threading.Thread(target=self._run_interpreter, daemon=True)
self._thread.start()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._keyboard._waiting.wait)
intro = self._screen.flush()
return intro
async def handle_input(self, text: str) -> IFResponse:
if text.lower() == "::quit":
await self._do_save()
return IFResponse(output="game saved.", done=True)
if text.lower() == "::help":
help_text = """escape commands:
::quit - exit the game
::save - save game progress
::help - show this help"""
return IFResponse(output=help_text, done=False)
if text.lower() == "::save":
confirmation = await self._do_save()
return IFResponse(output=confirmation, done=False)
self._keyboard._waiting.clear()
self._keyboard.feed(text)
loop = asyncio.get_running_loop()
def wait_for_next_input():
while not self._done and not self._keyboard._waiting.is_set():
self._keyboard._waiting.wait(timeout=0.1)
await loop.run_in_executor(None, wait_for_next_input)
output = self._screen.flush()
if self._done and self._error:
output = f"{output}\r\n{self._error}" if output else self._error
return IFResponse(output=output, done=self._done)
async def stop(self):
self._done = True
if self._keyboard._waiting.is_set():
self._keyboard.feed("")
def _run_interpreter(self):
try:
self._zmachine.run()
except ZCpuQuit:
logger.debug("Interpreter quit normally")
except ZCpuRestart:
logger.debug("Interpreter restart requested")
except Exception as e:
logger.error(f"Interpreter crashed: {e}")
self._error = f"interpreter error: {e}"
finally:
self._done = True
self._keyboard._waiting.set()
async def _do_save(self) -> str:
try:
writer = QuetzalWriter(self._zmachine)
save_data = writer.generate_save_data()
success = self._filesystem.save_game(save_data)
if success:
return "saved."
return "error: save failed"
except Exception as e:
return f"error: save failed ({e})"
async def _do_restore(self) -> str:
"""Restore game state from disk. Returns status message."""
if not self.save_path.exists():
return ""
try:
save_data = self.save_path.read_bytes()
parser = QuetzalParser(self._zmachine)
parser.load_from_bytes(save_data)
# Flush stale intro text from screen buffer
self._screen.flush()
# Feed a blank line to continue execution from restored state
self._keyboard._waiting.clear()
self._keyboard.feed("")
# Wait for interpreter to process and generate output
loop = asyncio.get_running_loop()
def wait_for_output():
while not self._done and not self._keyboard._waiting.is_set():
self._keyboard._waiting.wait(timeout=0.1)
await loop.run_in_executor(None, wait_for_output)
# Get the output from restored state
output = self._screen.flush()
return f"restored.\r\n{output}" if output else "restored."
except Exception as e:
logger.debug(f"Restore failed: {e}")
return ""
def get_location_name(self) -> str | None:
try:
location_obj = self._zmachine._mem.read_global(0)
if location_obj == 0:
return None
return self._zmachine._objectparser.get_shortname(location_obj)
except Exception:
return None
def get_room_objects(self) -> list[str]:
try:
location_obj = self._zmachine._mem.read_global(0)
if location_obj == 0:
return []
objects = []
child = self._zmachine._objectparser.get_child(location_obj)
while child != 0:
name = self._zmachine._objectparser.get_shortname(child)
objects.append(name)
child = self._zmachine._objectparser.get_sibling(child)
return objects
except Exception:
return []

View file

@ -10,6 +10,7 @@ from mudlib.entity import Entity
if TYPE_CHECKING:
from mudlib.editor import Editor
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.if_session import IFSession
@ -23,7 +24,7 @@ class Player(Entity):
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
editor: Editor | None = None
if_session: IFSession | None = None
if_session: IFSession | EmbeddedIFSession | None = None
@property
def mode(self) -> str:

View file

@ -0,0 +1,150 @@
import logging
import queue
import threading
from pathlib import Path
from . import zaudio, zfilesystem, zscreen, zstream, zui
logger = logging.getLogger(__name__)
class NullAudio(zaudio.ZAudio):
def __init__(self):
super().__init__()
self.features = {"has_more_than_a_bleep": False}
def play_bleep(self, bleep_type):
pass
class MudScreen(zscreen.ZScreen):
def __init__(self):
super().__init__()
self._buffer = []
self._columns = 80
self._rows = zscreen.INFINITE_ROWS
self.features = {
"has_status_line": False,
"has_upper_window": False,
"has_graphics_font": False,
"has_text_colors": False,
}
def write(self, string):
self._buffer.append(string)
def flush(self) -> str:
result = "".join(self._buffer)
self._buffer.clear()
return result
def split_window(self, height):
logger.debug(f"split_window({height}) - no-op")
def select_window(self, window):
logger.debug(f"select_window({window}) - no-op")
def set_cursor_position(self, x, y):
logger.debug(f"set_cursor_position({x}, {y}) - no-op")
def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT):
logger.debug(f"erase_window({window}, {color}) - no-op")
def erase_line(self):
logger.debug("erase_line() - no-op")
def print_status_score_turns(self, text, score, turns):
pass
def print_status_time(self, hours, minutes):
pass
def set_font(self, font_number):
if font_number == zscreen.FONT_NORMAL:
return font_number
return None
def set_text_style(self, style):
pass
def set_text_color(self, foreground_color, background_color):
pass
class MudInputStream(zstream.ZInputStream):
def __init__(self):
super().__init__()
self._input_queue = queue.Queue()
self._waiting = threading.Event()
self._ready = threading.Event()
self._done = False
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,
):
self._waiting.set()
self._ready.wait()
self._ready.clear()
self._waiting.clear()
text = self._input_queue.get()
if max_length > 0:
text = text[:max_length]
return text
def read_char(self, timed_input_routine=None, timed_input_interval=0):
self._waiting.set()
self._ready.wait()
self._ready.clear()
self._waiting.clear()
text = self._input_queue.get()
if text:
return ord(text[0])
return 0
def feed(self, text: str):
self._input_queue.put(text)
self._ready.set()
class MudFilesystem(zfilesystem.ZFilesystem):
def __init__(self, save_path: Path):
self.save_path = save_path
def save_game(self, data, suggested_filename=None):
try:
self.save_path.parent.mkdir(parents=True, exist_ok=True)
self.save_path.write_bytes(data)
return True
except Exception as e:
logger.error(f"Failed to save game: {e}")
return False
def restore_game(self):
if self.save_path.exists():
try:
return self.save_path.read_bytes()
except Exception as e:
logger.error(f"Failed to restore game: {e}")
return None
return None
def open_transcript_file_for_writing(self):
return None
def open_transcript_file_for_reading(self):
return None
def create_mud_ui(save_path: Path) -> tuple[zui.ZUI, MudScreen, MudInputStream]:
audio = NullAudio()
screen = MudScreen()
keyboard = MudInputStream()
filesystem = MudFilesystem(save_path)
ui = zui.ZUI(audio, screen, keyboard, filesystem)
return ui, screen, keyboard

View file

@ -216,7 +216,7 @@ class QuetzalParser:
# read anywhere from 0 to 15 local vars
local_vars = []
for _i in range(flags_bitfield[0:3]):
for _i in range(flags_bitfield[0:4]):
var = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
local_vars.append(var)