The Visible Zorker
Tuesday, January 14, 2025
Comments: (live)
Tagged: if, interactive fiction, zork, infocom, zil, zarf
Here's a little something I've been working on: The Visible Zorker!
This screenshot has spoilers for Zork 1. This whole project is spoilers for Zork 1. That's the point.
Really, go give it a shot. It's a toy. You can read the rest of this post later.
...Okay, a quick introduction. The left pane is regular old Parchment, the Z-code interpreter, playing Zork 1. You type commands; the game responds.
Just regular old Parchment? Not quite! This is Parchment exposed. The upper right pane shows the stack trace for the current turn. That's all the ZIL functions called, and all the text printed, when executing the most recent command.
And the bottom right pane shows the ZIL source code -- the original text, written by Infocom folks in the 1980s. Click on any function or printed string; it'll show you that code in context.
Now check out the other tabs!
The "World" tab shows the game world as nested objects. The "State" tab shows ZIL global variables. "Timers" is the table of timers and daemons -- functions called every turn or counting down to a future call.
All of these displays update live, every turn, as you play the game. You can click on any line to see the ZIL source that implements it.
And those green buttons? Those display my comments on the source. ZIL isn't the easiest language to read (it's a Lisp derivative), so I wrote up some helpful footnotes.
Really, go play with it. Run around. See how Zork works. Haven't you always wondered?
(I mean it about the spoilers, though.)
Seriously, you did what?
Infocom's games are among the best-researched works in videogame history. The Z-machine format has long since been documented. The games have been disassembled and analyzed. And then, in 2019, we got their original ZIL source code.
But most players have never read this stuff. What if I built a way to visualize the Z-machine as it executed? Like the Visible Woman at the science museum. Internals illuminated; cheerfully explaining itself; transgressively fascinating. (Especially if you're a twelve-year-old science nerd... boy.)
I think of it as a kind of exploratory programming. It's on the code-reading side rather than code-writing -- but reading code is so much of software development!
Or you can think of it as the Penn-and-Teller approach to the magic of game design. Zork is a great trick, and knowing how it works makes it greater.
And wow, this was a fun project to work on. A challenge, on several levels.
What was hard about this?
The first problem was extracting the data that the Visible Zorker needs.
I said that Zork (and the Z-machine) had been analyzed to the bones right? Yes, but not in the way I needed. Remember, ZIL is a compiled language. All the functions in the source code have been converted to numeric opcodes, operating on numbers.
Here's a bunch of opcodes extracted from the compiled game file. This is the function at memory address $100D8
. We've had this listing since the 1990s:
Routine 100d8, 2 locals (0000, 0000)
100dd: GET_PROP L00,#07 -> L01
100e1: JL L01,#00 [FALSE] RTRUE
100e5: SUB #00,L01 -> -(SP)
100e9: PUT_PROP L00,#07,(SP)+
100ee: GET_PROP L00,#11 -> -(SP)
100f2: CALL (SP)+ (#04) -> -(SP)
100f7: RTRUE
And here's the corresponding ZIL source, which we got in 2017:
<ROUTINE AWAKEN (O "AUX" (S <GETP .O ,P?STRENGTH>))
<COND (<L? .S 0>
<PUTP .O ,P?STRENGTH <- 0 .S>>
<APPLY <GETP .O ,P?ACTION> ,F-CONSCIOUS>)>
T>
If you have a reference, you can see how these match up. The first line gets property 07 from the object in local variable 00 -- that must be the STRENGTH
property. It stores that value in local variable 01. Then it checks whether that's less than zero. (JL
is "jump if less than...") And so on.
But -- here's the trick -- how did I know that these definitions went together? How did I know that function $100D8
corresponded to the AWAKEN
routine rather than, say, I-FIGHT
or INFESTED?
In some cases it's easy. Here's another disassembled routine:
Routine 10a3e, 0 locals ()
10a3f: JE G78,#39,#23,#2b [FALSE] 10a4f
10a46: PRINT_RET "You can't do that."
10a4f: JE G78,#38 [FALSE] RFALSE
10a53: PRINT "It looks pretty much like a "
10a66: PRINT_OBJ G76
10a68: PRINT_RET "."
The PRINT
and PRINT_RET
opcodes contain embedded string data -- the disassembler knows how to decode this. It's easy to find the ZIL code that corresponds to that. It must be this function:
<ROUTINE DUMB-CONTAINER ()
<COND (<VERB? OPEN CLOSE LOOK-INSIDE>
<TELL "You can't do that." CR>)
(<VERB? EXAMINE>
<TELL "It looks pretty much like a " D ,PRSO "." CR>)>>
So the first thing I did was write a ZIL parser. It runs through the source files and parses all the functions. For each function, it records (a) the function name; (b) the location in the ZIL source; (c) all the strings used in TELL
statements.
And then I wrote a parser for the disassembly dump, which runs through and extracts (a) the function address and (b) all the embedded strings in PRINT
opcodes.
I figured I'd have to write a fussy search algorithm to match up functions in the first list with functions in the second list. And for function with no embedded text, like AWAKEN?
I'd have to match them up by hand!
...Then it turned out that the ZIL compiler generated functions in strict source code order. I didn't have to do any searching; the two lists were already in the same order. Exploratory programming, right?
(It wasn't quite that easy. ZIL supports conditional compilation -- like #ifdef
in C -- and my parser had to account for that. Just a bit more work. On the up side, I needed those source code locations for the app anyhow.)
Well, that takes care of the functions. What about the objects? Here's a ZIL object definition:
<OBJECT LAMP
(IN LIVING-ROOM)
(SYNONYM LAMP LANTERN LIGHT)
(ADJECTIVE BRASS)
(DESC "brass lantern")
(FLAGS TAKEBIT LIGHTBIT)
(ACTION LANTERN)
(FDESC "A battery-powered brass lantern is on the trophy case.")
(LDESC "There is a brass lantern (battery-powered) here.")
(SIZE 15)>
The same disassembler can generate a list of the object data:
164. Attributes: 17, 31
Parent object: 193 Sibling object: 183 Child object: 0
Property address: 1a97
Description: "brass lantern"
Properties:
[18] 44 51 44 5f 44 c8
[17] 6e 32
[16] e9
[15] 00 0f
[14] 87 4d
[11] 87 5f
Happily, the object description ("brass lantern") is embedded in the object data, so that's easy to match up.
...or is it? What about this object dump?
59. Attributes: 5, 6
Parent object: 82 Sibling object: 60 Child object: 0
Property address: 1091
Description: "Maze"
Properties:
[30] 3c
[29] 36
[23] 3a
[11] 90 cd
The description is "Maze"... just like the other fourteen "Maze" rooms. How do I tell those apart?
Turns out the property data describes the exits. Property 23 is UP
, 29 is WEST
, 30 is EAST
, so can we find a maze room definition with pattern? We can. And hey, that tells us what rooms $36
, $3A
, and $3C
are too...
Mind you, at first I didn't know what property matched with which direction! Extra puzzle fun. But it was solvable, working backwards from the dead ends and the Troll Room.
Working through this mapping was a real deja vu moment. I was mapping the Zork maze! One room at a time, checking the exits... It felt like 1980 all over again.
Then I did it all again for the global variables list, the properties, the attributes...
After all that, I remembered that Allen Garvin, Ben Rudiak-Gould, and Ethan Dicks did lot of this analysis work back in 2007. That didn't solve all my problems -- they didn't have the ZIL source, so they made up their own function names and so on. ($100D8
is CheckStrength
in that file.) But it confirmed the property, attribute, and global variable numbers pretty well.
So after that it was easy, right?
Hooking up Parchment to a display UI was pretty easy. That was a question of collecting internal Z-machine info into a JS object and exporting it. (A list of global variable values, a list of object locations, a list of function addresses called this turn... Just numbers.) Then I had to convert all the address mappings I'd worked out (and objects, globals, etc) into JSON data. The UI loads all that JSON, and then it can display $100D8
as AWAKEN
.
Designing that UI was a journey. Again, exploratory: a very iterative process.
I started out with the basic ideas of a call tree, a list of printed strings, a table of objects. But how is that presented? Does the call tree include printing strings, or are those separate tabs? What does the source-code pane display at any given time?
I built a display pane, tried it out, and asked "What can't I see?" Then I did it again. And again. "What button am I reaching for that doesn't exist?" (I didn't know that the source pane needed forwards/backwards buttons until I reached for them.)
The Timers tab wasn't even an idea until I asked "Where is the lamp's battery counter stored, anyway?" I had unconsciously assumed it would be a property of the lamp object, because that's how Inform works. But it's not. It's not a global variable either. Where the heck is it?
Turns out it's a timer function which counts down from 200. When that runs out, it displays a message from LAMP-TABLE and resets to 100. Then 70, then 15, then it's dead. So the total lamp life is 385, but you have to dig quite a bit to understand why.
But you can't illuminate the workings of Zork without showing the lamp counter! So I added the Timers tab. Once I looked at it, I realized it was indispensable.
More questions...
How long did this take?
I wrote the ZIL parser over Thanksgiving, just because I was hanging out at my parents' place with my laptop. But I didn't seriously get started on the project until, let's see, Dec 22. That's when I started banging Parchment into the shape I needed.
So a bit over three weeks. Mind you, I've been pretty obsessed with this project. I worked on it a lot over the holidays.
Do you plan to add anything more to it?
Not much. There's a few UI tweaks left, and I could keep adding commentary as long as I feel like. But it's basically done.
I might add a "Map" pane. That would be fun. It'd be a bunch of SVG work, though, so I didn't try to include it in today's release.
Ooh, map! Could you use this tool to build a "more playable" Zork, with a built-in map, inventory and room displays, and so on?
You could certainly do some of that.
I think it's a limited path, though. Any attempt to really modernize the Infocom games would involve changing the game -- that is, compiling a new version. New game responses, enhanced descriptions, different timers, etc. Some people have already tried this; see this Modern Planetfall project.
That's a perfectly valid approach. But it's not what I'm interested in. I am approaching this as an educational project, a museum exhibit. Most obviously, I'm not worrying about spoilers. The Visible Zorker is 100% spoilers and that's fine.
If you really want to update Zork for a modern audience, you have to think about adapting the puzzles so they're more approachable but still good puzzles. That's a much harder problem.
Are you going to do the rest of the Infocom games?
I doubt it. It wouldn't be much fun. I'd have to reconstruct the object numbers and functions addresses and so on for each game, and maybe rewrite some of the display logic as well. (I tried to keep it general, but some things are still hard-coded for this specific Zork release.)
Also, I wouldn't learn any more React that way. One unstated goal of this thing was to get some React on my resume.
(See also BlorbTool, which I released last month.)
Mind you, if you want to hire me to do the rest of the Infocom games, I'd be fine with that. That'd be awesome. That's what resumes are for, right?
What about other kinds of games?
I dunno. What other kinds of games would work? Not many games have this kind of complex game logic and source code available.
Are there easter eggs in the Visible Zorker?
Where could I possibly hide such a thing?
Comments from Mastodon (live)
Please wait...
This comment thread exists on Mastodon. (Why is this?) To reply, paste this URL into your Mastodon search bar: