| """ |
| Thin embeddable wrapper around xyppy's Z-machine engine. |
| |
| Captures game text in memory (no terminal) and accepts player commands |
| via a queue. This is our glue layer — not part of xyppy itself. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import re |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Callable, Optional |
|
|
| from xyppy import ops, ops_impl, quetzal, zenv |
| from xyppy import vterm |
|
|
| from saves import resolve_save_path, save_file_path, set_active_story |
|
|
| |
| _orig_quetzal_read = quetzal.read |
| _orig_quetzal_write = quetzal.write |
|
|
|
|
| def _save_basename(filename: str) -> str: |
| name = Path(filename.strip()).name |
| if name.endswith(".sav"): |
| name = name[:-4] |
| return name or "save" |
|
|
|
|
| def _save_path(filename: str) -> Path: |
| return save_file_path(filename) |
|
|
|
|
| def _read_save_path(filename: str) -> Path: |
| path = resolve_save_path(filename) |
| return path if path is not None else save_file_path(filename) |
|
|
|
|
| def _patched_quetzal_read(filename: str): |
| return _orig_quetzal_read(str(_read_save_path(filename))) |
|
|
|
|
| def _patched_quetzal_write(env, filename: str) -> bool: |
| return _orig_quetzal_write(env, str(_save_path(filename))) |
|
|
|
|
| quetzal.read = _patched_quetzal_read |
| quetzal.write = _patched_quetzal_write |
|
|
|
|
| class NeedInputError(Exception): |
| """Raised when the game waits for player input and none is queued.""" |
|
|
| def __init__(self, kind: str = "line", prefilled: str = ""): |
| self.kind = kind |
| self.prefilled = prefilled |
| super().__init__(kind) |
|
|
|
|
| class EmbeddableEnv(zenv.Env): |
| """zenv.Env that keeps our CapturingScreen across env.reset() (restore).""" |
|
|
| def __init__(self, mem: bytes, options: argparse.Namespace) -> None: |
| self._screen_factory: Callable[[zenv.Env], "CapturingScreen"] | None = None |
| super().__init__(mem, options) |
|
|
| def attach_capturing_screen( |
| self, factory: Callable[[zenv.Env], "CapturingScreen"] |
| ) -> None: |
| self._screen_factory = factory |
| self._install_capturing_screen() |
|
|
| def _install_capturing_screen(self) -> None: |
| screen = self._screen_factory(self) |
| self.screen = screen |
| self.output_buffer[1] = screen |
|
|
| def reset(self) -> None: |
| bits_to_save = self.hdr.flags2 & 3 |
| factory = self._screen_factory |
| super().reset() |
| self.hdr.flags2 &= ~3 |
| self.hdr.flags2 |= bits_to_save |
| self._screen_factory = factory |
| if factory is not None: |
| self._install_capturing_screen() |
|
|
|
|
| @dataclass |
| class _UndoSnapshot: |
| mem: list[int] |
| pc: int |
| callstack: list[ops_impl.Frame] |
| last_pc_branch_var: int | None |
| last_pc_store_var: int | None |
|
|
|
|
| @dataclass |
| class GameSession: |
| """Runs a Z-code story file with in-memory I/O.""" |
|
|
| story_path: Path |
| _input_queue: list[str] = field(default_factory=list, repr=False) |
| _char_remainder: str = field(default="", repr=False) |
| _finished: bool = field(default=False, repr=False) |
| _env: Optional[EmbeddableEnv] = field(default=None, repr=False) |
| _rewind_pc: Optional[int] = field(default=None, repr=False) |
| _undo: _UndoSnapshot | None = field(default=None, repr=False) |
| _undo_restore: _UndoSnapshot | None = field(default=None, repr=False) |
| _pending_input_kind: str | None = field(default=None, repr=False) |
|
|
| def __post_init__(self) -> None: |
| self.story_path = Path(self.story_path) |
| if not self.story_path.is_file(): |
| raise FileNotFoundError(self.story_path) |
|
|
| def _screen_factory(self, env: zenv.Env) -> "CapturingScreen": |
| env.hdr.screen_width_units = 80 |
| env.hdr.screen_width_chars = 80 |
| return CapturingScreen(env, self._next_line_input, self._next_char_input) |
|
|
| def _build_env(self) -> EmbeddableEnv: |
| mem = self.story_path.read_bytes() |
| options = argparse.Namespace(no_slow_scroll=True) |
| env = EmbeddableEnv(mem, options) |
| env.attach_capturing_screen(self._screen_factory) |
| env._turnkeeper_session = self |
| ops.setup_opcodes(env) |
| self._register_undo_opcodes(env) |
| return env |
|
|
| def _register_undo_opcodes(self, env: EmbeddableEnv) -> None: |
| session = self |
|
|
| def save_undo(env, opinfo) -> None: |
| if session._capture_undo(): |
| ops_impl.set_var(env, opinfo.store_var, 1) |
| else: |
| ops_impl.set_var(env, opinfo.store_var, 0) |
|
|
| def restore_undo(env, opinfo) -> None: |
| if session._restore_undo(): |
| ops_impl.set_var(env, opinfo.store_var, 2) |
| else: |
| ops_impl.set_var(env, opinfo.store_var, 0) |
|
|
| ops.ext(9, save_undo, svar=True) |
| ops.ext(10, restore_undo, svar=True) |
|
|
| def _copy_frame(self, frame: ops_impl.Frame) -> ops_impl.Frame: |
| return ops_impl.Frame( |
| frame.return_addr, |
| frame.num_args, |
| list(frame.locals), |
| frame.return_val_loc, |
| list(frame.stack), |
| ) |
|
|
| def _capture_undo(self) -> bool: |
| env = self.env |
| try: |
| snap = _UndoSnapshot( |
| mem=list(env.mem[: env.hdr.static_mem_base]), |
| pc=env.pc, |
| callstack=[self._copy_frame(f) for f in env.callstack], |
| last_pc_branch_var=env.last_pc_branch_var, |
| last_pc_store_var=env.last_pc_store_var, |
| ) |
| |
| |
| |
| self._undo_restore = self._undo |
| self._undo = snap |
| return True |
| except Exception: |
| self._undo_restore = None |
| self._undo = None |
| return False |
|
|
| def _restore_undo(self) -> bool: |
| target = self._undo_restore if self._undo_restore is not None else self._undo |
| if target is None: |
| return False |
| snap = target |
| env = self.env |
| for i, byte in enumerate(snap.mem): |
| env.mem[i] = byte |
| env.pc = snap.pc |
| env.callstack = [self._copy_frame(f) for f in snap.callstack] |
| env.last_pc_branch_var = snap.last_pc_branch_var |
| env.last_pc_store_var = snap.last_pc_store_var |
| env.fixup_after_restore() |
| self.screen.reset_display() |
| return True |
|
|
| @property |
| def env(self) -> EmbeddableEnv: |
| if self._env is None: |
| self._env = self._build_env() |
| return self._env |
|
|
| @property |
| def screen(self) -> "CapturingScreen": |
| return self.env.screen |
|
|
| @property |
| def needs_char_input(self) -> bool: |
| """True when the game waits for a single keystroke (press any key).""" |
| return self._pending_input_kind == "char" |
|
|
| def _next_line_input(self, prefilled: str = "") -> str: |
| if self._char_remainder: |
| word = self._char_remainder |
| self._char_remainder = "" |
| return word |
| if self._input_queue: |
| return self._input_queue.pop(0) |
| if prefilled: |
| return prefilled |
| raise NeedInputError("line", prefilled) |
|
|
| def _next_char_input(self) -> str: |
| if self._char_remainder: |
| ch = self._char_remainder[0] |
| self._char_remainder = self._char_remainder[1:] |
| return ch |
| if self._input_queue: |
| line = self._input_queue.pop(0) |
| if line: |
| self._char_remainder = line[1:] |
| return line[0] |
| raise NeedInputError("char") |
|
|
| def _safe_step(self) -> None: |
| """Run one VM step; rewind PC if input is needed mid-read.""" |
| if self._rewind_pc is not None: |
| self.env.pc = self._rewind_pc |
| self._rewind_pc = None |
| pc_before = self.env.pc |
| try: |
| zenv.step(self.env) |
| except NeedInputError: |
| |
| |
| self._rewind_pc = pc_before |
| raise |
|
|
| def reset(self) -> None: |
| """Start a fresh game from the story file.""" |
| self._input_queue.clear() |
| self._char_remainder = "" |
| self._finished = False |
| self._rewind_pc = None |
| self._undo = None |
| self._undo_restore = None |
| self._pending_input_kind = None |
| self._env = self._build_env() |
| self.screen.first_draw() |
|
|
| def start(self) -> str: |
| """Run from a clean state until the game needs input or quits.""" |
| self.reset() |
| return self.run_until_input() |
|
|
| def send_command(self, command: str) -> str: |
| """Send a player command and return new game output.""" |
| if self._finished: |
| raise RuntimeError("Game has ended — call reset() to play again.") |
| cmd = command.strip() |
| if cmd.lower() == "undo": |
| return self._undo_last_turn() |
| if self._pending_input_kind == "char": |
| |
| |
| self._char_remainder = "" |
| self._input_queue.append(cmd[0] if cmd else " ") |
| else: |
| self._input_queue.append(cmd) |
| return self.run_until_input() |
|
|
| def send_key(self, char: str = " ") -> str: |
| """Send a single keystroke for press-any-key / more-text prompts.""" |
| if self._finished: |
| raise RuntimeError("Game has ended — call reset() to play again.") |
| self._char_remainder = "" |
| self._input_queue.append((char or " ")[:1]) |
| return self.run_until_input() |
|
|
| def _undo_last_turn(self) -> str: |
| """Restore the pre-turn snapshot and refresh the room description.""" |
| if self._undo_restore is None and self._undo is None: |
| return '[You can\'t "undo" what hasn\'t been done!]' |
| if not self._restore_undo(): |
| return '"Undo" failed. [Not all interpreters provide it.]' |
| self.screen.reset_display() |
| self._input_queue.append("look") |
| return self.run_until_input() |
|
|
| def leave_filename_prompt(self) -> str: |
| """Return to normal play after a failed or abandoned save/restore filename.""" |
| self._input_queue.clear() |
| self._char_remainder = "" |
| self._rewind_pc = None |
| if self._restore_undo(): |
| self.screen.reset_display() |
| self._input_queue.append("look") |
| return self.run_until_input() |
| self._input_queue.append("") |
| return self.run_until_input() |
|
|
| def run_until_input(self) -> str: |
| """Advance the VM until input is required or the game exits.""" |
| self._pending_input_kind = None |
| while not self._finished: |
| try: |
| self._safe_step() |
| except NeedInputError as exc: |
| self._pending_input_kind = exc.kind |
| return self.screen.drain_output() |
| except SystemExit: |
| self._finished = True |
| return self.screen.drain_output() |
| return self.screen.drain_output() |
|
|
|
|
| class CapturingScreen(vterm.Screen): |
| """xyppy Screen that captures text instead of drawing to a terminal.""" |
|
|
| def __init__( |
| self, |
| env: zenv.Env, |
| line_input: Callable[[str], str], |
| char_input: Callable[[], str], |
| ) -> None: |
| super().__init__(env) |
| self._line_input = line_input |
| self._char_input = char_input |
| self._chunks: list[str] = [] |
| self._last_status: dict[str, str] = {} |
|
|
| @property |
| def last_status(self) -> dict[str, str]: |
| return dict(self._last_status) |
|
|
| def first_draw(self) -> None: |
| """Skip terminal setup — we are not using a real console.""" |
|
|
| def reset_display(self) -> None: |
| """Clear screen buffers after undo/restore so status text stays in sync.""" |
| self._chunks.clear() |
| self._last_status = {} |
| self.textBuf = self.make_screen_buf() |
| self.seenBuf = {line: True for line in self.textBuf} |
| self.wrapBuf = [] |
| self.haveNotScrolled = True |
|
|
| def slow_scroll_effect(self) -> None: |
| pass |
|
|
| def pause_scroll_for_user_input(self) -> None: |
| self.flush() |
| self.update_seen_lines() |
|
|
| def overwrite_line_with(self, new_line) -> None: |
| pass |
|
|
| def flush(self) -> None: |
| self.finish_wrapping() |
|
|
| def write(self, text: str) -> None: |
| """Capture raw game text before the screen buffer wraps/pads it.""" |
| if self.env.current_window == 0: |
| self._chunks.extend(text) |
| super().write(text) |
|
|
| def write_unwrapped(self, text_as_screenchars, already_seen=False) -> None: |
| super().write_unwrapped(text_as_screenchars, already_seen=already_seen) |
|
|
| def new_line(self) -> None: |
| super().new_line() |
| if self.env.current_window != 0: |
| return |
| if not self._chunks or self._chunks[-1] != "\n": |
| self._chunks.append("\n") |
|
|
| def drain_output(self) -> str: |
| self.flush() |
| text = _clean_zmachine_output("".join(self._chunks)) |
| self._chunks.clear() |
| self._last_status = _status_from_screen(self.env, self.textBuf) |
| return text |
|
|
| def scroll(self, count_lines: bool = True) -> None: |
| env = self.env |
| self.textBuf.pop(env.top_window_height) |
| new_line = self.make_screen_line() |
| self.textBuf.append(new_line) |
| self.seenBuf[new_line] = False |
| self.haveNotScrolled = False |
|
|
| def scroll_top_line_only(self) -> None: |
| env = self.env |
| if self.haveNotScrolled and vterm.line_empty(self.textBuf[env.top_window_height]): |
| return |
| new_line = self.make_screen_line() |
| self.textBuf[env.top_window_height] = new_line |
| self.seenBuf[new_line] = False |
| self.haveNotScrolled = False |
|
|
| def get_line_of_input(self, prompt: str = "", prefilled: str = "") -> str: |
| if prompt: |
| self.write(prompt) |
| self.flush() |
| return self._line_input(prefilled) |
|
|
| def getch_or_esc_seq(self) -> str: |
| self.flush() |
| try: |
| ch = self._char_input() |
| if ch == "\x7f": |
| ch = "\b" |
| return ch |
| except NeedInputError as exc: |
| exc.kind = "char" |
| raise |
|
|
| def msg(self, text: str) -> None: |
| self.write(text) |
| self.flush() |
| try: |
| self._char_input() |
| except NeedInputError: |
| raise |
|
|
|
|
| def _clean_zmachine_output(text: str) -> str: |
| """Trim Z-machine line padding; keep paragraph breaks.""" |
| lines: list[str] = [] |
| for line in text.split("\n"): |
| stripped = line.strip() |
| if stripped == ">": |
| continue |
| if not stripped: |
| if lines and lines[-1] != "": |
| lines.append("") |
| continue |
| lines.append(stripped) |
|
|
| deduped: list[str] = [] |
| for line in lines: |
| if not deduped or deduped[-1] != line: |
| deduped.append(line) |
|
|
| while deduped and deduped[-1] == "": |
| deduped.pop() |
|
|
| return "\n".join(deduped).strip() |
|
|
|
|
| def _status_from_screen(env: zenv.Env, text_buf) -> dict[str, str]: |
| """Read the fixed-width Inform status line from the top screen row.""" |
| if env.top_window_height < 1: |
| return {} |
| row = text_buf[0] |
| line = "".join(c.char for c in row) |
| return _parse_status_line(line) |
|
|
|
|
| def _parse_status_line(raw: str) -> dict[str, str]: |
| """Parse Inform status line: location left, score/moves right.""" |
| line = raw.rstrip() |
| if not line.strip(): |
| return {} |
|
|
| score_m = re.search(r"Score:\s*(-?\d+)", line, re.I) |
| moves_m = re.search(r"Moves:\s*(\d+)", line, re.I) |
| if score_m: |
| return { |
| "location": line[: score_m.start()].strip(), |
| "score": score_m.group(1), |
| **({"moves": moves_m.group(1)} if moves_m else {}), |
| } |
| if moves_m: |
| return { |
| "location": line[: moves_m.start()].strip(), |
| "moves": moves_m.group(1), |
| } |
|
|
| |
| compact_m = re.search(r"(-?\d+)\s*/\s*(\d+)\s*$", line) |
| if compact_m: |
| return { |
| "location": line[: compact_m.start()].strip(), |
| "score": compact_m.group(1), |
| "moves": compact_m.group(2), |
| } |
|
|
| collapsed = re.sub(r"\s+", " ", line).strip() |
| return {"location": collapsed} |
|
|