Compare commits
No commits in common. "c91d6a49937682e3e31105bff9677386ead78763" and "1b3a3646d6f1d1e99a28b5004038742319a99227" have entirely different histories.
c91d6a4993
...
1b3a3646d6
3 changed files with 6 additions and 943 deletions
|
|
@ -1,622 +0,0 @@
|
||||||
==========================================
|
|
||||||
multiplayer z-machine — shared world design
|
|
||||||
==========================================
|
|
||||||
|
|
||||||
level 4 means multiple MUD players sharing one z-machine instance. one game
|
|
||||||
world, multiple explorers. MojoZork proved this works for Zork 1 with 4
|
|
||||||
concurrent players over telnet. this doc designs how it works in our Python
|
|
||||||
interpreter, using MojoZork's lessons but improving on its limitations.
|
|
||||||
|
|
||||||
see ``docs/how/mojozork-audit.rst`` for the full MojoZork technical analysis.
|
|
||||||
see ``docs/how/if-journey.rst`` for the five-level vision (this is level 4).
|
|
||||||
|
|
||||||
|
|
||||||
why this is hard
|
|
||||||
================
|
|
||||||
|
|
||||||
the z-machine is a single-player virtual machine. one program counter, one
|
|
||||||
stack, one set of global variables. the game assumes there's one "you" in
|
|
||||||
the world. multiplayer means faking multiple players inside a system that
|
|
||||||
was never designed for it.
|
|
||||||
|
|
||||||
MojoZork solved this with brute force: swap the entire execution context
|
|
||||||
(PC, stack, globals) every time a different player takes a turn. it works,
|
|
||||||
but it's hardcoded to one build of Zork 1 — 10 specific global variable
|
|
||||||
indices, 6 bytecode patch addresses, one known object layout.
|
|
||||||
|
|
||||||
our design keeps the same core trick (context swapping) but wraps it in
|
|
||||||
a config-driven system that can support multiple games.
|
|
||||||
|
|
||||||
|
|
||||||
the core trick: context swapping
|
|
||||||
================================
|
|
||||||
|
|
||||||
the z-machine blocks on input (``op_sread`` in V3, ``op_aread`` in V5+).
|
|
||||||
when it blocks, the entire interpreter state is frozen: PC points past the
|
|
||||||
read instruction, the stack is stable, globals hold current values. this is
|
|
||||||
the natural swap point.
|
|
||||||
|
|
||||||
the multiplayer loop::
|
|
||||||
|
|
||||||
1. player sends a command
|
|
||||||
2. restore that player's saved context (PC, stack, globals, object data)
|
|
||||||
3. feed the command to the interpreter
|
|
||||||
4. run until the next READ (interpreter blocks again)
|
|
||||||
5. capture output, save context back out
|
|
||||||
6. repeat for next player with input
|
|
||||||
|
|
||||||
this is what MojoZork does. it's also what we'll do. the z-machine is
|
|
||||||
inherently single-threaded — no way around processing one player at a time.
|
|
||||||
|
|
||||||
|
|
||||||
shared vs per-player state
|
|
||||||
==========================
|
|
||||||
|
|
||||||
which z-machine state is shared, and which needs per-player copies?
|
|
||||||
|
|
||||||
======================== ========= ================ ================================
|
|
||||||
state shared? per-player copy? notes
|
|
||||||
======================== ========= ================ ================================
|
|
||||||
static/high memory yes no code and strings, immutable
|
|
||||||
dynamic memory yes no world state (object tree, etc)
|
|
||||||
program counter no yes each player paused at own READ
|
|
||||||
call stack + locals no yes different execution contexts
|
|
||||||
data stack no yes operand stack diverges
|
|
||||||
player-specific globals no yes location, deaths, flags, etc
|
|
||||||
player object entry no yes parent/attrs/properties
|
|
||||||
touchbits (visited rooms) no yes each player's exploration history
|
|
||||||
PRNG state yes no shared randomness is fine
|
|
||||||
======================== ========= ================ ================================
|
|
||||||
|
|
||||||
this matches MojoZork's split exactly. the key insight: dynamic memory is ONE
|
|
||||||
copy shared by all players. most of the game world (objects, rooms, items,
|
|
||||||
puzzle state) is shared. only the player's execution state and a handful of
|
|
||||||
"this is about me specifically" globals need per-player copies.
|
|
||||||
|
|
||||||
the consequence: when player A picks up the brass lantern, player B can't pick
|
|
||||||
it up either. the object tree is shared. this is correct — it's the same world.
|
|
||||||
but when player A is in the kitchen and player B is in the cellar, they each
|
|
||||||
have their own location global. the game "sees" each player in their own room
|
|
||||||
during their turn.
|
|
||||||
|
|
||||||
|
|
||||||
player object strategy
|
|
||||||
======================
|
|
||||||
|
|
||||||
each player needs a z-machine object to exist in the world — to have a
|
|
||||||
location, to hold inventory, to be visible to other players.
|
|
||||||
|
|
||||||
**V3 (Zork 1):** 255 max objects, Zork uses ~250. objects 251-255 are
|
|
||||||
available as player slots. this limits V3 to ~5 concurrent players.
|
|
||||||
|
|
||||||
**V5/V8 (Lost Pig, etc):** 65535 max objects. even a large game uses a few
|
|
||||||
thousand at most. tens of thousands of spare slots.
|
|
||||||
|
|
||||||
MojoZork virtualizes player objects — stores them outside the real object
|
|
||||||
table and intercepts all object access with function pointer hooks. this is
|
|
||||||
clever but adds complexity and tight coupling to every object opcode.
|
|
||||||
|
|
||||||
**our approach: use real object table slots.**
|
|
||||||
|
|
||||||
for V3, claim spare slots at the top of the range (251+). for V5/V8, claim
|
|
||||||
a block starting well above the game's highest object. player objects live
|
|
||||||
in the real object table, initialized with appropriate parent (starting room),
|
|
||||||
attributes (visible), and properties (name, description).
|
|
||||||
|
|
||||||
advantages over MojoZork's virtualization:
|
|
||||||
|
|
||||||
- no function pointer hooks on every object access
|
|
||||||
- standard object opcodes work unmodified
|
|
||||||
- properties live in real memory (no fake address ranges)
|
|
||||||
- simpler code, fewer places for bugs
|
|
||||||
|
|
||||||
disadvantages:
|
|
||||||
|
|
||||||
- requires knowing the game's max object count (to avoid collisions)
|
|
||||||
- V3 is limited to ~5 players (real constraint, but acceptable)
|
|
||||||
- must write object data into dynamic memory during initialization
|
|
||||||
|
|
||||||
to determine available slots, the game config specifies a safe range, or we
|
|
||||||
scan the object table at startup to find the highest used object.
|
|
||||||
|
|
||||||
comparison with MojoZork::
|
|
||||||
|
|
||||||
MojoZork our approach
|
|
||||||
───────────────────────────────── ──────────────────────────────────
|
|
||||||
virtual objects outside table real objects in table
|
|
||||||
function pointer hooks no hooks needed
|
|
||||||
fake address range (0xFE00+) real memory addresses
|
|
||||||
hardcoded to Zork objects 251-254 config-driven range per game
|
|
||||||
4 player limit (hardcoded) V3: ~5, V8: effectively unlimited
|
|
||||||
|
|
||||||
|
|
||||||
identifying player-specific state
|
|
||||||
=================================
|
|
||||||
|
|
||||||
the hardest part of supporting a new game: figuring out which global variables
|
|
||||||
need per-player copies.
|
|
||||||
|
|
||||||
MojoZork hardcodes 10 Zork 1 globals (location, deaths, lit, verbose, etc).
|
|
||||||
that works for one game but doesn't scale.
|
|
||||||
|
|
||||||
**our approach: per-game TOML config files.**
|
|
||||||
|
|
||||||
each supported game gets a multiplayer config that maps globals to meanings::
|
|
||||||
|
|
||||||
# content/stories/zork1.multiplayer.toml
|
|
||||||
|
|
||||||
[game]
|
|
||||||
name = "zork1"
|
|
||||||
version = 3
|
|
||||||
story_file = "zork1.z3"
|
|
||||||
|
|
||||||
[player_object]
|
|
||||||
original_id = 4 # the game's built-in "player" object
|
|
||||||
start_room = 180 # West of House (object ID)
|
|
||||||
room_container = 82 # parent of all rooms
|
|
||||||
|
|
||||||
[player_globals]
|
|
||||||
# global variable indices that need per-player copies
|
|
||||||
# index = human-readable name (for debugging/logging)
|
|
||||||
0 = "location"
|
|
||||||
60 = "lucky"
|
|
||||||
61 = "deaths"
|
|
||||||
62 = "dead"
|
|
||||||
66 = "lit"
|
|
||||||
70 = "superbrief"
|
|
||||||
71 = "verbose"
|
|
||||||
72 = "alwayslit"
|
|
||||||
133 = "loadallowed"
|
|
||||||
139 = "coffin_held"
|
|
||||||
|
|
||||||
[attributes]
|
|
||||||
invisible = 7 # attribute to hide player during swap
|
|
||||||
touchbit = 3 # room-visited flag (per-player)
|
|
||||||
|
|
||||||
[player_slots]
|
|
||||||
start = 251 # first available object ID for players
|
|
||||||
max = 255 # last available (V3 ceiling)
|
|
||||||
|
|
||||||
[bytecode_patches]
|
|
||||||
# addresses where the game hardcodes the player object ID.
|
|
||||||
# during swap, these bytes get patched to the current player's object ID.
|
|
||||||
# found by disassembling and searching for references to object 4.
|
|
||||||
addresses = [0x6B3F, 0x93E4, 0x9411, 0xD748, 0xE1AF, 0x6B88]
|
|
||||||
|
|
||||||
how to create these configs for new games:
|
|
||||||
|
|
||||||
1. **location global** — always global 0 (``read_global(0x10)``). this is
|
|
||||||
standard across all z-machine games.
|
|
||||||
2. **other player globals** — requires disassembly and analysis. look for
|
|
||||||
globals that change on room entry, death, or mode switches. the
|
|
||||||
``scripts/zmachine_inspect.py`` tool can help, and we could build a
|
|
||||||
tracing tool that logs global writes during single-player play.
|
|
||||||
3. **bytecode patches** — search disassembly for literal references to the
|
|
||||||
player object ID. not all games will need these (some reference the
|
|
||||||
player via a global, not a literal).
|
|
||||||
4. **object slots** — load the game, count objects, pick a safe range above
|
|
||||||
the highest used object.
|
|
||||||
|
|
||||||
this is manual work per game. but multiplayer is inherently game-specific —
|
|
||||||
each game makes different assumptions about the player. a config file is the
|
|
||||||
honest way to capture those assumptions.
|
|
||||||
|
|
||||||
future tooling (not in scope for initial implementation):
|
|
||||||
|
|
||||||
- a tracing mode that logs all global writes during play, flagging candidates
|
|
||||||
for per-player globals
|
|
||||||
- a disassembler that searches for literal object ID references
|
|
||||||
- a test harness that runs two contexts against the same game and detects
|
|
||||||
state conflicts
|
|
||||||
|
|
||||||
|
|
||||||
the turn model
|
|
||||||
==============
|
|
||||||
|
|
||||||
MojoZork uses implicit round-robin: whoever sends input next gets their turn.
|
|
||||||
there's no explicit queue or scheduling.
|
|
||||||
|
|
||||||
**our approach: input queue, process one at a time.**
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
SharedIFSession
|
|
||||||
├── input_queue: asyncio.Queue[(player_name, command)]
|
|
||||||
├── processing_lock: asyncio.Lock
|
|
||||||
└── tick():
|
|
||||||
while not input_queue.empty():
|
|
||||||
player, command = input_queue.get()
|
|
||||||
swap_in(player)
|
|
||||||
feed(command)
|
|
||||||
run_until_read()
|
|
||||||
capture_output(player)
|
|
||||||
swap_out(player)
|
|
||||||
|
|
||||||
when a MUD player types a command in IF mode, it goes into the shared input
|
|
||||||
queue. the session processes queued commands one at a time in FIFO order.
|
|
||||||
|
|
||||||
latency: in a MUD, players expect immediate response. with context swapping,
|
|
||||||
each player's command takes one z-machine "turn" to process (typically
|
|
||||||
milliseconds — our interpreter runs thousands of instructions per ms). even
|
|
||||||
with 4-5 players, the queue drains in well under 100ms. this is fast enough
|
|
||||||
that players won't notice the serialization.
|
|
||||||
|
|
||||||
if latency becomes a problem (unlikely), we could process the queue on every
|
|
||||||
MUD tick rather than waiting for it to drain, giving each player one turn per
|
|
||||||
tick in round-robin order.
|
|
||||||
|
|
||||||
comparison with MojoZork::
|
|
||||||
|
|
||||||
MojoZork our approach
|
|
||||||
───────────────────────────────── ──────────────────────────────────
|
|
||||||
implicit (whoever sends input) explicit FIFO queue
|
|
||||||
blocking telnet read per player async queue, non-blocking
|
|
||||||
single-threaded C async Python (event loop)
|
|
||||||
no fairness guarantee FIFO ordering
|
|
||||||
|
|
||||||
|
|
||||||
data structures
|
|
||||||
===============
|
|
||||||
|
|
||||||
the core types for multiplayer::
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlayerContext:
|
|
||||||
"""per-player execution state for a shared z-machine."""
|
|
||||||
player_name: str
|
|
||||||
mud_player: Player # reference to MUD player entity
|
|
||||||
|
|
||||||
# execution state (saved when swapped out)
|
|
||||||
program_counter: int
|
|
||||||
call_stack: list[ZRoutine] # full call frame chain
|
|
||||||
data_stack: list[int] # operand stack
|
|
||||||
|
|
||||||
# player-specific z-machine state
|
|
||||||
player_globals: dict[int, int] # global index -> value
|
|
||||||
player_object_id: int # our object in the z-machine
|
|
||||||
object_entry: bytes # saved object table entry
|
|
||||||
property_data: bytes # saved property table
|
|
||||||
touchbits: bytearray # visited-room flags
|
|
||||||
|
|
||||||
# i/o
|
|
||||||
output_buffer: list[str] # accumulated output this turn
|
|
||||||
|
|
||||||
# session
|
|
||||||
hash_code: str # for reconnection (6 chars)
|
|
||||||
joined_at: float # timestamp
|
|
||||||
|
|
||||||
|
|
||||||
class SharedIFSession:
|
|
||||||
"""multiplayer z-machine session — one game, many players."""
|
|
||||||
zmachine: ZMachine # single shared interpreter
|
|
||||||
story_name: str
|
|
||||||
game_config: GameMultiplayerConfig
|
|
||||||
|
|
||||||
contexts: dict[str, PlayerContext] # player_name -> context
|
|
||||||
current_player: str | None # who's executing right now
|
|
||||||
input_queue: asyncio.Queue # (player_name, command) pairs
|
|
||||||
spectators: set[Player] # watchers who aren't playing
|
|
||||||
|
|
||||||
# persistence
|
|
||||||
save_path: Path
|
|
||||||
moves_since_save: int
|
|
||||||
|
|
||||||
the ``GameMultiplayerConfig`` is loaded from the TOML file described above.
|
|
||||||
it holds the per-game mappings (player globals, object slots, patches, etc).
|
|
||||||
|
|
||||||
|
|
||||||
the swap in detail
|
|
||||||
==================
|
|
||||||
|
|
||||||
**swap_in(player_name)** — restore a player's context before their turn::
|
|
||||||
|
|
||||||
1. load PlayerContext for this player
|
|
||||||
2. restore PC:
|
|
||||||
opdecoder.program_counter = ctx.program_counter
|
|
||||||
3. restore stack:
|
|
||||||
stackmanager.restore(ctx.call_stack, ctx.data_stack)
|
|
||||||
4. restore player-specific globals:
|
|
||||||
for idx, value in ctx.player_globals.items():
|
|
||||||
memory.write_global(idx + 0x10, value)
|
|
||||||
5. restore player object entry:
|
|
||||||
write ctx.object_entry to object table at ctx.player_object_id
|
|
||||||
write ctx.property_data to property table address
|
|
||||||
6. restore touchbits:
|
|
||||||
for each room object, set/clear touchbit attribute from ctx.touchbits
|
|
||||||
7. apply bytecode patches:
|
|
||||||
for each patch address in game_config:
|
|
||||||
memory[addr] = ctx.player_object_id (low byte)
|
|
||||||
8. mark other players' objects as invisible (set invisible attribute)
|
|
||||||
|
|
||||||
**swap_out(player_name)** — save a player's context after their turn::
|
|
||||||
|
|
||||||
1. save PC:
|
|
||||||
ctx.program_counter = opdecoder.program_counter
|
|
||||||
2. save stack:
|
|
||||||
ctx.call_stack, ctx.data_stack = stackmanager.snapshot()
|
|
||||||
3. save player-specific globals:
|
|
||||||
for idx in game_config.player_globals:
|
|
||||||
ctx.player_globals[idx] = memory.read_global(idx + 0x10)
|
|
||||||
4. save player object entry:
|
|
||||||
ctx.object_entry = read object table at ctx.player_object_id
|
|
||||||
ctx.property_data = read property table
|
|
||||||
5. save touchbits:
|
|
||||||
for each room, ctx.touchbits[i] = get_attr(room, touchbit_attr)
|
|
||||||
6. clear visibility hacks (remove invisible from other players)
|
|
||||||
|
|
||||||
the swap is fast. it's mostly memcpy-equivalent operations. the touchbit
|
|
||||||
loop is O(rooms) but rooms are typically < 300.
|
|
||||||
|
|
||||||
|
|
||||||
output routing
|
|
||||||
==============
|
|
||||||
|
|
||||||
during a player's turn, the z-machine generates text via the UI callback.
|
|
||||||
this text should go to that player. but other players might also need to
|
|
||||||
see certain things (someone entering their room, items being taken, etc).
|
|
||||||
|
|
||||||
**basic output routing:** all z-machine output during player A's turn goes
|
|
||||||
to player A's output buffer. after the turn, flush the buffer to their MUD
|
|
||||||
connection.
|
|
||||||
|
|
||||||
**broadcast messages:** when a player takes an action visible to others
|
|
||||||
(enters a room, picks up an item), the MUD layer generates broadcast messages
|
|
||||||
for nearby players. this happens at the MUD level, not the z-machine level.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
after swap_out(player_a):
|
|
||||||
old_room = previous_location
|
|
||||||
new_room = ctx.player_globals[0] # location
|
|
||||||
if old_room != new_room:
|
|
||||||
broadcast_to_room(old_room, f"{player_a} leaves.")
|
|
||||||
broadcast_to_room(new_room, f"{player_a} arrives.")
|
|
||||||
|
|
||||||
**spectators:** non-playing watchers see all output from all players,
|
|
||||||
prefixed with the player's name. same pattern as current single-player
|
|
||||||
spectator support, extended to multiplex output streams.
|
|
||||||
|
|
||||||
|
|
||||||
MUD integration
|
|
||||||
===============
|
|
||||||
|
|
||||||
how does a multiplayer IF session look from the MUD side?
|
|
||||||
|
|
||||||
**entering a shared session:**
|
|
||||||
|
|
||||||
a terminal object in the MUD world hosts the shared game. players "use" the
|
|
||||||
terminal to join. the first player creates the session; subsequent players
|
|
||||||
join the existing one::
|
|
||||||
|
|
||||||
> use terminal
|
|
||||||
[joining Zork I — 2 players already exploring]
|
|
||||||
[you are player 3, starting at West of House]
|
|
||||||
|
|
||||||
the player's mode stack pushes "shared_if". input routes to the shared
|
|
||||||
session's input queue instead of the MUD command dispatcher.
|
|
||||||
|
|
||||||
**leaving and rejoining:**
|
|
||||||
|
|
||||||
players can leave with ``::quit`` (same as single-player). their context is
|
|
||||||
preserved — the player object stays in the world (marked inactive/invisible),
|
|
||||||
and their state is saved. they can rejoin later and resume where they left off.
|
|
||||||
|
|
||||||
MojoZork uses a 6-char hash code for reconnection. we can do the same, or
|
|
||||||
just use the player's MUD identity (they're already authenticated).
|
|
||||||
|
|
||||||
**when the last player leaves:**
|
|
||||||
|
|
||||||
the session enters a dormant state. the shared z-machine state (dynamic
|
|
||||||
memory) is saved to disk along with all player contexts. when someone
|
|
||||||
rejoins, the session is restored.
|
|
||||||
|
|
||||||
option: keep the session alive for some grace period (5 minutes?) before
|
|
||||||
saving and tearing down, in case players are just briefly disconnecting.
|
|
||||||
|
|
||||||
**spectators:**
|
|
||||||
|
|
||||||
work the same as single-player spectators. watchers see multiplexed output
|
|
||||||
from all active players, prefixed with player names. spectators can't send
|
|
||||||
commands to the z-machine.
|
|
||||||
|
|
||||||
|
|
||||||
persistence
|
|
||||||
===========
|
|
||||||
|
|
||||||
MojoZork saves to SQLite (instance state + per-player state + transcripts).
|
|
||||||
we already have Quetzal save/restore for single-player. for multiplayer
|
|
||||||
we need both the shared state and per-player contexts.
|
|
||||||
|
|
||||||
**what to save:**
|
|
||||||
|
|
||||||
- shared dynamic memory (one copy, same as Quetzal CMem/UMem)
|
|
||||||
- per-player: PC, call stack, data stack, player globals, object data,
|
|
||||||
touchbits
|
|
||||||
- session metadata: game name, player list, creation time
|
|
||||||
|
|
||||||
**format options:**
|
|
||||||
|
|
||||||
1. **extend Quetzal** — add custom chunks for per-player data. Quetzal
|
|
||||||
is extensible (IFF format). but it's designed for single-player saves.
|
|
||||||
2. **separate files** — one Quetzal-like file for shared state, one file
|
|
||||||
per player for their context. simpler but more files to manage.
|
|
||||||
3. **SQLite** — like MojoZork. one row per session, BLOBs for state.
|
|
||||||
integrates with our existing player database.
|
|
||||||
|
|
||||||
**recommendation:** SQLite, following MojoZork's proven approach. we already
|
|
||||||
use SQLite for player accounts. add tables for multiplayer sessions and
|
|
||||||
player contexts. BLOBs for binary state (memory, stacks).
|
|
||||||
|
|
||||||
**autosave:** every N moves (MojoZork uses 30). also save on player
|
|
||||||
join/leave and session dormancy.
|
|
||||||
|
|
||||||
|
|
||||||
game candidates
|
|
||||||
===============
|
|
||||||
|
|
||||||
not all IF games make sense for multiplayer. criteria:
|
|
||||||
|
|
||||||
- exploration-heavy (many rooms to discover)
|
|
||||||
- items and puzzles that can be shared or divided
|
|
||||||
- enough physical space for multiple players
|
|
||||||
- not deeply single-protagonist (tight narrative, personal journey)
|
|
||||||
|
|
||||||
**good candidates:**
|
|
||||||
|
|
||||||
1. **Zork I** (V3) — the original. large map, many items, exploration-focused.
|
|
||||||
MojoZork already proved this works. our first target.
|
|
||||||
|
|
||||||
2. **Lost Pig** (V8) — single location focus (farm/caves), but funny and
|
|
||||||
interactive. orc protagonist gives natural "party of adventurers" vibe.
|
|
||||||
V8 format means plenty of object slots.
|
|
||||||
|
|
||||||
3. **Zork II / Zork III** (V3) — similar to Zork I. would need their own
|
|
||||||
multiplayer configs but the gameplay pattern works.
|
|
||||||
|
|
||||||
4. **Enchanter trilogy** (V3/V5) — magic system could make cooperative play
|
|
||||||
interesting (different players learn different spells).
|
|
||||||
|
|
||||||
**poor candidates:**
|
|
||||||
|
|
||||||
- tightly scripted narrative (Photopia, Shade)
|
|
||||||
- real-time or timing-sensitive games
|
|
||||||
- games with one-protagonist arcs (most modern literary IF)
|
|
||||||
- Glulx games (out of scope — different VM entirely)
|
|
||||||
|
|
||||||
**what we have now:** ``content/stories/zork1.z3`` and
|
|
||||||
``content/stories/LostPig.z8``. Zork I is the obvious first target since
|
|
||||||
MojoZork provides a reference implementation to validate against.
|
|
||||||
|
|
||||||
|
|
||||||
implementation plan
|
|
||||||
===================
|
|
||||||
|
|
||||||
in rough dependency order:
|
|
||||||
|
|
||||||
**phase 1: game config and analysis tools**
|
|
||||||
|
|
||||||
- define the multiplayer TOML config format
|
|
||||||
- write ``content/stories/zork1.multiplayer.toml`` (translating MojoZork's
|
|
||||||
hardcoded values)
|
|
||||||
- extend ``scripts/zmachine_inspect.py`` with object counting and
|
|
||||||
global-tracing modes
|
|
||||||
- analyze Lost Pig for multiplayer config (object count, candidate globals)
|
|
||||||
|
|
||||||
**phase 2: context swapping**
|
|
||||||
|
|
||||||
- implement ``PlayerContext`` dataclass
|
|
||||||
- implement ``swap_in()`` / ``swap_out()`` on the interpreter
|
|
||||||
- add ``stackmanager.snapshot()`` and ``stackmanager.restore()`` methods
|
|
||||||
- add player object initialization (write new objects into table)
|
|
||||||
- test: two contexts alternating on Zork 1, verify independent locations
|
|
||||||
|
|
||||||
**phase 3: shared session**
|
|
||||||
|
|
||||||
- implement ``SharedIFSession`` (the multiplayer equivalent of
|
|
||||||
``EmbeddedIFSession``)
|
|
||||||
- input queue and FIFO processing
|
|
||||||
- output routing (per-player buffers)
|
|
||||||
- broadcast messages for cross-player events (room entry/exit)
|
|
||||||
|
|
||||||
**phase 4: MUD integration**
|
|
||||||
|
|
||||||
- shared terminal object that hosts multiplayer sessions
|
|
||||||
- join/leave/rejoin flow
|
|
||||||
- mode stack integration (``shared_if`` mode)
|
|
||||||
- spectator support (multiplexed output)
|
|
||||||
|
|
||||||
**phase 5: persistence**
|
|
||||||
|
|
||||||
- SQLite schema for multiplayer sessions
|
|
||||||
- save/restore shared state + per-player contexts
|
|
||||||
- autosave logic
|
|
||||||
- dormant session management (save on last leave, restore on rejoin)
|
|
||||||
|
|
||||||
**phase 6: Lost Pig support**
|
|
||||||
|
|
||||||
- analyze Lost Pig for multiplayer config
|
|
||||||
- write ``LostPig.multiplayer.toml``
|
|
||||||
- test with V8 object slots (high-range allocation)
|
|
||||||
- validate context swapping works for V8/V5 (aread vs sread differences)
|
|
||||||
|
|
||||||
|
|
||||||
comparison table
|
|
||||||
================
|
|
||||||
|
|
||||||
================================ ============================== ==============================
|
|
||||||
concern MojoZork our design
|
|
||||||
================================ ============================== ==============================
|
|
||||||
player objects virtual (outside object table) real (in object table)
|
|
||||||
object access function pointer hooks standard opcodes, no hooks
|
|
||||||
player globals 10 hardcoded indices TOML config per game
|
|
||||||
bytecode patches 6 hardcoded addresses TOML config per game
|
|
||||||
player limit 4 (hardcoded) V3: ~5, V8: hundreds
|
|
||||||
game support one build of Zork 1 config-driven, multiple games
|
|
||||||
turn model implicit (whoever sends input) explicit FIFO queue
|
|
||||||
output routing callback to current connection per-player buffer + broadcast
|
|
||||||
persistence SQLite (memory BLOBs) SQLite (same approach)
|
|
||||||
reconnection 6-char hash code MUD identity (already authed)
|
|
||||||
error recovery longjmp + instance destroy exception handling + save state
|
|
||||||
language C (single file, 2500 lines) Python (multiple modules)
|
|
||||||
================================ ============================== ==============================
|
|
||||||
|
|
||||||
|
|
||||||
known limitations
|
|
||||||
=================
|
|
||||||
|
|
||||||
things we're explicitly not solving in the initial implementation:
|
|
||||||
|
|
||||||
- **no generic multiplayer** — each game needs manual analysis and a config
|
|
||||||
file. there's no way around this; games make game-specific assumptions.
|
|
||||||
|
|
||||||
- **no concurrent execution** — one player at a time. the z-machine is
|
|
||||||
single-threaded. serialized turns are the only safe model.
|
|
||||||
|
|
||||||
- **V3 player limit** — roughly 5 players max due to the 255-object ceiling.
|
|
||||||
acceptable for the MUD use case (small group exploration).
|
|
||||||
|
|
||||||
- **no cross-player item interaction** — if player A drops an item in a room
|
|
||||||
and player B is in that room, B won't see it until their next turn (when
|
|
||||||
the shared object tree is visible to their context). this is a fundamental
|
|
||||||
consequence of turn-based context swapping.
|
|
||||||
|
|
||||||
- **game-specific bytecode patches** — some games hardcode the player object
|
|
||||||
ID in z-code instructions. these patch addresses must be found manually
|
|
||||||
via disassembly. not all games will need them.
|
|
||||||
|
|
||||||
- **save_undo** — currently stubbed as "not available" in our interpreter.
|
|
||||||
multiplayer makes undo even harder (whose undo?). punting entirely.
|
|
||||||
|
|
||||||
- **dictionary injection** — level 3 feature (adding new words to the game).
|
|
||||||
orthogonal to multiplayer but would enhance it. not in scope here.
|
|
||||||
|
|
||||||
- **Glulx games** — different VM, out of scope. only z-machine V3/V5/V8.
|
|
||||||
|
|
||||||
|
|
||||||
open questions
|
|
||||||
==============
|
|
||||||
|
|
||||||
things to resolve during implementation:
|
|
||||||
|
|
||||||
1. **stackmanager snapshot format** — do we deep-copy ZRoutine objects, or
|
|
||||||
serialize to bytes? deep copy is simpler; bytes are more compact for
|
|
||||||
persistence. probably start with deep copy, optimize later.
|
|
||||||
|
|
||||||
2. **initial player setup** — when a new player joins mid-game, where do
|
|
||||||
they start? MojoZork puts everyone at West of House. we could let the
|
|
||||||
config specify a start room, or always use the game's default start.
|
|
||||||
|
|
||||||
3. **player object properties** — what name and description does a player
|
|
||||||
object get? MojoZork uses "Adventurer" plus a number. we could use the
|
|
||||||
MUD player's name, but that requires writing a z-machine string into
|
|
||||||
the property table.
|
|
||||||
|
|
||||||
4. **item conflict** — if player A is carrying an item and player B tries
|
|
||||||
to take it during their turn (shared object tree), what happens? the
|
|
||||||
game will say "you can't see that here" because the item's parent is
|
|
||||||
player A's object, not the room. this is probably fine — the z-machine
|
|
||||||
handles it naturally.
|
|
||||||
|
|
||||||
5. **scoring** — most games track score in a global. is score per-player
|
|
||||||
or shared? MojoZork doesn't address this. probably per-player (add to
|
|
||||||
player_globals config).
|
|
||||||
|
|
@ -65,7 +65,6 @@ class ZCpu:
|
||||||
self._lexer = zlexer
|
self._lexer = zlexer
|
||||||
self._zmachine = zmachine
|
self._zmachine = zmachine
|
||||||
self._trace = deque(maxlen=20)
|
self._trace = deque(maxlen=20)
|
||||||
self._undo_snapshot = None
|
|
||||||
self._dispatch = self._build_dispatch_table()
|
self._dispatch = self._build_dispatch_table()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -1182,85 +1181,16 @@ class ZCpu:
|
||||||
self._write_result(0) # unsupported font
|
self._write_result(0) # unsupported font
|
||||||
|
|
||||||
def op_save_undo(self, *args):
|
def op_save_undo(self, *args):
|
||||||
"""Save undo state (V5+, EXT:9).
|
"""Save undo state. Store -1 if not available (V5+).
|
||||||
|
|
||||||
Captures a snapshot of dynamic memory, call stack, and PC.
|
Stores 1 on success, 0 on failure, -1 if not available.
|
||||||
Stores 1 on success. After restore_undo, execution resumes
|
Real undo support deferred; return -1 for now.
|
||||||
here with result 2 (like fork() returning different values).
|
|
||||||
"""
|
"""
|
||||||
from .zstackmanager import ZRoutine, ZStackBottom
|
self._write_result(self._unmake_signed(-1))
|
||||||
|
|
||||||
# Read store address first — advances PC past the store byte
|
|
||||||
store_addr = self._opdecoder.get_store_address()
|
|
||||||
|
|
||||||
# Capture dynamic memory
|
|
||||||
mem = self._memory
|
|
||||||
dynamic_copy = bytearray(mem._memory[mem._dynamic_start : mem._dynamic_end + 1])
|
|
||||||
|
|
||||||
# Deep copy call stack
|
|
||||||
stack_copy = []
|
|
||||||
for frame in self._stackmanager._call_stack:
|
|
||||||
if isinstance(frame, ZStackBottom):
|
|
||||||
bottom = ZStackBottom()
|
|
||||||
bottom.program_counter = frame.program_counter
|
|
||||||
bottom.stack = frame.stack[:]
|
|
||||||
bottom.local_vars = frame.local_vars[:]
|
|
||||||
stack_copy.append(bottom)
|
|
||||||
else:
|
|
||||||
new_frame = ZRoutine(
|
|
||||||
frame.start_addr,
|
|
||||||
frame.return_addr,
|
|
||||||
self._memory,
|
|
||||||
[],
|
|
||||||
local_vars=frame.local_vars[:],
|
|
||||||
stack=frame.stack[:],
|
|
||||||
)
|
|
||||||
new_frame.program_counter = frame.program_counter
|
|
||||||
new_frame.arg_count = frame.arg_count
|
|
||||||
stack_copy.append(new_frame)
|
|
||||||
|
|
||||||
self._undo_snapshot = (
|
|
||||||
self._opdecoder.program_counter,
|
|
||||||
store_addr,
|
|
||||||
dynamic_copy,
|
|
||||||
stack_copy,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store 1 = success
|
|
||||||
self._write_result(1, store_addr=store_addr)
|
|
||||||
|
|
||||||
def op_restore_undo(self, *args):
|
def op_restore_undo(self, *args):
|
||||||
"""Restore undo state (V5+, EXT:10).
|
"""Restore undo state. Store 0 on failure (V5+)."""
|
||||||
|
self._write_result(0)
|
||||||
Restores dynamic memory, call stack, and PC from snapshot.
|
|
||||||
Stores 0 on failure. On success, execution resumes at the
|
|
||||||
save_undo call site with result 2.
|
|
||||||
"""
|
|
||||||
if self._undo_snapshot is None:
|
|
||||||
self._write_result(0)
|
|
||||||
return
|
|
||||||
|
|
||||||
pc, store_addr, dynamic_copy, stack_copy = self._undo_snapshot
|
|
||||||
self._undo_snapshot = None
|
|
||||||
|
|
||||||
# Restore dynamic memory
|
|
||||||
mem = self._memory
|
|
||||||
mem._memory[mem._dynamic_start : mem._dynamic_end + 1] = dynamic_copy
|
|
||||||
|
|
||||||
# Restore call stack: keep the live ZStackBottom identity,
|
|
||||||
# but restore its state from the snapshot
|
|
||||||
live_bottom = self._stackmanager._stackbottom
|
|
||||||
saved_bottom = stack_copy[0]
|
|
||||||
live_bottom.program_counter = saved_bottom.program_counter
|
|
||||||
live_bottom.stack = saved_bottom.stack[:]
|
|
||||||
live_bottom.local_vars = saved_bottom.local_vars[:]
|
|
||||||
self._stackmanager._call_stack[:] = [live_bottom] + stack_copy[1:]
|
|
||||||
|
|
||||||
# Restore PC
|
|
||||||
self._opdecoder.program_counter = pc
|
|
||||||
|
|
||||||
# Store 2 at save_undo's store location (not restore_undo's)
|
|
||||||
self._write_result(2, store_addr=store_addr)
|
|
||||||
|
|
||||||
def op_print_unicode(self, char_code):
|
def op_print_unicode(self, char_code):
|
||||||
"""Print a Unicode character (V5+, EXT:11)."""
|
"""Print a Unicode character (V5+, EXT:11)."""
|
||||||
|
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
"""Tests for Z-machine save_undo / restore_undo opcodes (V5+, EXT:9/10)."""
|
|
||||||
|
|
||||||
from unittest import TestCase
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from mudlib.zmachine.zcpu import ZCpu
|
|
||||||
from mudlib.zmachine.zmemory import ZMemory
|
|
||||||
from mudlib.zmachine.zstackmanager import ZStackManager
|
|
||||||
|
|
||||||
|
|
||||||
class MockOpDecoder:
|
|
||||||
"""Mock opcode decoder for undo tests."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.program_counter = 0x800
|
|
||||||
self.store_address = None
|
|
||||||
self.branch_condition = True
|
|
||||||
self.branch_offset = 2
|
|
||||||
|
|
||||||
def get_store_address(self):
|
|
||||||
return self.store_address
|
|
||||||
|
|
||||||
def get_branch_offset(self):
|
|
||||||
return (self.branch_condition, self.branch_offset)
|
|
||||||
|
|
||||||
|
|
||||||
class MockUI:
|
|
||||||
def __init__(self):
|
|
||||||
self.screen = Mock()
|
|
||||||
self.keyboard_input = Mock()
|
|
||||||
self.filesystem = Mock()
|
|
||||||
|
|
||||||
|
|
||||||
def make_v8_story(static_start=0x0800, globals_start=0x0400):
|
|
||||||
"""Create a minimal V8 story with proper memory layout."""
|
|
||||||
size = max(static_start + 512, 2048)
|
|
||||||
story = bytearray(size)
|
|
||||||
story[0] = 8 # V8
|
|
||||||
story[0x04] = (static_start >> 8) & 0xFF # high memory start
|
|
||||||
story[0x05] = static_start & 0xFF
|
|
||||||
story[0x0C] = (globals_start >> 8) & 0xFF
|
|
||||||
story[0x0D] = globals_start & 0xFF
|
|
||||||
story[0x0E] = (static_start >> 8) & 0xFF
|
|
||||||
story[0x0F] = static_start & 0xFF
|
|
||||||
return story
|
|
||||||
|
|
||||||
|
|
||||||
class SaveUndoTests(TestCase):
|
|
||||||
"""Tests for op_save_undo and op_restore_undo."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
story = make_v8_story()
|
|
||||||
self.memory = ZMemory(bytes(story))
|
|
||||||
self.stack = ZStackManager(self.memory)
|
|
||||||
self.decoder = MockOpDecoder()
|
|
||||||
self.ui = MockUI()
|
|
||||||
|
|
||||||
self.cpu = ZCpu(
|
|
||||||
self.memory,
|
|
||||||
self.decoder,
|
|
||||||
self.stack,
|
|
||||||
Mock(), # objects
|
|
||||||
Mock(), # string
|
|
||||||
Mock(), # stream manager
|
|
||||||
self.ui,
|
|
||||||
Mock(), # lexer
|
|
||||||
zmachine=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_save_undo_stores_1(self):
|
|
||||||
"""save_undo stores 1 (success), not -1 (not available)."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
self.assertEqual(self.memory.read_global(0x10), 1)
|
|
||||||
|
|
||||||
def test_restore_undo_no_snapshot_stores_0(self):
|
|
||||||
"""restore_undo with no prior save stores 0."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
self.assertEqual(self.memory.read_global(0x10), 0)
|
|
||||||
|
|
||||||
def test_save_then_restore_stores_2(self):
|
|
||||||
"""After save + restore, save_undo's store location holds 2."""
|
|
||||||
# save_undo stores result in global var 0x10
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
self.assertEqual(self.memory.read_global(0x10), 1)
|
|
||||||
|
|
||||||
# restore — use a different store address for restore_undo itself
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.decoder.program_counter = 0xA00
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
# save_undo's store var (0x10) should hold 2
|
|
||||||
self.assertEqual(self.memory.read_global(0x10), 2)
|
|
||||||
|
|
||||||
def test_restore_reverts_dynamic_memory(self):
|
|
||||||
"""Memory changes after save_undo are reverted by restore_undo."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
|
|
||||||
# Set initial memory state in dynamic region
|
|
||||||
self.memory[0x100] = 0xAA
|
|
||||||
self.memory[0x200] = 0xBB
|
|
||||||
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Modify memory after save
|
|
||||||
self.memory[0x100] = 0x11
|
|
||||||
self.memory[0x200] = 0x22
|
|
||||||
self.memory[0x300] = 0x33
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
# Memory should be reverted to save-time state
|
|
||||||
self.assertEqual(self.memory[0x100], 0xAA)
|
|
||||||
self.assertEqual(self.memory[0x200], 0xBB)
|
|
||||||
self.assertEqual(self.memory[0x300], 0x00)
|
|
||||||
|
|
||||||
def test_restore_reverts_program_counter(self):
|
|
||||||
"""PC is restored to save_undo's save point."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Move PC forward
|
|
||||||
self.decoder.program_counter = 0xB00
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
self.assertEqual(self.decoder.program_counter, 0x900)
|
|
||||||
|
|
||||||
def test_restore_reverts_call_stack(self):
|
|
||||||
"""Call stack changes after save_undo are reverted."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
|
|
||||||
# Set up a routine header at 0x500: 2 local vars (V8 = zero-init)
|
|
||||||
self.memory._memory[0x500] = 2
|
|
||||||
|
|
||||||
# Start a routine before saving
|
|
||||||
self.stack.start_routine(0x500, 0x01, 0x900, [42, 99])
|
|
||||||
self.assertEqual(len(self.stack._call_stack), 2)
|
|
||||||
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Push another routine after save
|
|
||||||
self.memory._memory[0x600] = 1
|
|
||||||
self.stack.start_routine(0x600, 0x02, 0xA00, [7])
|
|
||||||
self.assertEqual(len(self.stack._call_stack), 3)
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
# Should be back to 2 frames
|
|
||||||
self.assertEqual(len(self.stack._call_stack), 2)
|
|
||||||
|
|
||||||
def test_multiple_saves_keeps_latest(self):
|
|
||||||
"""Second save_undo overwrites the first snapshot."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
|
|
||||||
# First save
|
|
||||||
self.memory[0x100] = 0xAA
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Modify and save again
|
|
||||||
self.memory[0x100] = 0xBB
|
|
||||||
self.decoder.program_counter = 0x950
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Modify again
|
|
||||||
self.memory[0x100] = 0xCC
|
|
||||||
|
|
||||||
# Restore — should go to second save (0xBB), not first (0xAA)
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
self.assertEqual(self.memory[0x100], 0xBB)
|
|
||||||
self.assertEqual(self.decoder.program_counter, 0x950)
|
|
||||||
|
|
||||||
def test_snapshot_is_deep_copy_memory(self):
|
|
||||||
"""Modifying memory after save doesn't corrupt the snapshot."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
|
|
||||||
self.memory[0x100] = 0xAA
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Overwrite the same address
|
|
||||||
self.memory[0x100] = 0xFF
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
# Should be AA, not FF
|
|
||||||
self.assertEqual(self.memory[0x100], 0xAA)
|
|
||||||
|
|
||||||
def test_snapshot_is_deep_copy_stack(self):
|
|
||||||
"""Modifying stack frames after save doesn't corrupt the snapshot."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
|
|
||||||
# Start a routine with known local vars
|
|
||||||
self.memory._memory[0x500] = 2
|
|
||||||
self.stack.start_routine(0x500, 0x01, 0x900, [42, 99])
|
|
||||||
self.stack.push_stack(555)
|
|
||||||
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# Mutate the live stack frame
|
|
||||||
self.stack.set_local_variable(0, 0)
|
|
||||||
self.stack.push_stack(999)
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
|
|
||||||
# Local var 0 should be restored to 42
|
|
||||||
self.assertEqual(self.stack.get_local_variable(0), 42)
|
|
||||||
# Eval stack should have the original 555 (not 999)
|
|
||||||
self.assertEqual(self.stack.pop_stack(), 555)
|
|
||||||
|
|
||||||
def test_restore_consumes_snapshot(self):
|
|
||||||
"""After restore, the snapshot is consumed (no double undo)."""
|
|
||||||
self.decoder.store_address = 0x10
|
|
||||||
self.decoder.program_counter = 0x900
|
|
||||||
self.cpu.op_save_undo()
|
|
||||||
|
|
||||||
# First restore succeeds
|
|
||||||
self.decoder.store_address = 0x11
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
self.assertEqual(self.memory.read_global(0x10), 2)
|
|
||||||
|
|
||||||
# Second restore fails (no snapshot)
|
|
||||||
self.decoder.store_address = 0x12
|
|
||||||
self.cpu.op_restore_undo()
|
|
||||||
self.assertEqual(self.memory.read_global(0x12), 0)
|
|
||||||
Loading…
Reference in a new issue