378 lines
14 KiB
ReStructuredText
378 lines
14 KiB
ReStructuredText
mojozork audit
|
|
===============
|
|
|
|
technical audit of icculus/mojozork, a C z-machine interpreter with multiplayer
|
|
telnet server. this sits alongside the viola and zvm audits as reference for our
|
|
level 4 (shared world) integration plans.
|
|
|
|
project facts
|
|
=============
|
|
|
|
author: Ryan C. Gordon (icculus)
|
|
|
|
language: C, single-file per target
|
|
|
|
files::
|
|
|
|
mojozork.c (1,800 lines) — standalone single-player V3 z-machine
|
|
multizorkd.c (2,784 lines) — multiplayer telnet server
|
|
mojozork-libretro.c — RetroArch core frontend
|
|
mojozork-sdl3.c — SDL3 graphical frontend
|
|
CMakeLists.txt — build system, four optional targets
|
|
|
|
version support: V3 only. covers most of the infocom catalog. V4/V5/V6+ opcodes
|
|
are stubbed but unimplemented.
|
|
|
|
includes zork1.dat (92KB story file) — Activision released Zork I-III for free.
|
|
|
|
only external dependency: sqlite3 (multizork server only).
|
|
|
|
what matters for us
|
|
===================
|
|
|
|
this is NOT a candidate for direct use (it's C, we're Python). what matters:
|
|
|
|
1. the multiplayer architecture — MultiZork proves you can have multiple
|
|
players in one z-machine instance. this is our Level 4 (shared world) from
|
|
if-journey.rst.
|
|
|
|
2. the step execution model — runInstruction() executes one opcode at a time.
|
|
step_completed flag breaks out of the loop at READ/QUIT. this is the async
|
|
pattern we want in our interpreter.
|
|
|
|
3. the memory virtualization trick — two function pointers
|
|
(get_virtualized_mem_ptr and remap_objectid) redirect memory access for
|
|
multiplayer objects. clean separation of concern.
|
|
|
|
4. the persistence model — SQLite schema for saving complete z-machine state,
|
|
player state, transcripts. same pattern we'd use.
|
|
|
|
5. the game-specific problem — MultiZork works for Zork 1 only. the multiplayer
|
|
code is deeply hardcoded to Zork 1's specific object IDs, global variable
|
|
indices, and memory layout. Ryan himself says it won't even work with other
|
|
builds of Zork 1.
|
|
|
|
core interpreter
|
|
================
|
|
|
|
ZMachineState struct
|
|
--------------------
|
|
|
|
mojozork.c:88-131::
|
|
|
|
story: uint8* — game file loaded into memory
|
|
header: ZHeader — parsed 40-byte z-machine header
|
|
logical_pc / pc — program counter (offset and pointer)
|
|
sp / bp — stack and base pointer into uint16 stack[2048]
|
|
operands[8] + operand_count — current instruction arguments
|
|
opcodes[256] — function pointer dispatch table
|
|
step_completed — flag set by READ/QUIT to break execution
|
|
quit — flag for game exit
|
|
writestr callback — function pointer for output
|
|
die callback — function pointer for fatal errors
|
|
|
|
execution model
|
|
---------------
|
|
|
|
mojozork.c main loop::
|
|
|
|
while (!GState->quit) {
|
|
runInstruction();
|
|
}
|
|
|
|
runInstruction() (line ~1611-1676):
|
|
|
|
1. fetch opcode byte from PC
|
|
2. parse operand types from encoding bits
|
|
3. read operand values (large constant, small constant, variable, omitted)
|
|
4. dispatch: opcodes[opcode_number].fn()
|
|
5. handler modifies state (PC, stack, memory)
|
|
|
|
the step_completed pattern (used by MultiZork)::
|
|
|
|
GState->step_completed = 0;
|
|
while (!GState->step_completed) {
|
|
runInstruction();
|
|
}
|
|
// READ or QUIT set step_completed = 1, breaking out
|
|
|
|
this is the async pattern we want. execute until natural yield point (input
|
|
needed), then return control to the server.
|
|
|
|
object tree (V3)
|
|
================
|
|
|
|
object entry structure
|
|
----------------------
|
|
|
|
V3 object entry is 9 bytes::
|
|
|
|
bytes 0-3: attributes (32 boolean flags)
|
|
byte 4: parent object ID (0=none)
|
|
byte 5: sibling object ID (0=none)
|
|
byte 6: first child object ID (0=none)
|
|
bytes 7-8: address of property table
|
|
|
|
V3 allows 255 objects (8-bit IDs). Zork 1 uses 250. the 5 unused slots are
|
|
where MultiZork puts player objects.
|
|
|
|
object tree functions
|
|
---------------------
|
|
|
|
- getObjectPtr(objid) — returns pointer to 9-byte entry in object table
|
|
- getObjectProperty(objid, propid, *size) — walks property list, returns data
|
|
- unparentObject(objid) — removes from parent's child list (linear sibling walk)
|
|
- opcode_insert_obj — unparent, then insert as first child of destination
|
|
- opcode_remove_obj — unparent, clear parent/sibling fields
|
|
- opcode_get_parent/get_sibling/get_child — tree traversal
|
|
- opcode_set_attr/clear_attr/test_attr — bit manipulation on attribute flags
|
|
|
|
property tables: variable-length entries in descending ID order. each entry has
|
|
a size/ID byte (3 bits size, 5 bits propid) followed by data. 31 default
|
|
property values stored at start of object table.
|
|
|
|
multiplayer architecture
|
|
========================
|
|
|
|
the interesting part.
|
|
|
|
player state
|
|
------------
|
|
|
|
Player struct (multizorkd.c:96-127) stores per-player snapshots::
|
|
|
|
PC, SP, BP + full stack copy (uint16[2048])
|
|
object_table_data[9] — the player's object (parent/child/sibling/attrs)
|
|
property_table_data[32] — player name in ZSCII + properties
|
|
touchbits[32] — per-player room-visited flags (256 bits for 250 objects)
|
|
|
|
10 player-specific global variables:
|
|
location, deaths, dead, lit, verbose, superbrief,
|
|
alwayslit, lucky, loadallowed, coffin_held
|
|
|
|
connection pointer, username, rejoin hash
|
|
againbuf (last command for "again"/"g")
|
|
|
|
Instance struct wraps ZMachineState + up to 4 Players + database IDs + jmpbuf
|
|
for error recovery.
|
|
|
|
the 5-slot trick
|
|
----------------
|
|
|
|
Zork 1 uses objects 1-250 of 255 possible. objects 251-254 become player
|
|
objects. BUT: no free space in the object table — Infocom's tools pack it
|
|
tight, with property data immediately after object 250.
|
|
|
|
solution: virtual memory. player object data lives outside z-machine address
|
|
space. when the VM requests object 251-254:
|
|
|
|
- getObjectPtr() returns a pointer to player.object_table_data (external buffer)
|
|
- property table addresses map to 0xFE00-0xFFFF (high memory, unreachable by
|
|
game code)
|
|
- get_virtualized_mem_ptr() intercepts these fake addresses and redirects to
|
|
player.property_table_data
|
|
|
|
this works because Zork 1 never directly accesses the object table by address —
|
|
it always uses the z-machine object opcodes. the override hooks intercept at
|
|
the opcode level.
|
|
|
|
the player swap
|
|
---------------
|
|
|
|
step_instance() (multizorkd.c:1308-1507) — the core multiplayer function.
|
|
|
|
before running:
|
|
|
|
1. restore player's PC/SP/BP/stack from snapshot
|
|
2. swap in player-specific globals (globals[0]=location, globals[60]=lucky, etc)
|
|
3. patch 6 hardcoded "object #4" references in Zork's bytecode to point at this
|
|
player's object
|
|
4. set INVISIBLE and NDESCBIT on the player object (so they don't see themselves)
|
|
5. if input provided, write it into z-machine's input buffer and tokenize
|
|
|
|
execute:
|
|
|
|
6. while (!step_completed) { runInstruction(); }
|
|
|
|
after running:
|
|
|
|
7. save PC/SP/BP/stack to player snapshot
|
|
8. save player-specific globals back to player struct
|
|
9. save touchbits (room visited flags) for all rooms
|
|
10. check for win condition (globals[140] != 0), reset touchbits for endgame room
|
|
11. clear INVISIBLE/NDESCBIT on player object
|
|
12. if QUIT opcode hit, flag player as game_over, drop connection
|
|
|
|
error recovery: setjmp/longjmp around the execution loop. if die() is called,
|
|
longjmp back, broadcast error, free instance.
|
|
|
|
chat system
|
|
-----------
|
|
|
|
- "!message" — say to players in same room (room = same parent object)
|
|
- "!!message" — broadcast to all players in instance
|
|
- regular text — z-machine command for current player
|
|
|
|
the game-specific problem
|
|
--------------------------
|
|
|
|
the multiplayer code is riddled with "ZORK 1 SPECIFIC MAGIC" comments.
|
|
|
|
hardcoded::
|
|
|
|
object 4 = player
|
|
object 82 = rooms container
|
|
object 180 = West of House (start room)
|
|
6 bytecode locations where Zork compares against literal 4 instead of using PLAYER global
|
|
10 specific global variable indices for player state
|
|
attribute 3 = TOUCHBIT, attribute 7 = INVISIBLE, attribute 14 = NDESCBIT
|
|
|
|
Ryan's own words: "there's an enormous amount of hardcoded tapdancing in
|
|
multizork to work with this build of Zork 1. It won't even work with other
|
|
versions of Zork 1, as the global variables will be in a different order at the
|
|
least."
|
|
|
|
for other games, you'd need to:
|
|
|
|
1. identify which globals are player-specific (game analysis required)
|
|
2. find hardcoded player object references (disassembly required)
|
|
3. determine room structure (game-specific object tree analysis)
|
|
4. identify visibility/description attributes (game-specific)
|
|
|
|
this is fundamentally a per-game effort, not a generic system.
|
|
|
|
persistence
|
|
===========
|
|
|
|
SQLite schema in multizorkd.c (lines 170-250)::
|
|
|
|
instances:
|
|
hash, num_players, start/save times, dynamic_memory (BLOB), story filename
|
|
|
|
players:
|
|
hash, username, PC/SP/BP, stack (BLOB), object/property/touchbits (BLOBs),
|
|
all player-specific globals, game_over flag
|
|
|
|
transcripts:
|
|
player FK, texttype (output/input/system), content text
|
|
|
|
crashes:
|
|
instance FK, timestamp, current_player, PC, error string
|
|
|
|
blocked:
|
|
IP address blocking for failed logins
|
|
|
|
used_hashes:
|
|
tracks issued rejoin codes
|
|
|
|
auto-save every 30 moves. full dynamic memory snapshot + all player state.
|
|
|
|
reconnection: players get a 6-char hash code at game start. typing the code at
|
|
login restores their session, even if all players disconnected and the game was
|
|
archived.
|
|
|
|
transcripts saved for every session — accessible on the web
|
|
(multizork-transcripts.php).
|
|
|
|
telnet server
|
|
=============
|
|
|
|
simple custom telnet implementation (not a library):
|
|
|
|
- select()-based I/O loop (no threads)
|
|
- newline conversion (\n -> \r\n)
|
|
- dynamic output buffers (realloc on growth)
|
|
- connection state machine: READY -> DRAINING -> CLOSING
|
|
- input function pointers per connection (login/lobby/ingame) — state machine
|
|
pattern
|
|
- IP-based rate limiting (blocked table, 24h timeout)
|
|
|
|
no telnet option negotiation (no NAWS, no GMCP, no MSDP). raw text in/out.
|
|
|
|
what we learn from this
|
|
========================
|
|
|
|
step execution works
|
|
--------------------
|
|
|
|
runInstruction() + step_completed flag = clean pause/resume at input prompts.
|
|
this is the model we want. whether we use viola, zvm hybrid, or write our own,
|
|
this pattern is proven.
|
|
|
|
memory virtualization is elegant
|
|
---------------------------------
|
|
|
|
two function pointer overrides (get_virtualized_mem_ptr, remap_objectid) let
|
|
one codebase serve both single-player and multiplayer. no opcode changes
|
|
needed. the principle: intercept at the memory access level, not the
|
|
instruction level.
|
|
|
|
multiplayer z-machine is possible but game-specific
|
|
----------------------------------------------------
|
|
|
|
MultiZork proves the concept works. multiple players, independent inventory,
|
|
shared world. but the implementation requires deep knowledge of the specific
|
|
game's internals. a generic solution would need:
|
|
|
|
- static analysis tools to identify player-specific globals
|
|
- disassembly tools to find hardcoded player references
|
|
- per-game configuration files mapping globals, objects, attributes
|
|
|
|
this is not impossible but it's a significant tooling investment.
|
|
|
|
the object table is more hackable than expected
|
|
------------------------------------------------
|
|
|
|
adding objects to a running game works IF the game uses opcodes (not direct
|
|
memory access) for object operations. the virtual memory trick is clever — fake
|
|
addresses that the VM can't distinguish from real ones.
|
|
|
|
persistence patterns transfer directly
|
|
---------------------------------------
|
|
|
|
the SQLite schema maps cleanly to our needs: quetzal blobs (they use raw memory
|
|
snapshots), player state, transcripts. the reconnection hash is a good UX
|
|
pattern.
|
|
|
|
V3 vs V5 matters for multiplayer
|
|
---------------------------------
|
|
|
|
V3: 255 objects, 8-bit IDs, 9-byte entries. tight but hackable.
|
|
|
|
V5: 65535 objects, 16-bit IDs, 14-byte entries. much more room for player
|
|
objects but also much more complex object tree. V5 games may use objects more
|
|
aggressively (modern IF is larger). the "5 spare slots" trick is V3-specific.
|
|
|
|
comparison with viola and zvm
|
|
==============================
|
|
|
|
::
|
|
|
|
aspect | mojozork | viola | zvm
|
|
----------------|--------------------|----------------------|--------------------
|
|
language | C | Python | Python
|
|
version support | V3 only | V1-V5 (V6-V8 partial)| V1-V5 (structural)
|
|
can run games | yes | yes | no (~46/108 opcodes)
|
|
global state | GState pointer | 13 modules w/globals | instance-based (clean)
|
|
IO abstraction | function pointers | adapter pattern | abstract ZUI
|
|
step execution | runInstruction() | feasible (5 lines) | feasible (5 lines)
|
|
multiplayer | working (Zork 1) | not supported | not supported
|
|
save/restore | custom format | quetzal | stubbed
|
|
error handling | die() + longjmp | sys.exit() (8+ locs) | exceptions (clean)
|
|
object tree | complete V3 | complete V3-V5 | read complete, write partial
|
|
test suite | script-based | none | minimal
|
|
usable by us | reference only (C) | embeddable with work | skeleton needs finishing
|
|
|
|
the verdict from if-journey.rst still holds: "zvm has the architecture you'd
|
|
want. viola has the implementation you'd need." MojoZork adds: "and mojozork
|
|
has the multiplayer proof you need to believe level 4 is achievable."
|
|
|
|
related documents
|
|
=================
|
|
|
|
- ``docs/how/if-journey.rst`` — integration vision and roadmap
|
|
- ``docs/how/viola-embedding-audit.rst`` — viola architecture audit
|
|
- ``docs/how/zvm-embedding-audit.rst`` — zvm architecture audit
|
|
- ``docs/how/if-integration.txt`` — original integration plan
|
|
- Ryan's Patreon post (Aug 17, 2021) — motivation and technical narrative
|
|
behind MultiZork
|