Compare commits
7 commits
305f25e77a
...
af941b329b
| Author | SHA1 | Date | |
|---|---|---|---|
| af941b329b | |||
| d15238eb4e | |||
| 91c1af86e2 | |||
| ca53357730 | |||
| e6bfd77464 | |||
| 84cd75e3a3 | |||
| e0910151f4 |
1 changed files with 378 additions and 0 deletions
378
docs/how/mojozork-audit.rst
Normal file
378
docs/how/mojozork-audit.rst
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
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
|
||||
Loading…
Reference in a new issue