Compare commits

..

7 commits

Author SHA1 Message Date
305f25e77a
Add spawn command and wire mobs into server
Phase 6: spawn command creates mobs at player position from loaded
templates. Server loads mob templates from content/mobs/ at startup,
injects world into combat/commands module, and runs process_mobs()
each game loop tick after process_combat().
2026-02-09 11:43:37 -05:00
42bb3fa046
Add mob AI for combat decisions
Phase 5: process_mobs() runs each tick, handling mob attack and defense
decisions. Mobs pick random attacks from their move list when IDLE,
swap roles if needed, and attempt defense during TELEGRAPH/WINDOW with
a 40% chance of correct counter. 1-second cooldown between actions.
Training dummies with empty moves never fight back.
2026-02-09 11:43:37 -05:00
86c2c46dfa
Handle mob defeat in combat resolution
Phase 4: when combat ends, determine winner/loser. If the loser is a
Mob, despawn it and send a victory message to the winner. If the loser
is a Player fighting a Mob, send a defeat message instead.
2026-02-09 11:43:37 -05:00
f2d52c4936
Render mobs as * in viewport
Phase 3: look command now collects alive mob positions using the same
wrapping-aware relative position calc as players, and renders them as *
with the same priority as other players (after @ but before effects).
2026-02-09 11:43:37 -05:00
1fa21e20b0
Add mob target resolution in combat commands
Phase 2: do_attack now searches the mobs registry after players dict
when resolving a target name. Players always take priority over mobs
with the same name. World instance injected into combat/commands module
for wrapping-aware mob proximity checks.
2026-02-09 11:43:37 -05:00
2bab61ef8c
Add mob templates, registry, and spawn/despawn/query
Phase 1 of fightable mobs: MobTemplate dataclass loaded from TOML,
global mobs list, spawn_mob/despawn_mob/get_nearby_mob with
wrapping-aware distance. Mob entity gets moves and next_action_at fields.
2026-02-09 11:43:37 -05:00
ed6ffbdc5d
Add research on IF possibilities 2026-02-09 11:38:12 -05:00
3 changed files with 1311 additions and 0 deletions

676
docs/how/if-integration.txt Normal file
View 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
View 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

View 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.