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