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:
parent
5b7cb252b5
commit
7c1d1efcdb
5 changed files with 336 additions and 6 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.embedded_if_session import EmbeddedIFSession
|
||||||
from mudlib.if_session import IFSession, broadcast_to_spectators
|
from mudlib.if_session import IFSession, broadcast_to_spectators
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
@ -60,8 +61,20 @@ async def cmd_play(player: Player, args: str) -> None:
|
||||||
await player.send(msg)
|
await player.send(msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create and start IF session
|
# Ensure story_path is a Path object (for mocking compatibility)
|
||||||
session = IFSession(player, str(story_path), game_name)
|
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:
|
try:
|
||||||
intro = await session.start()
|
intro = await session.start()
|
||||||
except FileNotFoundError:
|
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")
|
await player.send("(type ::help for escape commands)\r\n")
|
||||||
|
|
||||||
# Check for saved game
|
# Check for saved game (both session types now support _do_restore)
|
||||||
if session.save_path.exists():
|
if hasattr(session, "_do_restore") and session.save_path.exists():
|
||||||
await player.send("restoring saved game...\r\n")
|
await player.send("restoring saved game...\r\n")
|
||||||
restored_text = await session._do_restore()
|
restored_text = await session._do_restore()
|
||||||
if restored_text:
|
if restored_text:
|
||||||
|
|
|
||||||
166
src/mudlib/embedded_if_session.py
Normal file
166
src/mudlib/embedded_if_session.py
Normal 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 []
|
||||||
|
|
@ -10,6 +10,7 @@ from mudlib.entity import Entity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mudlib.editor import Editor
|
from mudlib.editor import Editor
|
||||||
|
from mudlib.embedded_if_session import EmbeddedIFSession
|
||||||
from mudlib.if_session import IFSession
|
from mudlib.if_session import IFSession
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +24,7 @@ class Player(Entity):
|
||||||
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
||||||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||||
editor: Editor | None = None
|
editor: Editor | None = None
|
||||||
if_session: IFSession | None = None
|
if_session: IFSession | EmbeddedIFSession | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
|
||||||
150
src/mudlib/zmachine/mud_ui.py
Normal file
150
src/mudlib/zmachine/mud_ui.py
Normal 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
|
||||||
|
|
@ -216,7 +216,7 @@ class QuetzalParser:
|
||||||
|
|
||||||
# read anywhere from 0 to 15 local vars
|
# read anywhere from 0 to 15 local vars
|
||||||
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]
|
var = (bytes[ptr] << 8) + bytes[ptr + 1]
|
||||||
ptr += 2
|
ptr += 2
|
||||||
local_vars.append(var)
|
local_vars.append(var)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue