Add research on IF possibilities
This commit is contained in:
parent
f36085c921
commit
ed6ffbdc5d
4 changed files with 1467 additions and 0 deletions
676
docs/how/if-integration.txt
Normal file
676
docs/how/if-integration.txt
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
interactive fiction integration — hosting z-machine games in a MUD
|
||||
====================================================================
|
||||
|
||||
where this fits. the dreambook calls for IF games as in-world content — a room
|
||||
in the MUD becomes a portal to a z-machine adventure. players enter IF mode,
|
||||
play through a self-contained story, and other players see "jared is playing
|
||||
zork in the arcade." fully isolated from world noise, but visible to
|
||||
spectators in the same room.
|
||||
|
||||
the architecture is ready. we have the mode stack, input routing, editor mode
|
||||
pattern. the missing pieces:
|
||||
- z-machine interpreter (either embedded or subprocess)
|
||||
- room-local spectator broadcasting (others see your game output)
|
||||
- "terminal" game object concept (a room object that hosts IF sessions)
|
||||
- save/restore integration (quetzal snapshots to sqlite)
|
||||
|
||||
this doc synthesizes research on three z-machine implementations and proposes
|
||||
integration paths.
|
||||
|
||||
|
||||
the z-machine landscape
|
||||
=======================
|
||||
|
||||
three implementations evaluated:
|
||||
|
||||
sussman/zvm (python, pure stdlib):
|
||||
- versions 1-5 claimed, but only ~40% of opcodes implemented
|
||||
- interpreter HALTS on any unimplemented opcode — can't gracefully degrade
|
||||
- excellent pluggable I/O: abstract ZUI, ZScreen, ZInputStream, ZOutputStream
|
||||
- quetzal save/restore infrastructure exists but opcodes are stubbed
|
||||
- pure python, zero dependencies — perfect for embedding
|
||||
- blocking run loop, not async-compatible
|
||||
- clean API: ZMachine(story_bytes, ui=custom_ui).run()
|
||||
- python 3 port was a "blind fix" — functional but fragile
|
||||
- verdict: TOO INCOMPLETE. would need 2-4 weeks of opcode implementation
|
||||
before any real game runs. the I/O architecture is excellent reference
|
||||
material but the CPU is not production-ready.
|
||||
|
||||
viola (python, pygame dependency):
|
||||
- versions 1-8, comprehensive modern standard 1.1 compliance
|
||||
- 120+ opcodes implemented — ALL documented z-spec instructions have
|
||||
implementations
|
||||
- clean stream architecture: 5 output streams, each individually addressable
|
||||
- full quetzal 1.4 save/restore support with undo
|
||||
- active maintenance (2024 copyright, author engaged)
|
||||
- dependencies: pygame (for rendering/input), numpy (for image processing)
|
||||
- bundled ififf submodule for blorb/quetzal/IFF handling
|
||||
- blocking main loop (execloop), module-level global state
|
||||
- no tests whatsoever
|
||||
- clean module separation: zcode/ (VM core) vs vio/ (I/O layer)
|
||||
- ~8,500 lines in zcode/ + ~2,000 in vio/
|
||||
- verdict: MOST COMPLETE python z-machine available. pygame dependency is
|
||||
only in vio/ — replace that module with a text-callback I/O backend and
|
||||
the core VM is usable. strongest candidate for embedding.
|
||||
|
||||
mojozork (C, educational implementation):
|
||||
- version 3 only (rejects other versions with error)
|
||||
- ~45 opcodes, complete for v3 (covers ~90% of infocom catalog)
|
||||
- excellent pluggable I/O via function pointer callbacks
|
||||
- has runInstruction() for single-step execution — async-friendly by design
|
||||
- multiple frontends: stdio, libretro, SDL3, and a TELNET MULTIPLAYER SERVER
|
||||
- MultiZork uses sqlite for multiplayer state persistence
|
||||
- zlib license (very permissive)
|
||||
- author: ryan c. gordon (well-known open source dev)
|
||||
- custom save format (not quetzal)
|
||||
- C only — would need ctypes/CFFI wrapper or subprocess
|
||||
- verdict: EXCELLENT REFERENCE for architecture patterns. MultiZork shows
|
||||
exactly how to embed a z-machine in a multiplayer telnet server. v3-only
|
||||
is limiting but covers classic infocom games. best studied, potentially
|
||||
wrapped via C extension.
|
||||
|
||||
|
||||
integration options
|
||||
===================
|
||||
|
||||
three paths forward:
|
||||
|
||||
option A: embed viola (python native)
|
||||
- fork or vendor viola's zcode/ module
|
||||
- replace vio/ with our own I/O backend: MudZUI class
|
||||
- wrap the blocking execloop with async bridge (run_in_executor)
|
||||
- state serialization via viola's quetzal support
|
||||
- pros: pure python, full z-machine 1-8 support, comprehensive opcodes,
|
||||
native access to VM state
|
||||
- cons: no tests in upstream, blocking loop requires thread pool,
|
||||
global state needs refactoring for multiple concurrent games
|
||||
|
||||
option B: subprocess dfrozt (quick prototype)
|
||||
- spawn dfrotz in a subprocess, pipe stdin/stdout
|
||||
- asyncio subprocess API handles I/O
|
||||
- save/restore via dfrotz's built-in quetzal support
|
||||
- pros: FASTEST PATH TO WORKING. dfrozt is battle-tested, z-machine 1-8,
|
||||
comprehensive game compatibility
|
||||
- cons: less control over VM state, harder to implement spectators (need to
|
||||
tee output), subprocess management overhead, can't introspect game state
|
||||
for GMCP/rich features
|
||||
|
||||
option C: write our own (long-term ideal)
|
||||
- study mojozork and sussman/zvm architectures
|
||||
- implement enough opcodes for target games (start with v3)
|
||||
- async-native from the start
|
||||
- pros: perfect fit for dreambook philosophy, full control, async-native,
|
||||
clean integration with MUD internals
|
||||
- cons: SIGNIFICANT DETOUR. weeks/months before first game runs. opcode
|
||||
implementation is tedious and error-prone. spec compliance is hard.
|
||||
|
||||
recommendation: START WITH B (dfrozt subprocess), PLAN FOR A (viola embedding).
|
||||
|
||||
rationale: dfrozt gets IF working in hours. players can play zork today. we
|
||||
learn what the integration needs (spectator broadcasting, save management,
|
||||
input routing) with real usage. once the architecture is proven, we can
|
||||
replace the subprocess with embedded viola for better control and state
|
||||
access. option C remains on the table if viola doesn't fit, but we don't
|
||||
invest in a VM until we know what the MUD needs from it.
|
||||
|
||||
|
||||
the "terminal" game object
|
||||
==========================
|
||||
|
||||
a terminal is a room object that hosts an IF session. mechanically:
|
||||
|
||||
- a terminal has a z-machine story file (path or blob reference)
|
||||
- when a player enters the terminal, it pushes IF mode onto their stack
|
||||
- input from the player routes to the z-machine interpreter
|
||||
- output from the z-machine routes to the player (and spectators)
|
||||
- the terminal tracks active sessions (who's playing, current state)
|
||||
|
||||
example from a player's perspective:
|
||||
|
||||
> look
|
||||
you stand in a neon-lit arcade. rows of terminals hum quietly.
|
||||
a large screen on the north wall shows "ZORK - The Great Underground
|
||||
Empire" in green phosphor text.
|
||||
|
||||
jared is standing here, absorbed in a terminal.
|
||||
sarah is watching jared's game on a nearby screen.
|
||||
|
||||
> use terminal
|
||||
you sit down at a terminal. the screen flickers to life.
|
||||
|
||||
[IF mode engaged. type 'quit' to exit the game.]
|
||||
|
||||
ZORK I: The Great Underground Empire
|
||||
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
|
||||
ZORK is a registered trademark of Infocom, Inc.
|
||||
Revision 88 / Serial number 840726
|
||||
|
||||
West of House
|
||||
You are standing in an open field west of a white house, with a boarded
|
||||
front door.
|
||||
There is a small mailbox here.
|
||||
|
||||
>
|
||||
|
||||
at this point the player's input goes to the z-machine. the terminal object
|
||||
manages the interpreter subprocess/instance. other players in the room see
|
||||
"jared is playing zork" in their ambient messages.
|
||||
|
||||
|
||||
spectator broadcasting
|
||||
======================
|
||||
|
||||
key feature from mojozork's MultiZork: others can watch your game.
|
||||
|
||||
in the MUD: if sarah is in the same room as jared, she can see his game
|
||||
output in near-realtime. not keystroke-by-keystroke, but command and response.
|
||||
|
||||
implementation:
|
||||
|
||||
- terminal object has a set of active_spectators (player refs)
|
||||
- when the z-machine sends output to the active player, terminal mirrors
|
||||
that output to all spectators in the same room
|
||||
- spectators see: "[jared's game] > n\nYou are in a maze of twisty little
|
||||
passages, all alike."
|
||||
- spectators can 'watch jared' to start spectating, 'stop watching' to stop
|
||||
- spectators are in normal mode, not IF mode. they see their own world
|
||||
events too.
|
||||
|
||||
this creates communal play. someone stuck on a puzzle can have friends watch
|
||||
and suggest commands. speedruns become spectator events. IF becomes social.
|
||||
|
||||
technical: output from the z-machine goes to a broadcast function:
|
||||
|
||||
def send_to_player_and_spectators(player, output):
|
||||
player.send(output)
|
||||
terminal = player.current_terminal
|
||||
for spectator in terminal.spectators:
|
||||
if spectator.location == terminal.location:
|
||||
spectator.send(f"[{player.name}'s game] {output}")
|
||||
|
||||
spectators don't share game state, just output. they can't send commands to
|
||||
someone else's game.
|
||||
|
||||
|
||||
viola embedding details
|
||||
========================
|
||||
|
||||
if we go with option A, here's how viola integration would work:
|
||||
|
||||
replace vio/ with MudZUI:
|
||||
|
||||
class MudZUI:
|
||||
"""viola I/O backend that routes to MUD player sessions"""
|
||||
|
||||
def __init__(self, player, terminal):
|
||||
self.player = player
|
||||
self.terminal = terminal
|
||||
self.output_buffer = []
|
||||
|
||||
def write(self, text):
|
||||
"""called by VM to send output"""
|
||||
self.output_buffer.append(text)
|
||||
|
||||
def flush(self):
|
||||
"""send buffered output to player and spectators"""
|
||||
output = ''.join(self.output_buffer)
|
||||
self.terminal.broadcast_output(self.player, output)
|
||||
self.output_buffer.clear()
|
||||
|
||||
def read_line(self):
|
||||
"""called by VM to get player input"""
|
||||
# return input from player's IF mode input queue
|
||||
return self.player.if_input_queue.get()
|
||||
|
||||
def read_char(self):
|
||||
"""single-key input for timed events"""
|
||||
# viola supports this, we'd need to too
|
||||
return self.player.if_input_queue.get_char()
|
||||
|
||||
wrap the blocking VM loop:
|
||||
|
||||
async def run_if_game(player, terminal, story_path):
|
||||
"""run z-machine in thread pool, bridge to async"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# load story file
|
||||
with open(story_path, 'rb') as f:
|
||||
story_data = f.read()
|
||||
|
||||
# create VM with our I/O backend
|
||||
zui = MudZUI(player, terminal)
|
||||
zmachine = ZMachine(story_data, zui)
|
||||
|
||||
# run in executor (thread pool) since VM loop is blocking
|
||||
def run_vm():
|
||||
try:
|
||||
zmachine.run()
|
||||
except GameOver:
|
||||
pass # normal exit
|
||||
|
||||
await loop.run_in_executor(None, run_vm)
|
||||
|
||||
state management:
|
||||
|
||||
- viola has full quetzal save/restore
|
||||
- when player types 'save', VM writes quetzal IFF to bytes
|
||||
- we store that blob in sqlite: game_saves table (player_id, terminal_id,
|
||||
save_slot, quetzal_data, timestamp)
|
||||
- when player types 'restore', we fetch the blob and pass to VM
|
||||
- viola handles all the z-machine state serialization
|
||||
|
||||
multiple concurrent games:
|
||||
|
||||
- viola uses module-level global state in some places
|
||||
- need to audit zcode/ and refactor globals into instance state
|
||||
- each terminal object owns a ZMachine instance
|
||||
- multiple players can play the same game in different terminals
|
||||
- each gets their own VM instance with independent state
|
||||
|
||||
this is work, but manageable. viola's clean separation of zcode/ (VM) from
|
||||
vio/ (I/O) makes the I/O replacement straightforward. the async bridge is a
|
||||
standard pattern. the global state refactor is the biggest risk.
|
||||
|
||||
|
||||
dfrozt subprocess details
|
||||
==========================
|
||||
|
||||
if we go with option B for prototyping, here's the implementation:
|
||||
|
||||
spawn dfrozt:
|
||||
|
||||
async def run_if_game_subprocess(player, terminal, story_path):
|
||||
"""run dfrotz as subprocess, pipe I/O"""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'dfrotz',
|
||||
'-p', # plain output, no formatting
|
||||
'-m', # disable MORE prompts
|
||||
story_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# bridge player input to subprocess stdin
|
||||
async def input_loop():
|
||||
while process.returncode is None:
|
||||
command = await player.if_input_queue.get()
|
||||
process.stdin.write(f"{command}\n".encode())
|
||||
await process.stdin.drain()
|
||||
|
||||
# bridge subprocess stdout to player and spectators
|
||||
async def output_loop():
|
||||
while process.returncode is None:
|
||||
line = await process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode('utf-8', errors='replace')
|
||||
terminal.broadcast_output(player, text)
|
||||
|
||||
# run both loops concurrently
|
||||
await asyncio.gather(input_loop(), output_loop())
|
||||
await process.wait()
|
||||
|
||||
save/restore:
|
||||
|
||||
- dfrotz handles saves via its own commands (SAVE, RESTORE)
|
||||
- save files go to filesystem
|
||||
- we could intercept SAVE/RESTORE commands, manage files in a
|
||||
player-specific directory, copy to sqlite for persistence
|
||||
- or let dfrozt write to /tmp/ and just copy the files on exit
|
||||
|
||||
spectator output:
|
||||
|
||||
- subprocess output goes to all (player + spectators) via broadcast
|
||||
- tee is automatic — we control the output loop
|
||||
|
||||
exit/cleanup:
|
||||
|
||||
- player types 'quit' or EOF — we send QUIT to process, wait for exit
|
||||
- process crash — we catch it, log error, tell player "game crashed"
|
||||
- terminal cleanup — kill subprocess if still running
|
||||
|
||||
pros: simple, robust, dfrozt is battle-tested.
|
||||
cons: less control. can't introspect VM state for GMCP data. can't implement
|
||||
custom opcodes. subprocess overhead (but negligible for text games).
|
||||
|
||||
|
||||
mojozork patterns to study
|
||||
===========================
|
||||
|
||||
MultiZork is mojozork's multiplayer telnet server. it shows:
|
||||
|
||||
1. session management: each player gets a separate VM instance, but the game
|
||||
world is shared. players see each other's actions. this is DIFFERENT from
|
||||
our model (each terminal is isolated) but the architecture is instructive.
|
||||
|
||||
2. sqlite persistence: MultiZork stores game state in sqlite. saves are just
|
||||
snapshots of the VM memory. we'd do the same with quetzal blobs.
|
||||
|
||||
3. command broadcasting: when one player types something, MultiZork echoes it
|
||||
to others in the same location. same as our spectator model.
|
||||
|
||||
4. pluggable I/O: mojozork's I/O is function pointers:
|
||||
|
||||
typedef struct {
|
||||
void (*print)(const char *text);
|
||||
char *(*read_line)(void);
|
||||
void (*save_state)(const uint8_t *data, size_t len);
|
||||
uint8_t *(*restore_state)(size_t *len);
|
||||
} IOCallbacks;
|
||||
|
||||
clean, simple. viola's ZUI is the python equivalent.
|
||||
|
||||
5. tick-based execution: MultiZork runs the VM in a loop, checking for input
|
||||
each tick. same as our game loop. z-machine opcodes are fast — dozens
|
||||
execute per tick.
|
||||
|
||||
MultiZork source is excellent reference for "how to embed IF in a multiplayer
|
||||
server." if we write our own VM (option C), MultiZork is the template.
|
||||
|
||||
|
||||
save/restore and persistence
|
||||
=============================
|
||||
|
||||
z-machine has built-in save/restore opcodes. quetzal is the standard format.
|
||||
|
||||
in our MUD:
|
||||
|
||||
- player types SAVE in the IF game
|
||||
- VM serializes its state to quetzal IFF (binary format)
|
||||
- we capture that blob and write to sqlite:
|
||||
|
||||
CREATE TABLE if_saves (
|
||||
id INTEGER PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
terminal_id INTEGER NOT NULL,
|
||||
slot INTEGER DEFAULT 0,
|
||||
quetzal_data BLOB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(player_id, terminal_id, slot)
|
||||
);
|
||||
|
||||
- player types RESTORE, we fetch the blob and pass back to VM
|
||||
|
||||
autosave:
|
||||
|
||||
- when player exits IF mode (QUIT command or connection drop), we could
|
||||
autosave their state
|
||||
- on reconnect/re-enter, offer to resume from last autosave
|
||||
|
||||
multiple save slots:
|
||||
|
||||
- z-machine games usually support named saves ("SAVE mysave")
|
||||
- we could parse the filename and use it as the slot identifier
|
||||
- or just offer slots 1-10 and map them to quetzal blobs
|
||||
|
||||
cross-session saves:
|
||||
|
||||
- player saves in session A, restores in session B (different terminal)
|
||||
- works fine if both terminals host the same story file
|
||||
- could enable "speedrun mode" — reset to a specific save and time from
|
||||
there
|
||||
|
||||
undo:
|
||||
|
||||
- z-machine has UNDO opcode
|
||||
- viola implements it via a stack of quetzal snapshots
|
||||
- we could surface undo as a MUD command outside the game (safety net if
|
||||
player makes a mistake)
|
||||
|
||||
|
||||
if mode on the mode stack
|
||||
==========================
|
||||
|
||||
when player enters a terminal, push IF mode:
|
||||
|
||||
player.mode_stack.push(Mode.IF)
|
||||
|
||||
IF mode behavior:
|
||||
|
||||
- all input goes to if_input_queue, not command dispatcher
|
||||
- world events (chat, movement, ambient) are buffered, not displayed
|
||||
- combat can't start (you're isolated)
|
||||
- on exit, pop mode, show summary of buffered events
|
||||
|
||||
commands available in IF mode:
|
||||
|
||||
- anything the z-machine game accepts (depends on the game)
|
||||
- special MUD commands prefixed with / or !:
|
||||
- /quit — exit the game, pop IF mode
|
||||
- /save [slot] — trigger VM save
|
||||
- /restore [slot] — trigger VM restore
|
||||
- /undo — trigger VM undo
|
||||
- /help — show IF mode help
|
||||
|
||||
input routing:
|
||||
|
||||
before IF mode: player types "north" → command dispatcher → cmd_move
|
||||
during IF mode: player types "north" → if_input_queue → z-machine
|
||||
|
||||
the mode stack handles this cleanly. IF mode is just another mode, like combat
|
||||
or editor.
|
||||
|
||||
|
||||
terminal object implementation sketch
|
||||
======================================
|
||||
|
||||
rough structure:
|
||||
|
||||
class IFTerminal:
|
||||
"""a room object that hosts z-machine games"""
|
||||
|
||||
def __init__(self, story_path, name, description):
|
||||
self.story_path = story_path # path to .z3/.z5/.z8 file
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.active_sessions = {} # player -> IFSession
|
||||
self.spectators = set() # players watching
|
||||
|
||||
async def enter(self, player):
|
||||
"""player sits at terminal, start IF session"""
|
||||
if player in self.active_sessions:
|
||||
player.send("You're already playing this game.")
|
||||
return
|
||||
|
||||
# push IF mode
|
||||
player.mode_stack.push(Mode.IF)
|
||||
|
||||
# create session (VM instance or subprocess)
|
||||
session = await self.create_session(player)
|
||||
self.active_sessions[player] = session
|
||||
|
||||
# announce to room
|
||||
self.broadcast_except(player, f"{player.name} sits down at {self.name}.")
|
||||
|
||||
async def create_session(self, player):
|
||||
"""spawn VM or subprocess for this player"""
|
||||
# option A: return ZMachineSession(player, self)
|
||||
# option B: return DfrotzSession(player, self)
|
||||
pass
|
||||
|
||||
def broadcast_output(self, player, output):
|
||||
"""send game output to player and spectators"""
|
||||
player.send(output)
|
||||
for spectator in self.spectators:
|
||||
if spectator.location == self.location:
|
||||
spectator.send(f"[{player.name}'s game]\n{output}")
|
||||
|
||||
async def exit(self, player):
|
||||
"""player leaves terminal, cleanup session"""
|
||||
session = self.active_sessions.pop(player, None)
|
||||
if session:
|
||||
await session.cleanup()
|
||||
player.mode_stack.pop() # exit IF mode
|
||||
self.broadcast_except(player, f"{player.name} stands up from {self.name}.")
|
||||
|
||||
terminals are room objects. they could be defined in world data:
|
||||
|
||||
- rooms:
|
||||
- id: arcade
|
||||
description: "a dimly lit arcade. terminals line the walls."
|
||||
objects:
|
||||
- type: if_terminal
|
||||
name: "zork terminal"
|
||||
story: "games/zork1.z3"
|
||||
description: "an old CRT displaying 'ZORK I' in green text"
|
||||
|
||||
or created by players (builder commands):
|
||||
|
||||
> create terminal "hitchhiker terminal" games/hhgg.z5
|
||||
You create a new IF terminal.
|
||||
|
||||
> describe terminal "A sleek modern terminal. The screen reads:
|
||||
'The Hitchhiker's Guide to the Galaxy - Don't Panic'"
|
||||
|
||||
|
||||
phased implementation plan
|
||||
===========================
|
||||
|
||||
phase 1: dfrozt subprocess prototype
|
||||
- define IFTerminal class
|
||||
- implement IF mode on mode stack
|
||||
- spawn dfrozt subprocess for a hardcoded game (zork1.z3)
|
||||
- route player input to subprocess stdin
|
||||
- route subprocess stdout to player
|
||||
- /quit command to exit
|
||||
goal: player can sit at a terminal and play zork. no saves, no spectators.
|
||||
estimate: 1-2 days.
|
||||
|
||||
phase 2: spectator broadcasting
|
||||
- terminal tracks spectators (players in same room)
|
||||
- tee game output to spectators with prefix
|
||||
- 'watch [player]' and 'stop watching' commands
|
||||
goal: others can watch your game in realtime.
|
||||
estimate: half day.
|
||||
|
||||
phase 3: save/restore via filesystem
|
||||
- intercept SAVE/RESTORE commands
|
||||
- manage save files in player-specific directory
|
||||
- copy to sqlite on exit for persistence
|
||||
goal: players can save progress and resume later.
|
||||
estimate: 1 day.
|
||||
|
||||
phase 4: terminal as room object
|
||||
- define terminals in world data (YAML)
|
||||
- load at startup, spawn on player enter
|
||||
- multiple terminals, different games
|
||||
goal: arcade room with multiple games, each on its own terminal.
|
||||
estimate: 1 day.
|
||||
|
||||
phase 5: evaluate viola embedding
|
||||
- fork/vendor viola's zcode/
|
||||
- implement MudZUI (I/O backend)
|
||||
- wrap VM loop with run_in_executor
|
||||
- test with zork1, compare to dfrozt
|
||||
goal: decide if viola is better than subprocess.
|
||||
estimate: 2-3 days.
|
||||
|
||||
phase 6: switch to viola if proven
|
||||
- replace dfrozt sessions with viola sessions
|
||||
- refactor terminal to use embedded VM
|
||||
- quetzal saves to sqlite (direct, no filesystem)
|
||||
- expose VM state for GMCP (current room, inventory, score)
|
||||
goal: full control over VM, native python integration.
|
||||
estimate: 2-3 days (assuming viola works).
|
||||
|
||||
phase 7: builder tools
|
||||
- 'create terminal' command for admins/builders
|
||||
- upload .z3/.z5/.z8 files to server
|
||||
- terminal object editor (change description, story file)
|
||||
goal: builders can add new IF games without code changes.
|
||||
estimate: 2 days.
|
||||
|
||||
total estimate: 10-15 days for full IF integration with all features.
|
||||
|
||||
if we stop after phase 4, we have working IF-in-MUD with spectators and saves.
|
||||
that's enough to prove the concept. phases 5-7 are refinement and tooling.
|
||||
|
||||
|
||||
what we learn from this
|
||||
========================
|
||||
|
||||
IF integration exercises several MUD systems:
|
||||
|
||||
- mode stack: IF mode is the third mode (after normal and editor). proves
|
||||
the stack abstraction works for diverse isolation needs.
|
||||
|
||||
- session management: each terminal session is a persistent object (VM or
|
||||
subprocess) tied to a player. different from stateless command dispatch.
|
||||
|
||||
- room-local broadcasting: spectator output is the first use of "broadcast
|
||||
to players in this room." will be useful for ambient messages, weather,
|
||||
room events later.
|
||||
|
||||
- binary blob persistence: quetzal saves are blobs in sqlite. same pattern
|
||||
will apply to uploaded files, cached terrain chunks, whatever.
|
||||
|
||||
- game objects with behavior: terminals are room objects that do things
|
||||
(spawn VMs, route I/O). pattern extends to doors, NPCs, triggers.
|
||||
|
||||
getting IF right means the architecture can handle editor mode, crafting
|
||||
mini-games, puzzles, dialogue trees, anything with isolated state and
|
||||
custom input handling.
|
||||
|
||||
|
||||
risks and unknowns
|
||||
==================
|
||||
|
||||
viola's lack of tests:
|
||||
we'd be trusting ~10k lines of untested code. bugs in opcode implementation
|
||||
would surface as "game doesn't work" with no clear cause. mitigation: test
|
||||
with a suite of known games (zork, hhgg, curses, anchorhead) before
|
||||
committing. if bugs emerge, consider dfrozt as the long-term solution.
|
||||
|
||||
async bridge overhead:
|
||||
viola's blocking loop in a thread pool adds latency. run_in_executor is
|
||||
designed for this but it's not zero-cost. mitigation: measure latency in
|
||||
prototype. if it's perceptible (>100ms), consider writing an async-native
|
||||
VM or sticking with subprocess.
|
||||
|
||||
global state in viola:
|
||||
haven't audited zcode/ for globals yet. if it's pervasive, the refactor to
|
||||
instance state could be large. mitigation: audit before committing to viola.
|
||||
if globals are deeply baked in, subprocess is safer.
|
||||
|
||||
z-machine version support:
|
||||
viola claims 1-8, mojozork does 3 only. most infocom classics are v3 or v5.
|
||||
modern IF (inform 7) compiles to v8. if we only care about classics, v3 is
|
||||
enough. if we want modern games, need v8. mitigation: decide target game
|
||||
set before choosing VM.
|
||||
|
||||
spectator bandwidth:
|
||||
if 10 players are watching one game, every line gets sent 11 times (player
|
||||
+ 10 spectators). that's fine for text but worth monitoring. mitigation:
|
||||
spectator output could be rate-limited or summarized (show every 5 lines,
|
||||
not every line).
|
||||
|
||||
save file size:
|
||||
quetzal saves are typically 20-100KB. if player saves frequently, sqlite
|
||||
grows. mitigation: limit saves per player per game (10 slots?), auto-prune
|
||||
old saves, or compress quetzal blobs (they're IFF, should compress well).
|
||||
|
||||
|
||||
closing
|
||||
=======
|
||||
|
||||
IF-in-MUD is not a detour. it's a proof that the architecture supports
|
||||
isolated, stateful, player-driven content. the mode stack, room objects,
|
||||
save/restore, multiplayer isolation — all tested in a concrete use case.
|
||||
|
||||
start with dfrozt subprocess (phase 1-4). prove the concept. if it works,
|
||||
consider viola for tighter integration. if viola is risky, subprocess is
|
||||
fine long-term. mojozork shows that C via subprocess is viable.
|
||||
|
||||
the dream: player-created IF games, built in the editor, tested in-world,
|
||||
published for others. that requires a DSL and builder tools. but the
|
||||
foundation is terminals + mode stack + save/restore. get that right and the
|
||||
dream is achievable.
|
||||
|
||||
|
||||
code
|
||||
----
|
||||
|
||||
this document docs/how/if-integration.txt
|
||||
dreambook DREAMBOOK.md
|
||||
architecture docs/how/architecture-plan.txt
|
||||
258
docs/how/if-journey.rst
Normal file
258
docs/how/if-journey.rst
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
if journey — from arcade terminal to moldable worlds
|
||||
=====================================================
|
||||
|
||||
This doc tracks the IF (interactive fiction) integration effort for the MUD engine. It's a living document — updated as research progresses and decisions are made. For detailed technical analysis, see the two interpreter audits (``viola-embedding-audit.rst`` and ``zvm-embedding-audit.rst``) and the original integration notes (``if-integration.txt``).
|
||||
|
||||
the vision — five levels of integration
|
||||
----------------------------------------
|
||||
|
||||
Five levels emerged from design discussion. They represent a spectrum of how deeply IF worlds integrate with the MUD world, from simplest to most ambitious.
|
||||
|
||||
Level 1 — terminal mode
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Subprocess (dfrotz), text in/out, spectators see text scrolling on a screen. Player sits at an arcade terminal, plays Zork. Others in the room see the output. The IF world is opaque — a black box.
|
||||
|
||||
Works today with dfrotz. Proven technology.
|
||||
|
||||
Level 2 — inspectable world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Embedded interpreter. MUD can READ z-machine state. Know what room the player is in, describe it to spectators, track progress. Spectators don't just see text — they know "jared is in the Trophy Room." Read-only bridge between MUD and IF world.
|
||||
|
||||
Level 3 — moldable world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Embedded interpreter. MUD can READ AND WRITE z-machine state. Inject items into IF game objects. Put a note in the Zork mailbox. The z-machine object tree (parent/child/sibling with attributes and properties) becomes accessible from MUD code. Two-way bridge. Game world is modifiable from outside.
|
||||
|
||||
Level 4 — shared world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Multiple MUD players mapped into the same z-machine instance. Each has their own player object. Independent inventory and position. MojoZork's MultiZork proved this works for V3 games. The IF world is a zone in the MUD that multiple players inhabit simultaneously.
|
||||
|
||||
Level 5 — transcendent world
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Z-machine object tree and MUD entity model are unified. An item in Zork IS a MUD item. Pick it up in the IF world, carry it back to the MUD. The mailbox exists at coordinates in both worlds simultaneously. Full bidirectional entity bridge.
|
||||
|
||||
Note: Level 1 uses subprocess. Levels 2-5 require being inside the interpreter. Once you're at level 2, the jump to 3 is small (reading memory vs writing it). Level 4 is the MojoZork leap. Level 5 is the dream.
|
||||
|
||||
what we know — audit findings
|
||||
------------------------------
|
||||
|
||||
Two Python z-machine interpreters were audited in detail. Don't repeat everything here — see the audit docs for details. Focus on decision-relevant facts.
|
||||
|
||||
viola (DFillmore/viola)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Can run games today. All V1-V5 opcodes implemented, partial V6-V8. But global state is deeply tangled (13/18 modules with mutable globals). Multiple instances in one process: not feasible without major refactor.
|
||||
|
||||
pygame dependency is cleanly separable (adapter pattern, unidirectional). ``sys.exit()`` in error paths (8+ locations) — needs patching for server use. Memory leaks (5+ unbounded growth patterns) — fixable with cleanup hooks.
|
||||
|
||||
Object tree accessors exist and are wired through all opcodes. Full quetzal save/restore working.
|
||||
|
||||
See: ``docs/how/viola-embedding-audit.rst``
|
||||
|
||||
zvm (sussman/zvm)
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
Cannot run any game. ~46/108 opcodes implemented, including ZERO input opcodes. But instance-based state (mostly clean, two minor global leaks fixable in ~20 lines). Multiple instances in one process: structurally possible.
|
||||
|
||||
IO abstraction is excellent — purpose-built for embedding (abstract ZUI with stubs). Proper exception hierarchy, zero ``sys.exit()`` calls. No memory leaks, clean bounded state.
|
||||
|
||||
Object tree parser has complete read API, mostly complete write API. BUT: many object opcodes not wired up at CPU level. Save parser works, save writer is stubbed.
|
||||
|
||||
See: ``docs/how/zvm-embedding-audit.rst``
|
||||
|
||||
The verdict from the audits: "zvm has the architecture you'd want. viola has the implementation you'd need."
|
||||
|
||||
MojoZork (C, Ryan C. Gordon)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
V3 only but has working multiplayer telnet server (MultiZork). Proves multiplayer z-machine works (up to 4 players, separate inventories). ``runInstruction()`` for single-step execution — the async pattern we want. Excellent reference architecture, not directly usable (C only).
|
||||
|
||||
the hybrid path — option D
|
||||
---------------------------
|
||||
|
||||
This emerged from comparing the audits side by side. Use zvm's architecture (clean IO abstraction, instance-based state, exception model) as the skeleton. Port viola's working opcode implementations into it.
|
||||
|
||||
Why this is attractive:
|
||||
|
||||
- zvm's opcodes take ``self`` (ZCpu) and access state through instance attributes
|
||||
- viola's opcodes use module-level globals (``zcode.game.PC``, ``zcode.memory.data``, etc)
|
||||
- porting means translating global lookups to instance attribute lookups
|
||||
- that's mechanical, not creative — could port 5-10 opcodes per hour once the pattern is established
|
||||
- gets the clean design with the working code
|
||||
|
||||
Why this could be harder than it sounds:
|
||||
|
||||
- viola and zvm may represent z-machine internals differently
|
||||
- memory layout assumptions, stack frame format, string encoding details
|
||||
- porting opcodes may require porting the data structures they operate on
|
||||
- need to verify each ported opcode against the z-machine spec, not just translate
|
||||
|
||||
Estimated effort: medium. Less than finishing zvm from scratch, less than refactoring viola's globals. But not trivial.
|
||||
|
||||
the object tree — key to moldable worlds
|
||||
-----------------------------------------
|
||||
|
||||
This is what makes levels 3-5 possible. The z-machine has an object tree — every game entity is a node with parent/child/sibling pointers, attributes (boolean flags), and properties (variable-length data).
|
||||
|
||||
What the object tree gives us:
|
||||
|
||||
- Read what room the player is in (player object's parent)
|
||||
- Read container contents (children of the container object)
|
||||
- Inject items (create objects, parent them to containers)
|
||||
- Modify game state (set/clear attributes, change properties)
|
||||
- Query the dictionary (what words the parser recognizes)
|
||||
|
||||
Both interpreters have object tree parsers:
|
||||
|
||||
- viola: complete read + write, all opcodes wired, working
|
||||
- zvm: complete read, mostly complete write (missing ``set_attr``/``clear_attr``), many opcodes unwired at CPU level, bug in ``insert_object``
|
||||
|
||||
The dictionary problem (level 3+)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Injecting an object into the mailbox works for "look in mailbox" — the game iterates children and prints short names. But "take [new item]" fails unless the word exists in the game's dictionary. The dictionary is baked into the story file.
|
||||
|
||||
Options:
|
||||
|
||||
- Add words to dictionary at runtime (memory surgery — relocating/expanding the dictionary)
|
||||
- Intercept input before the parser and handle custom items at the MUD layer
|
||||
- Use existing dictionary words for injected items ("note", "scroll", "key" are common)
|
||||
- Hybrid: intercept unrecognized words, check if they match MUD-injected items, handle outside z-machine
|
||||
|
||||
This is a level 3-5 problem. Not a blocker for levels 1-2.
|
||||
|
||||
games we care about
|
||||
-------------------
|
||||
|
||||
The games that motivated this work:
|
||||
|
||||
The Wizard Sniffer (Buster Hudson, 2017)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You play a pig who sniffs out wizards. IFComp winner, XYZZY winner. Screwball comedy. The pig/wizard game that started this. Z-machine format. Would need V5 support.
|
||||
|
||||
Lost Pig (Admiral Jota, 2007)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Grunk the orc chases an escaped pig. IFComp winner, 4 XYZZY awards. Famous for its responsive parser and comedy writing. Z-machine format. V5.
|
||||
|
||||
Zork I, II, III
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
The classics. Everyone should play them. V3.
|
||||
|
||||
Hitchhiker's Guide to the Galaxy
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Funny, frustrating, great spectator game. V3.
|
||||
|
||||
Also: Anchorhead, Photopia, Spider and Web, Shade, Colossal Cave.
|
||||
|
||||
V3 covers the Infocom catalog. V5 covers most modern IF including the pig games. V8 covers big modern Inform 7 games but is lower priority.
|
||||
|
||||
Note: no Python Glulx interpreter exists. Games that target Glulx (some modern Inform 7) are out of scope unless we subprocess to a C interpreter.
|
||||
|
||||
architecture fit
|
||||
----------------
|
||||
|
||||
How this fits the existing MUD architecture. The codebase is ready:
|
||||
|
||||
Mode stack
|
||||
~~~~~~~~~~
|
||||
|
||||
Push "if" mode. All input routes to IF handler, bypassing command dispatch. Same pattern as editor mode. Already proven.
|
||||
|
||||
Input routing
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
``server.py`` shell loop checks ``player.mode``. Add elif for "if" mode, route to ``if_game.handle_input()``. Same as ``editor.handle_input()``.
|
||||
|
||||
Room-local broadcasting
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Not implemented yet. IF integration is the first use case. Terminal object maintains spectator list, broadcasts output to all. Pattern will be reused for ambient messages, weather, room events.
|
||||
|
||||
State storage
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Quetzal saves as blobs in SQLite. Same pattern as other binary persistence.
|
||||
|
||||
Terminal game object
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A room object (or coordinate-anchored object) that hosts IF sessions. Players "use" it to enter IF mode. Pattern extends to other interactive objects.
|
||||
|
||||
open questions
|
||||
--------------
|
||||
|
||||
Things we haven't figured out yet. Update this as questions get answered.
|
||||
|
||||
1. V3 opcode footprint
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
How many of the ~62 missing zvm opcodes are actually exercised by V3 games? V3 uses a smaller subset. If we target V3 first, the hybrid might need 30 ported, not 62. Research: run a V3 game through viola with opcode tracing, collect the set.
|
||||
|
||||
2. zvm/viola memory layout compatibility
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Do they represent z-machine memory the same way? Both use bytearrays, but header parsing, object table offsets, string encoding — are these compatible enough that porting opcodes is translation, or is it a deeper rewrite?
|
||||
|
||||
3. Async model
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Both interpreters have blocking run loops. Options:
|
||||
|
||||
- ``run_in_executor`` (thread pool) — standard pattern, adds latency
|
||||
- extract ``step()`` and call from async loop — zvm audit says this is ~5 lines
|
||||
- run in separate thread with queue-based IO — more complex but natural
|
||||
|
||||
Which is best for the MUD's tick-based game loop?
|
||||
|
||||
4. Multiplayer z-machine
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
MojoZork does this for V3. What would it take for V5? The V5 object model is larger (65535 objects vs 255). Do V5 games assume single-player in ways that break multiplayer?
|
||||
|
||||
5. Game file licensing
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Infocom games are abandonware but not legally free. Modern IF games (Lost Pig, Wizard Sniffer) are freely distributable. Need to figure out what we can bundle vs what players bring.
|
||||
|
||||
6. Dictionary injection feasibility
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
How hard is it to add words to a z-machine dictionary at runtime? The dictionary is in static memory. Adding words means expanding it, which means relocating it if there's no space. Is this practical?
|
||||
|
||||
what to do next
|
||||
---------------
|
||||
|
||||
Concrete next steps, roughly ordered. Update as items get done.
|
||||
|
||||
- [ ] trace V3 opcode usage: run zork through viola with opcode logging, get the actual set of opcodes a real game uses. this tells us how much porting work the hybrid path actually requires.
|
||||
|
||||
- [ ] compare memory layouts: look at how viola and zvm represent z-machine memory, object tables, string tables. determine if opcode porting is mechanical translation or deeper adaptation.
|
||||
|
||||
- [ ] prototype the hybrid: pick 5-10 common opcodes, port them from viola to zvm's architecture. see how the pattern feels. if it's smooth, the hybrid is viable. if every opcode is a battle, reconsider.
|
||||
|
||||
- [ ] build level 1 prototype: regardless of interpreter choice, implement the terminal object, IF mode, and subprocess dfrotz path. this proves the MUD-side architecture (mode stack, spectators, save/restore) independently of the interpreter question.
|
||||
|
||||
- [ ] study MojoZork's multiplayer model: read the MultiZork source for how it handles multiple players in one z-machine. document the pattern for our eventual level 4.
|
||||
|
||||
- [ ] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available).
|
||||
|
||||
related documents
|
||||
-----------------
|
||||
|
||||
``docs/how/if-integration.txt`` — original research and integration plan (predates audits)
|
||||
|
||||
``docs/how/viola-embedding-audit.rst`` — detailed viola architecture audit
|
||||
|
||||
``docs/how/zvm-embedding-audit.rst`` — detailed zvm architecture audit with comparison
|
||||
|
||||
``docs/how/architecture-plan.txt`` — MUD engine architecture plan
|
||||
|
||||
``DREAMBOOK.md`` — project vision and philosophy
|
||||
156
docs/how/viola-embedding-audit.rst
Normal file
156
docs/how/viola-embedding-audit.rst
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
=============================
|
||||
viola embedding audit — Z-machine interpreter feasibility
|
||||
=============================
|
||||
|
||||
viola is a Python Z-machine interpreter being evaluated for embedding in mudlib
|
||||
to run interactive fiction games over telnet. this audit covers architecture,
|
||||
isolation requirements, and modification paths.
|
||||
|
||||
|
||||
1. global state — fork-level entangled
|
||||
=======================================
|
||||
|
||||
13 of 18 modules in zcode/ have mutable module-level globals.
|
||||
|
||||
critical three:
|
||||
|
||||
- game.py — PC, callstack, currentframe, undolist, interruptstack. modified by
|
||||
20+ functions across multiple modules via global declarations
|
||||
- memory.py — data (entire Z-machine memory as a bytearray). read/written by
|
||||
nearly every module
|
||||
- screen.py — currentWindow, zwindow list, color spectrum dict. all mutated at
|
||||
runtime
|
||||
|
||||
every module does ``import zcode`` then accesses ``zcode.game.PC``,
|
||||
``zcode.memory.data``, etc. hardcoded module-level lookups, not
|
||||
dependency-injected. state mutation touches 1000+ function calls.
|
||||
|
||||
wrapping in a class would require threading a state parameter through the
|
||||
entire codebase. process-level isolation (subprocess per game) is the realistic
|
||||
path for concurrent games.
|
||||
|
||||
|
||||
2. pygame boundary — clean
|
||||
===========================
|
||||
|
||||
zero pygame imports in zcode/. architecture uses adapter pattern:
|
||||
|
||||
- zcode/ imports only vio.zcode as io (aliased)
|
||||
- vio/zcode.py contains ALL pygame calls (1044 lines)
|
||||
- dependency is unidirectional — vio.zcode never imports from zcode/
|
||||
- zcode/ extends io classes via inheritance (window, font, sound channels)
|
||||
|
||||
can swap vio/zcode.py for a telnet IO adapter without touching VM core.
|
||||
|
||||
|
||||
3. error paths — server-hostile
|
||||
================================
|
||||
|
||||
8+ locations call sys.exit() directly:
|
||||
|
||||
- memory out-of-bounds (5 locations in memory.py)
|
||||
- invalid opcode (opcodes.py)
|
||||
- division by zero (numbers.py)
|
||||
- corrupt story file (viola.py)
|
||||
|
||||
fatal path: ``zcode.error.fatal()`` -> ``sys.exit()``. not catchable exceptions.
|
||||
|
||||
fix: patch error.fatal() to raise a custom exception instead.
|
||||
|
||||
|
||||
4. version coverage — V1-V5 solid, V6 partial, V7-V8 theoretical
|
||||
=================================================================
|
||||
|
||||
- V1-V5: all opcodes implemented. most IF games are V3 or V5.
|
||||
- V6: graphics framework exists but 3 opcodes marked "unfinished"
|
||||
- V7-V8: supported at memory/opcode level but likely never tested
|
||||
- no test suite in the repo
|
||||
|
||||
for a MUD, V3 and V5 cover Zork, Curses, Anchorhead, etc.
|
||||
|
||||
|
||||
5. input model — moderate-to-hard adapter needed
|
||||
=================================================
|
||||
|
||||
line input (READ):
|
||||
easy — maps naturally to telnet line mode
|
||||
|
||||
single char (READ_CHAR):
|
||||
hard — needs raw telnet mode or buffering
|
||||
|
||||
timed input:
|
||||
hard — needs async server-side timer
|
||||
|
||||
mouse input:
|
||||
impossible — stub it
|
||||
|
||||
arrow/function keys:
|
||||
moderate — parse ANSI escape sequences
|
||||
|
||||
pygame is hardwired with no abstraction layer. need to create a TelnetInput
|
||||
class implementing the same interface as vio.zcode.input. interface surface is
|
||||
small: getinput, starttimer, stoptimer.
|
||||
|
||||
|
||||
6. step execution mode — moderate refactor, feasible
|
||||
=====================================================
|
||||
|
||||
main loop in routines.py:60 is clean::
|
||||
|
||||
while not quit and not restart:
|
||||
check interrupts -> decode(PC) -> runops(oldpc)
|
||||
|
||||
one instruction = decode + execute. only blocking call is pygame.event.wait()
|
||||
at bottom of input stack. natural yield points are input requests.
|
||||
|
||||
execute_one() API achievable by:
|
||||
|
||||
1. making IO non-blocking
|
||||
2. converting z_read()'s inner while loop to a state machine
|
||||
3. unwrapping recursive interrupt_call() -> execloop() pattern
|
||||
|
||||
|
||||
7. memory/cleanup — will leak, fixable
|
||||
=======================================
|
||||
|
||||
unbounded growth:
|
||||
|
||||
- undo stack (game.py:23) — each undo saves full memory copy (MBs). never
|
||||
cleared.
|
||||
- command history (input.py:22) — every input appended forever
|
||||
- static routine cache (routines.py:30) — never cleared
|
||||
- static word cache (memory.py:108) — never cleared
|
||||
- object caches (objects.py:18-26) — 4 dicts, never purged
|
||||
|
||||
estimate: 10-100+ MB/hour depending on undo usage. fixable by adding cleanup
|
||||
calls to restart/setup path.
|
||||
|
||||
|
||||
8. IFIFF submodule — quetzal is independent
|
||||
============================================
|
||||
|
||||
- quetzal (saves) and blorb (multimedia) are architecturally separate
|
||||
- zcode/game.py imports only quetzal — no blorb references in save/restore
|
||||
- quetzal API: qdata(), save(), restore()
|
||||
- pure stdlib, no external deps
|
||||
- can cherry-pick quetzal.py without pulling in blorb
|
||||
|
||||
|
||||
priority for upstream PRs
|
||||
==========================
|
||||
|
||||
1. patch error.fatal() to raise instead of sys.exit() — small effort, unblocks
|
||||
server use
|
||||
2. add cleanup hooks for caches/undo on restart — small effort, fixes memory
|
||||
leaks
|
||||
3. create IO abstraction interface — medium effort, enables telnet adapter
|
||||
4. step execution mode (execute_one()) — medium effort, enables async embedding
|
||||
5. extract quetzal as standalone — small effort, clean dependency
|
||||
|
||||
|
||||
pragmatic path
|
||||
==============
|
||||
|
||||
the global state issue is bypassed with subprocess isolation (one Python
|
||||
process per game). fine for a MUD with less than 100 concurrent players.
|
||||
process-per-game sidesteps the entire global state refactor.
|
||||
377
docs/how/zvm-embedding-audit.rst
Normal file
377
docs/how/zvm-embedding-audit.rst
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
=============================
|
||||
zvm embedding audit — Z-machine interpreter feasibility
|
||||
=============================
|
||||
|
||||
zvm is a Python Z-machine interpreter (by Ben Collins-Sussman) being evaluated
|
||||
for embedding in mudlib to run interactive fiction games over telnet. this audit
|
||||
covers architecture, isolation requirements, and modification paths. compared
|
||||
against the viola audit for apples-to-apples decision-making.
|
||||
|
||||
|
||||
1. global state — mostly clean, two leaks
|
||||
==========================================
|
||||
|
||||
state is instance-based. ``ZMachine.__init__`` wires everything together::
|
||||
|
||||
ZMemory(story) -- memory
|
||||
ZStringFactory(mem) -- string decoding
|
||||
ZObjectParser(mem) -- object tree
|
||||
ZStackManager(mem) -- call/data stacks
|
||||
ZOpDecoder(mem, stack) -- instruction decoder
|
||||
ZStreamManager(mem, ui) -- I/O streams
|
||||
ZCpu(mem, opdecoder, stack, objects, string, streams, ui) -- CPU
|
||||
|
||||
dependency graph flows one way: ZCpu depends on everything else, everything
|
||||
else depends on ZMemory. no circular dependencies. clean layering.
|
||||
|
||||
two global leaks:
|
||||
|
||||
- zlogging.py — executes at import time. opens debug.log and disasm.log in cwd,
|
||||
sets root logger to DEBUG. all instances share the same loggers
|
||||
- zcpu.py uses ``random.seed()`` / ``random.randint()`` — global PRNG state.
|
||||
multiple instances would interfere with each other's randomness
|
||||
|
||||
both fixable with ~20 lines. the logging can be made instance-scoped, the PRNG
|
||||
replaced with ``random.Random()`` instances.
|
||||
|
||||
compared to viola's 13/18 modules with mutable globals, this is dramatically
|
||||
better. multiple ZMachine instances in one process is structurally possible.
|
||||
|
||||
|
||||
2. IO boundary — excellent, purpose-built for embedding
|
||||
========================================================
|
||||
|
||||
this is zvm's strongest feature. the README explicitly states the design goal:
|
||||
"no user interface. meant to be used as the backend in other programs."
|
||||
|
||||
IO is fully abstracted via a four-component ``ZUI`` object (zui.py)::
|
||||
|
||||
ZUI(audio, screen, keyboard_input, filesystem)
|
||||
|
||||
each component is an abstract base class with NotImplementedError stubs:
|
||||
|
||||
- ZScreen (zscreen.py) — write(), split_window(), select_window(),
|
||||
set_cursor_position(), erase_window(), set_text_style(), set_text_color()
|
||||
- ZInputStream (zstream.py) — read_line(), read_char() with full signatures
|
||||
for timed input, max length, terminating characters
|
||||
- ZFilesystem (zfilesystem.py) — save_game(), restore_game(),
|
||||
open_transcript_file_for_writing/reading()
|
||||
- ZAudio (zaudio.py) — play_bleep(), play_sound_effect()
|
||||
|
||||
trivialzui.py is the reference stdio implementation showing how to subclass.
|
||||
|
||||
for MUD embedding: implement ZScreen.write() to push to telnet session,
|
||||
ZInputStream.read_line() to receive from telnet reader, ZFilesystem to store
|
||||
saves in SQLite. natural fit.
|
||||
|
||||
compared to viola where pygame is hardwired and you'd need to create a
|
||||
TelnetInput class from scratch, zvm hands you the interface contract.
|
||||
|
||||
|
||||
3. error paths — server-friendly
|
||||
=================================
|
||||
|
||||
zero sys.exit() calls in the zvm/ package. only sys.exit() is in the CLI
|
||||
runner run_story.py, which is appropriate.
|
||||
|
||||
clean exception hierarchy:
|
||||
|
||||
- ZMachineError (zmachine.py)
|
||||
- ZCpuError -> ZCpuIllegalInstruction, ZCpuDivideByZero, ZCpuNotImplemented
|
||||
- ZMemoryError -> ZMemoryIllegalWrite, ZMemoryOutOfBounds, ZMemoryBadMemoryLayout
|
||||
- ZObjectError -> ZObjectIllegalObjectNumber, ZObjectIllegalAttributeNumber
|
||||
- ZStackError -> ZStackNoRoutine, ZStackNoSuchVariable, ZStackPopError
|
||||
- QuetzalError -> QuetzalMalformedChunk, QuetzalMismatchedFile
|
||||
|
||||
CPU run loop does not catch exceptions — errors propagate to the caller.
|
||||
correct behavior for embedding.
|
||||
|
||||
one weakness: some opcodes have bare ``assert`` statements that would be
|
||||
stripped with ``python -O``.
|
||||
|
||||
compared to viola's 8+ sys.exit() calls via error.fatal(), this is exactly
|
||||
what you want for a server.
|
||||
|
||||
|
||||
4. version coverage — V1-V5 declared, V3-V5 structural
|
||||
========================================================
|
||||
|
||||
zmemory.py handles v1-v5 with version-switched code throughout. v6-v8 are
|
||||
not supported (raises ZMemoryUnsupportedVersion).
|
||||
|
||||
opcode table (zcpu.py) has version annotations like ``(op_call_2s, 4)``
|
||||
meaning "available from v4 onward". systematic and well-done.
|
||||
|
||||
v1-v3 object model uses 1-byte pointers (max 255 objects), v4-v5 uses
|
||||
2-byte (max 65535). properly handled in zobjectparser.py.
|
||||
|
||||
test suite only uses curses.z5 — v1-v3 support exists structurally but has
|
||||
untested code paths.
|
||||
|
||||
compared to viola's solid V1-V5 plus partial V6 and theoretical V7-V8,
|
||||
zvm is narrower but the architecture is cleaner where it does exist.
|
||||
|
||||
|
||||
5. input model — abstracted but unimplemented
|
||||
===============================================
|
||||
|
||||
line input (read_line):
|
||||
interface complete — full signature with original_text, max_length,
|
||||
terminating_characters, timed_input_routine, timed_input_interval.
|
||||
trivial implementation handles basic line editing, ignores timed input.
|
||||
|
||||
single char (read_char):
|
||||
interface complete — signature includes timed_input_routine and
|
||||
timed_input_interval. CPU explicitly raises ZCpuNotImplemented if
|
||||
time/routine are nonzero.
|
||||
|
||||
timed input:
|
||||
feature flag system exists (``features["has_timed_input"] = False``).
|
||||
completely unimplemented at CPU level.
|
||||
|
||||
**critical problem**: op_sread (v1-v3) has an empty body — silently does
|
||||
nothing. op_sread_v4 and op_aread (v5) both raise ZCpuNotImplemented.
|
||||
the interpreter cannot accept text input. it cannot reach the first prompt
|
||||
of any game.
|
||||
|
||||
compared to viola where input works and needs an adapter, zvm has the
|
||||
better interface design but no working implementation behind it.
|
||||
|
||||
|
||||
6. step execution mode — not available, easy refactor
|
||||
======================================================
|
||||
|
||||
run loop in zcpu.py::
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
(opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction()
|
||||
implemented, func = self._get_handler(opcode_class, opcode_number)
|
||||
if not implemented:
|
||||
break
|
||||
func(self, *operands)
|
||||
|
||||
tight while-True with no yielding. no step() method, no async support, no
|
||||
callbacks between instructions, no instruction count limit.
|
||||
|
||||
the loop body is clean and self-contained though. extracting step() is a ~5
|
||||
line change — pull the body into a method, call it from run().
|
||||
|
||||
for async MUD embedding, options are:
|
||||
|
||||
1. extract step() and call from an async loop with awaits at IO points
|
||||
2. run each ZMachine in a thread (viable since state is instance-based)
|
||||
|
||||
compared to viola's similar tight loop, the refactor here is actually easier
|
||||
because the body is simpler (no interrupt handling, no recursive execloop).
|
||||
|
||||
|
||||
7. memory/cleanup — clean, no leak risk
|
||||
=========================================
|
||||
|
||||
memory is a bytearray, fixed size, bounded by story file.
|
||||
ZMachine.__init__ creates two copies (pristine + working).
|
||||
|
||||
ZStackManager uses a Python list as call stack. frames are popped in
|
||||
finish_routine(). bounded by Z-machine stack design.
|
||||
|
||||
ZStringFactory, ZCharTranslator, ZLexer all pre-load from story file at init.
|
||||
bounded by file content.
|
||||
|
||||
ZObjectParser does not cache anything — every access reads directly from
|
||||
ZMemory.
|
||||
|
||||
one minor leak: quetzal.py QuetzalParser stores self._file and closes it
|
||||
manually but does not use a context manager. exception during parsing would
|
||||
leak the file handle.
|
||||
|
||||
compared to viola's 5+ unbounded growth patterns (undo stack, command history,
|
||||
routine cache, word cache, object caches), zvm is dramatically cleaner.
|
||||
|
||||
|
||||
8. object tree API — complete read, mostly complete write
|
||||
==========================================================
|
||||
|
||||
zobjectparser.py public API:
|
||||
|
||||
read:
|
||||
- get_attribute(objectnum, attrnum) — single attribute (0/1)
|
||||
- get_all_attributes(objectnum) — list of all set attribute numbers
|
||||
- get_parent(objectnum) — parent object number
|
||||
- get_child(objectnum) — first child
|
||||
- get_sibling(objectnum) — next sibling
|
||||
- get_shortname(objectnum) — object's short name as string
|
||||
- get_prop(objectnum, propnum) — property value
|
||||
- get_prop_addr_len(objectnum, propnum) — property address and length
|
||||
- get_all_properties(objectnum) — dict of all properties
|
||||
- describe_object(objectnum) — debug pretty-printer
|
||||
|
||||
write:
|
||||
- set_parent(objectnum, new_parent_num)
|
||||
- set_child(objectnum, new_child_num)
|
||||
- set_sibling(objectnum, new_sibling_num)
|
||||
- insert_object(parent_object, new_child) — handles unlinking
|
||||
- set_property(objectnum, propnum, value)
|
||||
|
||||
missing: set_attribute() and clear_attribute(). the parser can read attributes
|
||||
but cannot set them. would need to be added.
|
||||
|
||||
bug in insert_object(): sibling walk loop at line 273 never advances current
|
||||
or prev — would infinite-loop. needs fix.
|
||||
|
||||
**critical for MUD embedding**: many CPU opcodes that USE the parser are
|
||||
unimplemented at the CPU level:
|
||||
|
||||
- op_test_attr, op_set_attr, op_clear_attr — not wired up
|
||||
- op_get_sibling — not wired up (parser method exists)
|
||||
- op_jin (test parent) — not wired up
|
||||
- op_remove_obj, op_print_obj — not wired up
|
||||
- op_get_prop_addr, op_get_next_prop, op_get_prop_len — not wired up
|
||||
|
||||
the parser infrastructure is there and correct. the CPU just doesn't call it.
|
||||
|
||||
compared to viola's zcode/objects.py which has working accessors wired through
|
||||
all opcodes, zvm has a better parser design but the plumbing is incomplete.
|
||||
|
||||
|
||||
9. save/restore — parse works, write is stubbed
|
||||
=================================================
|
||||
|
||||
quetzal.py QuetzalParser can parse Quetzal save files:
|
||||
- IFhd chunks (metadata) — working
|
||||
- CMem chunks (compressed memory) — working
|
||||
- UMem chunks (uncompressed memory) — has a bug (wrong attribute name)
|
||||
- Stks chunks (stack frames) — working
|
||||
|
||||
QuetzalWriter is almost entirely stubbed. all three data methods return "0".
|
||||
file writing logic exists but writes nonsense.
|
||||
|
||||
all save/restore CPU opcodes (op_save, op_restore, op_save_v4, op_restore_v4,
|
||||
op_save_v5, op_restore_v5, op_save_undo, op_restore_undo) raise
|
||||
ZCpuNotImplemented.
|
||||
|
||||
alternative for MUD: since ZMemory is a bytearray, could snapshot/restore
|
||||
raw memory + stack state directly without Quetzal format.
|
||||
|
||||
compared to viola's working quetzal implementation, zvm's save system needs
|
||||
significant work.
|
||||
|
||||
|
||||
10. test suite — minimal
|
||||
=========================
|
||||
|
||||
5 registered test modules:
|
||||
|
||||
- bitfield_tests.py — 6 tests, BitField bit manipulation
|
||||
- zscii_tests.py — 4 tests, string encoding/decoding
|
||||
- lexer_tests.py — 3 tests, dictionary parsing
|
||||
- quetzal_tests.py — 2 tests, save file parsing
|
||||
- glk_tests.py — 7 tests, requires compiled CheapGlk .so
|
||||
|
||||
not tested at all: ZCpu (no opcode tests), ZMemory, ZObjectParser,
|
||||
ZStackManager, ZOpDecoder, ZStreamManager.
|
||||
|
||||
all tests that need a story file use stories/curses.z5 with hardcoded paths.
|
||||
|
||||
compared to viola which has no tests at all, zvm has some but they don't
|
||||
cover the critical subsystems.
|
||||
|
||||
|
||||
11. dependencies — zero
|
||||
========================
|
||||
|
||||
pure stdlib. setup.py declares no dependencies. python >= 3.6.
|
||||
|
||||
uses: logging, random, time, itertools, re, chunk, os, sys.
|
||||
ctypes only for optional Glk native integration.
|
||||
|
||||
both viola and zvm are pure stdlib. no advantage either way.
|
||||
|
||||
|
||||
12. completeness — the dealbreaker
|
||||
====================================
|
||||
|
||||
of ~108 Z-machine opcodes, zvm implements approximately 46. the remaining ~62
|
||||
are stubbed with ZCpuNotImplemented or have empty bodies.
|
||||
|
||||
unimplemented opcodes include fundamentals:
|
||||
|
||||
- ALL input opcodes (op_sread, op_aread) — cannot accept player input
|
||||
- ALL save/restore opcodes — cannot save or load games
|
||||
- critical object opcodes (test_attr, set_attr, clear_attr, get_sibling,
|
||||
remove_obj, print_obj at CPU level)
|
||||
- many branch/comparison ops
|
||||
- string printing variants
|
||||
|
||||
the interpreter cannot execute any real interactive fiction game to
|
||||
completion. it cannot reach the first prompt of Zork.
|
||||
|
||||
|
||||
verdict — comparison with viola
|
||||
================================
|
||||
|
||||
+---------------------+--------------+----------------+
|
||||
| criterion | zvm | viola |
|
||||
+---------------------+--------------+----------------+
|
||||
| IO abstraction | excellent | needs adapter |
|
||||
| global state | mostly clean | deeply tangled |
|
||||
| multi-instance | structurally | process-only |
|
||||
| error handling | exceptions | sys.exit() |
|
||||
| memory leaks | none | 5+ patterns |
|
||||
| object tree parser | complete | complete |
|
||||
| object tree opcodes | ~half wired | all wired |
|
||||
| opcode coverage | ~46/108 | all V1-V5 |
|
||||
| can run a game | NO | YES |
|
||||
| input handling | abstracted | working |
|
||||
| save/restore | parse only | working |
|
||||
| dependencies | zero | zero |
|
||||
| tests | minimal | none |
|
||||
| maintenance | abandoned | abandoned |
|
||||
+---------------------+--------------+----------------+
|
||||
|
||||
zvm has the architecture you'd want. viola has the implementation you'd need.
|
||||
|
||||
zvm is a well-designed skeleton — clean IO abstraction, instance-based state,
|
||||
proper exceptions, no memory leaks. but it's roughly half-built. finishing the
|
||||
62 missing opcodes is weeks of work equivalent to writing a new interpreter,
|
||||
except you're also debugging someone else's partial implementation.
|
||||
|
||||
viola is a working interpreter with terrible architecture for embedding —
|
||||
global state everywhere, sys.exit() in error paths, pygame hardwired. but it
|
||||
can run Zork right now. the refactoring targets are known and bounded.
|
||||
|
||||
|
||||
pragmatic path
|
||||
===============
|
||||
|
||||
the "moldable world" vision (levels 3-5 from the design discussion) requires
|
||||
being inside the interpreter with access to the object tree. both interpreters
|
||||
have the parser infrastructure for this.
|
||||
|
||||
option A — fix viola's embedding problems:
|
||||
1. patch error.fatal() to raise (small)
|
||||
2. swap pygame IO for telnet adapter (medium)
|
||||
3. add cleanup hooks for caches (small)
|
||||
4. subprocess isolation handles global state (free)
|
||||
total: working IF in a MUD, with known limitations on multi-instance
|
||||
|
||||
option B — finish zvm's implementation:
|
||||
1. implement ~62 missing opcodes (large)
|
||||
2. fix insert_object bug (small)
|
||||
3. add set_attribute/clear_attribute to parser (small)
|
||||
4. complete save writer (medium)
|
||||
total: clean embeddable interpreter, but weeks of opcode work first
|
||||
|
||||
option C — write our own interpreter:
|
||||
designed for embedding from day one. state is a first-class object. object
|
||||
tree is an API. multiple games in one process. but it's the longest path and
|
||||
testing against real games is the hard part.
|
||||
|
||||
option D — hybrid:
|
||||
use zvm's architecture (ZUI interface, exception model, instance-based state)
|
||||
as the skeleton. port viola's working opcode implementations into it. gets
|
||||
the clean design with the working code. medium effort, high reward.
|
||||
|
||||
the hybrid path is probably the most interesting. zvm got the hard design
|
||||
decisions right. viola got the hard implementation work done. merging the two
|
||||
is less work than either finishing zvm or refactoring viola.
|
||||
Loading…
Reference in a new issue