Advent of Code on the Z-Machine
Fantasy consoles like the Pico-8 are a great idea. A fantasy console provides a standardised and portable environment in which developers can explore ideas within creative constraints. The Z-machine, developed by Infocom in 1979, is the earliest fantasy console I know, although this is probably the first time it’s been called that.
Infocom faced the problem of porting their text adventures to the multitude of platforms that existed at the time. Since they came out of the academic environment at mit, they applied a brand-spanking new solution to the problem: they built a virtual machine, the Z-machine, and made a compiler that produced bytecode for it. This meant that once they had made a new game, the new game could instantly be played on all platforms which had the Z-machine1 With a caveat. The Z-machine exists in multiple versions. For a long time, version 3 was the default for new games, and it was widely supported. Some games exceeded the capabilities of version 3 and were made for higher versions – they weren’t quite as portable, so the designer had to show that they could do good enough things with the more capable machines that the tradeoff would be worth it., and if they ported the Z-machine to a new platform, users of that platform could instantly play all games developed for the Z-machine. Today, when Flash, Java, ecmaScript and others have showed us how successful this approach is, we take it for granted, but at the time that was just not how you did things.
The z-machine is still alive
The observant reader has noticed that I use the present tense when mentioning the Z-machine. That’s because it still exists, and it’s still used. Granted, many text adventures today are written for the more capable Glulx virtual machine – a spiritual successor to the Z-machine – but then again, many text adventures are also written for the Z-machine.
Let’s say we want to learn to program the Z-machine. How do we get started?
We could emit bytecode directly. That would probably be a cool learning experience. It would also be kind of annoying, so we’ll ignore that alternative.
The next step up on the abstraction staircase is zil, that weird, low-level Lisp-looking thing that’s not at all Lisp.
)) ) ( > ) (T )>>
There is a modern compiler for zil, which can be used to build the source code from the original games, or indeed write new games. It’s tempting! But in the end, there are two reasons I opted not to write zil:
- First off, it is really low level. From what I understand, not even the people at Infocom wrote raw zil. Instead, they used Lisp macros that generated zil.2 They probably also had a zil interpreter in Lisp, meaning they could tweak parsing, move items, change location properties, etc. with the game running. It must have been a very productive way to iterate.
- Although there are plenty of examples of zil code (all of the Infocom games, for one thing!) there’s relatively little documentation.
Inform 6 is another language that compiles to Z-machine bytecode. From a birds eye view, it similar to zil because they are both similar to Z-machine bytecode. Inform 6 was created before the zil source code for the Infocom classics was released; Inform was designed by reverse-engineering the original Z-machines. The Inform 6 compiler comes as a set of stand-alone C source files that are built with the command
$ cc -O2 -o inform *.c
So refreshing. Once we have that compiler, we can give it a source file of
Inform 6 code and it produces bytecode for the Z-machine. Here’s
hello.inf.
[Main;
print "hello, world!^";
];
This looks strange, but it’s not very. Subroutines are declared with square
brackets. The entrypoint of an Inform 6 program is called Main. Names are not
case-sensitive, but subroutines conventionally use Pascal_Case. Carets in
strings become newlines.
We compile this to Z-machine bytecode with
$ inform hello.inf
and then we have a file called hello.z5 which is bytecode that runs on the
Z-machine, version 5. The compiler defaults to version 5 because it is most
popular today, but when Infocom developed games, they defaulted to the less
capable version 3, because it had lower system requirements. We can ask the
Inform 6 compiler for version 3 bytecode as long as our code is compatible with it.
$ inform -v3 hello.inf
Now we get a hello.z3 file with bytecode which should run on any Z-machine. I
happen to have bocfel lying around.
$ bocfel hello.z3 Bocfel 2.1.2 Using the Cheap Glk Implementation, library version 1.0.6. hello, world! $
Great! Now what’s that way everyone goes about learning a new language? Riiight, Advent of Code. Before committing to doing it in Inform 6 this year, maybe we should try the first couple of days of the previous year as a warm-up exercise. Let’s see, here’s day one. Okay, some numbers, a little sorting, shouldn’t be too hard.
A machine for olden times has olden integers
Except … look at the full puzzle input. The first number is something like 76309. Now guess the bit depth of integers in the Z-machine. Yup, it uses 16-bit integers, the highest of which is 65535. We cannot even represent the puzzle input natively on the Z-machine. Smarter people would close the tab and move on to other things, but I wrote the 160 lines of Inform 6 to do the required long integer maths using arrays of four bytes for storage.
It contains functions such as this one, which adds left and right, storing
the result in sum.3 Why not update one of the addends in place? That would
have been a valid design decision. Maybe that’s the way these things usually are
done. The current method signature is just the first thing that came to mind.
[long_plus left right sum _i _carry _temp;
for (_i = 3 : _i >= 0 : _i--) {
_temp = left->_i + right->_i + _carry;
sum->_i = _temp & $ff;
@log_shift _temp (-8) -> _carry;
}
];
There are a few things to note in this code:
- Inform 6 only has one type of local variable: subroutine parameter. But all parameters are optional as far as the compiler cares, so to get local variables we declare the local variables we need as parameters and then the caller doesn’t supply values for them. Conventionally, these parameters are prefixed with an underscore.
- You didn’t read the
forloop wrong. It actually uses colons to separate the parts of the loop head. Odd. - The byte-wise addition is greatly simplified by having 16-bit integers in the
Z-machine, since we just store the output of each half-adder in
_tempand it holds both result and carry. - The right arrow mostly indicates byte array indexing, such as in
left->_i. - When Inform 6 doesn’t have a high-level keyword, it is possible to embed
Z-machine instructions directly in the Inform 6 source code, by prefixing the
opcode with
@. This embedded assembly syntax uses the right arrow to indicate the destination of operations. - The
_carryvariable isn’t explicitly initialised before it is read for the first time. Is it valid Inform 6 to not do that? I don’t know. I’m just an amateur who was handed a hammer with no instruction on how to use it. It seems thatbocfelreliably fills my non-initialised local variables with zeroes, but I don’t know if that’s a guarantee. There are surely many other such errors that you wouldn’t find in professional code.4 I have been informed that Inform 6 guarantees parameters are initialised to zero unless they are given a value by the caller. But my point still stands! I could be missing other things.
Most of the other methods are fairly natural, if we have implemented long integer maths before. We will want to assign regular integers into long integers:
[long_set source target _temp;
bzero(target, 4);
target->3 = source & $ff;
@log_shift source (-8) -> _temp;
target->2 = _temp & $ff;
];
We need methods to store and fetch these long integers from arrays:
[long_arrfetch source target offset _i;
for (_i = 0 : _i < 4 : _i++)
target->_i = source->(4*offset+_i) & $ff;
];
[long_arrstore source target offset _i;
for (_i = 0 : _i < 4 : _i++)
target->(4*offset+_i) = source->_i & $ff;
];
We can use arrstore to copy from one long integer to another.
[long_copy source target; long_arrfetch(source, target, 0); ];
We can read a series of bytes into a long integer:
[long_read buffer offset target _i _j _temp;
long_set(0, target);
! Walking the length of the buffer.
for (_i = offset : _i < buffer->0 : _i++) {
! If we have a digit...
if (buffer->_i >= 48 && buffer->_i <= 57) {
! Take its numeric value.
_temp = buffer->_i - 48;
! Add it as the new least significant
! decimal digit of the long integer.
for (_j = 3 : _j >= 0 : _j--) {
_temp = _temp + target->_j * 10;
target->_j = _temp & $ff;
@log_shift _temp (-8) -> _temp;
}
} else {
! Return the position of the first non-digit.
return _i;
}
}
];
We can also convert a long integer into a series of digits:
[long_print value _i _count _temp;
! We're destructively extracting digits from the
! value, so better create a local copy of it
! before proceeding.
long_copy(value, _qx);
while (true) {
_temp = 0;
! Divide out the least significant digit.
for (_i = 0 : _i < 4 : _i++) {
@log_shift _temp 8 -> _temp;
_temp = _temp + _qx->_i;
_qx->_i = _temp / 10;
_temp = _temp % 10;
}
! Convert it to a character and store.
_fbuf->_count = 48 + _temp;
! We should never see more than 10 digits.
if (++_count > 10) break;
! When we have divided out all digits, we
! exit. (We always run one iteration to
! make sure we print 0 when it is 0.)
if (long_iszero(_qx)) break;
}
! We'll get the digits least significant first
! so we print the buffer backwards. (Although
! I'd argue we made a mistake when we copied
! the order Arabs write their numbers and
! transplanted it into a left-to-right language.
! Clearly, the Arabs meant their numbers to be
! least significant digit first in the direction
! the language is written.)
for (_i = _count - 1 : _i >= 0 : _i--) {
print (char) _fbuf->_i;
}
];
In this code we see our first print directive, (char). Normally the print
instruction would print the numeric value of the character, but we can instruct
it to interpret the next value as a character by giving it such a directive.
Although it looks like a type cast, it is not. It is part of the print
statement syntax of Inform 6.
Although almost all long integer methods that need temporary storage use the two
registers _rx and _tx, the print method specifically uses _qx and it is
the only method that uses that register. It makes debugging a lot simpler to be
able to call print inside another subroutine without having the printing method
clobber the temporary storage of the other subroutine! Pro tip.
We need a size comparison too.
[long_lessthan left right _i;
for (_i = 0 : _i < 4 : _i++) {
if (left->_i < right->_i) return true;
if (left->_i > right->_i) return false;
}
! If it gets here, they are equal, which means
! left is not less than right.
return false;
];
I’m not sure why I show you all this. It’s all fairly standard long integer maths implementation, and nothing relevant to the Z-machine at all. I hope you didn’t fall asleep. Sorry.
One thing that turned out much simpler than I thought it would be was the subroutine to sort arrays of these things. Granted, it’s only simple because of the other subroutines supporting it, but still!
[long_sort arr n _i _j;
for (_i = 1 : _i < n : _i++) {
long_arrfetch(arr, _rx, _i);
for (_j = _i-1 : _j >= -1 : _j--) {
long_arrfetch(arr, _tx, _j);
if (_j >= 0 && long_lessthan(_rx, _tx)) {
long_arrstore(_tx, arr, _j+1);
} else {
long_arrstore(_rx, arr, _j+1);
break;
}
}
}
];
Here, _rx and _tx are global temporary registers for use within this module.
The Z-machine is designed to not do any dynamic allocation outside of parameters
on the stack, so any arrays (e.g. to hold long integers) need to be allocated
statically.5 There are Inform games that rely on dynamic allocation and they
twist the Z-machine in awkward ways to achieve that, from what I understand.
Andrew Plotkin’s Lists and Lists comes to mind.
Solving the first day’s problems
With that little nightmare of implementing long integer maths out of the way, we
can start to figure out the Z-machine again. There seems to be a few ways to
read input from the user, but the main one is the instruction with opcode
@aread in version 5.6 The corresponding opcode in version 3 is @sread,
but I never got it to work after a quick test. I’m sure I could with more
tinkering, but that would be a distraction.
Here’s a method that uses it to read a line of user input into a buffer.
! Read a line into buf, returning the index of the ! first read character. [read_line buf l _discard; ! Zero out the buffer while keeping the initial ! element which indicates buffer size. l = buf->0; bzero(buf, l); buf->0 = l; @aread buf -> _discard; return 2; ];
The @aread instruction stores the number of read characters in the second
location of the array, and returns the final character of the input. We’ll
ignore both of those, because we’ll read the input until the first nul
character to figure out where it ends.
We will also have a function that skips past non-digits in a character buffer to advance to the next number in the input.
[skip_nodig buf offset;
while (offset < buf->0 && (buf->offset < 48 || buf->offset > 57)) offset++;
return offset;
];
That’s most of the preparation. Using this code, we can solve the first half of the first day of Advent of Code 2024. Note that we do not use the Inform 6 standard library at all. The compiler only sees the code we have written and the Z-machine only executes instructions compiled from our code. Why this matters will be explained later.
Include "util.h"; Include "long.h"; Constant MAX_INPUT = 20; Constant MAX_LINES = 1000; ! Read buffer, used to accept user input. Array rbuf->(MAX_INPUT); ! Temporary storage locations for long integers. Array ax->4; Array bx->4; Array cx->4; Array dx->4; ! We will need four bytes for each number in the two ! columns of full input. Array as ->(MAX_LINES*4); Array bs ->(MAX_LINES*4); [Main _next _n _i; ! Set the buffer size in the buffer. rbuf->0 = MAX_INPUT-1; while (_n < MAX_LINES) { ! Try to read another line of input. _next = read_line(rbuf); if (rbuf->_next == 0) break; ! Extract the two numbers from the input. _next = long_read(rbuf, _next, ax); _next = skip_nodig(rbuf, _next); _next = long_read(rbuf, _next, bx); ! Push the numbers into their arrays. long_arrstore(ax, as, _n); long_arrstore(bx, bs, _n); _n++; } ! Sort each array. long_sort(as, _n); long_sort(bs, _n); ! Accumulate distances into dx. long_set(0, dx); ! Compute the distances between parallel values. for (_i = 0 : _i < _n : _i++) { long_arrfetch(as, ax, _i); long_arrfetch(bs, bx, _i); long_minus(ax, bx, cx); long_plus(dx, cx, dx); } print "Cumulative distances: "; long_print(dx); print "^"; ];
For the full input, this takes four seconds to run in bocfel on my machine,
but it produces the correct answer! To solve the second half of the day, we can
tack on another loop at the end.
long_set(0, dx); ! Step through both arrays somewhat cleverly to ! find matches more cheaply than in square time. ! Well, it would have been more cheaply than ! square time if we didn't choose a square time ! algorithm for sorting both inputs... for (_i = 0, _j = 0 : _i < _n : _i++) { long_arrfetch(as, ax, _i); long_arrfetch(bs, bx, _j); ! If a is greater than b, then we need to ! advance j until they match. while (_j < _n && long_lessthan(bx, ax)) { _j++; long_arrfetch(bs, bx, _j); } _firstmatch = _j; ! If a is equal to b, it contributes and we ! advance j. while (_j < _n && ~~long_lessthan(ax, bx)) { long_plus(dx, ax, dx); _j++; long_arrfetch(bs, bx, _j); } ! Now a is less than b, so we need to advance i. ! But first we rewind b so that other equal ! elements of a have a chance of counting their ! contributions too! _j = _firstmatch; } print "Similarity score: "; long_print(dx); print "^";
Using objects to solve the second day
So far, we have only seen procedural code, but Inform 6 is also somewhat
object-oriented7 Sometimes the Z-machine is described as one of the first
widely-installed object-oriented systems, but there is very little support for
object-orientation in the Z-machine itself. Also zil does not support what we
would today recognise as object-oriented code. It has things called objects, but
they are closer to C structs., with the idea being that messages being passed
between objects is a useful way to simulate interactions in the world. It still
won’t allocate objects dynamically, so for the most part it is used with
singleton objects.8 It is possible to create objects during run-time, but
then they come from a statically allocated fixed-size pool.
Inform 6 supports dual object hierarchies: it encodes is-a relationships through inheritance, and has-a relationships through an object tree indicating containment. We can use the first half of the second day’s puzzle to illustrate both.
To model the second day’s problem, we begin by defining an attribute indicating that a report is safe. An attribute is a boolean flag (in fact, they are called flags in zil) that all objects start out not having, but it can be set on any of them.
Attribute valid;
Then we create a class for the generic report approver.
Class Report_Approver with ! Store the previous value for range calculations. _prev nothing, ! Method that decides whether to accept a new value. _accept, ! Default reject method that accepts the first value, ! rejects any changes that are too large, and otherwise ! defers to the accept method. _reject [next; if (self._prev == nothing) return false; if (abs(next - self._prev) > 3) return true; return ~~self._accept(next); ], ! When appending a number, if it is rejected, remove ! the valid attribute from this approver. append [next; if (self._reject(next)) give self ~valid; self._prev = next; ], ! To reset an approver, remove the previous value ! and default back to a valid report again. reset [; self._prev = nothing; give self valid; ], has valid;
Here we can see some new features. Properties are like attributes except instead
of booleans, they store values. Importantly, they can store anonymous
subroutines, which are declared like normal subroutines except without a name,
and inside them we have access to the implicit variable self. The keyword
give sets and unsets flags on objects (sorry, I mean “assigns attributes to”
objects, and “removes attributes from” objects).
As before, properties/methods that are not meant to be public are conventionally named with a leading underscore.
Next we define an aggregate approver that judges the validity of a report by
consulting multiple sub-approvers. It will accept a report as long as any of the
sub-approvers accept it. We inherit from the Report_Approver class to do it,
and we override both public methods append and reset.
Report_Approver multi_approver
with
append [next _sub _anyvalid;
! Append to all sub-approvers.
objectloop (_sub in self) {
_sub.append(next);
! As long as any of them are valid...
if (_sub has valid)
_anyvalid = true;
}
! ...then the aggregate is also valid.
if (~~_anyvalid) give self ~valid;
],
reset [_sub;
! Reset all sub-approvers
objectloop (_sub in self) _sub.reset();
! Then perform the same reset as
! the parent class.
self.Report_Approver::reset();
];
The reset method on this object calls the reset method of its superclass.
There are a few ways this can be done9 In some instances we can define
properties as additive and the full inheritance chain is consulted
automatically. but this seemed easiest here.
We also see the objectloop Inform 6 keyword, which starts a special kind of
loop that iterates through the direct descendants of an object in the object
tree.10 We could iterate through the children using object relationship
methods like parent, child, and sibling, but the objectloop is more
convenient and easier to read. As a reminder, the object tree is not the same
thing as the inheritance tree; the object tree is about which objects contain
each other (has-a, rather than is-a).
So far, we have not seen which objects are contained by the multi_approver,
but that happens next!
Report_Approver -> decremental_reports
with
_accept [next;
return next - self._prev < 0;
];
Report_Approver -> incremental_reports
with
_accept [next;
return next - self._prev > 0;
];
This is another way the right arrow is used in Inform 6. When we define objects
with a right arrow, they are automatically inserted as children into the object
defined just before. This means both decremental_reports and
incremental_reports become children of multi_approver.11 There are also
functions to move objects around in the object tree, if they need to move during
runtime, for example.
Finally, we use this by reading in numbers and pushing them into the aggregate
approver, counting the number of approved plans in the local variable _i.
while (_n < 1000) { ! Try to read another line of input. ! Stop if there is no more input. _next = read_line(rbuf); if (rbuf->_next == 0) break; while (rbuf->_next > 0) { ! Extract a number from the input. _next = long_read(rbuf, _next, ax); _next = skip_nodig(rbuf, _next); ! Truncate long integer into short integer ! and send it to the aggregate approver. multi_approver.append(long_trunc(ax)); } ! If the reports are still safe now, ! increment count and then reset. if (multi_approver has valid) _i++; multi_approver.reset(); }
The puzzle input for this day fits comfortably in the Z-machine short integers, but since we already had a method for parsing numbers that happens to produce a long integer, we might as well use it and then truncate it to a short integer.
The next half of that day’s puzzle sounds like it would need expensive backtracking unless done cleverly, and I’m all out of clever for this article, so I’ll stop here. At this point, I feel fairly done with Inform 6 for Advent of Code problems. I’ve toyed with the system and gotten a much better understanding of it. I’m reminded of why I don’t do more low-level programming: it’s a fun challenge, to be sure, but when I write code I do it mainly for the result, not for the challenge. If I want a mental challenge, I’d much rather play the game of go or something.
Why learn Inform 6
Now why, if I don’t like low-level programming, would I do this in the first place? Great question!
A little while ago I learned Inform 7, which I’m not entirely happy with. I like the rule-based approach, but I strongly dislike the syntax. I started looking into Inform 6 as an alternative. While Inform 7 comes as one, relatively opaque package, Inform 6 is split into two parts:
- The Inform 6 language, which compiles down to Z-machine bytecode and looks relatively sensible, as we have seen in this article; and
- The Inform 6 standard library, which acts as text adventure engine framework, providing basic interactions and world model.
This means we can learn the Inform 6 language in isolation, separate from its standard library!
If there’s anything I’ve learned about learning new systems, it’s that it’s useful to pull them apart and see exactly where the boundaries between their parts go. Exactly where does the Z-machine stop, and Inform 6 take over? Exactly where does Inform 6 stop, and the standard library take over? Incredibly useful to be able to answer those questions, but it’s hard if we try to learn the entire system as one unit. This article, then, was pulling the Inform 6 language apart from its standard library, and learning the where the boundaries go.
Something else that’s cool about this separation is that instead of including the standard library, we can include any other library to provide our basic interactions and world model. I, for example, have been eyeing PunyInform, which is compatible with version 3 of the Z-machine. That seems like a useful creative constraint. Version 3 only supports a maximum of 255 objects. If I can’t make a good game with 255 objects, it is not going to help to give myself more objects to hang myself with.
Wouldn’t this mean low-level programming? Not quite. With the addition of a standard library (either the one that used to ship with Inform 6, or an alternative like PunyInform), the Inform 6 language becomes much higher level – at least when trying to make text adventures.
Well, we’ll see. There’s a PunyInform competition starting soon and ending in November. If I can produce a game in time for that, I’ll let you know. If you hear nothing, I didn’t.
Acknowledgements
Many thanks to the members of the IntFiction forums for pointing out errors in a draft of this article.
What's Your Reaction?
Like
0
Dislike
0
Love
0
Funny
0
Angry
0
Sad
0
Wow
0