Technical Note No. 1
A User's Guide

Daisy Z-Machine

Infocom Interactive Fiction, Without Emulating Anything

This guide describes the Z-machine interpreter supplied with DaisyOS. The interpreter lets Daisy run the interactive fiction Infocom published in the 1980s: ZORK, DEADLINE, THE HITCHHIKER'S GUIDE TO THE GALAXY, and some thirty more. It runs them directly, without emulating any other computer.

To begin, type ZORK. The interpreter takes over the display and keyboard, plays the story, and returns you to the BASIC prompt when the story ends or when you press STOP.

 West of House             S:0   M:4

>open mailbox
Opening the small mailbox reveals a
leaflet.

>take leaflet
Taken.

>north
North of House
You are facing the north side of a
white house. There is no door here,
and all the windows are boarded up. To
the north a narrow path winds through
the trees.

>_
Zork I on the Daisy/1. Forty columns, status line in reverse video.
Note

Saving and restoring a game are not available. See Saving and Restoring, below.

What the Z-Machine Is

Infocom did not write their games for any particular computer. They wrote them for an imaginary one, the Z-machine, and then wrote a small interpreter for each real machine they wanted to sell to. A story file such as ZORK1.DAT holds the compiled program for that imaginary computer.

This is why a ZORK that runs on the Apple II also runs on the Commodore 64, the TRS-80 and the IBM PC. Only the interpreter changes.

Daisy is now one more machine on that list.

Why Not Emulate a Z80

You might ask why Daisy does not just emulate a Z80 and run the CP/M release of ZORK, since that software already exists.

The answer is that ZORK1.COM is itself a Z-machine interpreter, compiled for CP/M-80. To run it, Daisy would emulate a Z80 in order to reach the same virtual machine one layer further down. That costs a 64K address space in RAM and a disk drive to hold the story file.

Running the story directly costs 11,941 bytes of RAM and no storage hardware at all.

Note

The Z80 route was worked out in full before this port was started. It needs 87,524 bytes of a 98,304 byte machine, which leaves DaisyBASIC no room to run.

Where the Interpreter Came From

The interpreter is not original work. It descends as follows:

JZIP is a portable C interpreter covering Z-machine versions 1 through 8. It carries a BSD licence, so it may be used here. The files written for this port carry the GNU General Public Licence version 3, like the rest of DaisyOS.

A2Z_Machine is JZIP moved to an Adafruit ItsyBitsy M4, an ARM Cortex-M4 board. It was taken as the starting point because it had already settled the differences between JZIP's assumptions and those of the Arduino framework.

Why A2Z Could Not Be Used As It Stood

A2Z runs on a board with 192K of RAM and 2 megabytes of SPI flash holding a file system. It loads the whole story image into RAM and reads it from files on a mass storage device.

Daisy has 98,304 bytes of RAM, of which DaisyOS itself uses 28,080, and no mass storage of any kind. ZORK1.DAT is 84,992 bytes. The A2Z method therefore needs 113,072 bytes on a machine that has 98,304.

A2Z also uses four libraries Daisy does not carry (SdFat, Adafruit_SPIFlash, TinyUSB and mcurses), and its display code is written against mcurses throughout.

The Other Reference Port

A second port was studied: Zorkduino, by rossum, which runs ZORK on an ATmega328 with 2K of RAM. It manages this by putting every stack and memory access through a 160-byte cache and a 512-byte disk buffer, backed by a one-megabyte page file on an SD card.

Daisy needs neither method. It has forty-eight times the RAM of an ATmega328, and, more important, its processor maps flash into the normal address space.

What Was Kept, Replaced and Thrown Away

A2Z_Machine holds 23 source files. Eleven were kept, four were replaced, one was adapted, and seven were thrown away.

The kept files are the interpreter proper. They are unchanged apart from the corrections listed later in this guide. ztypes.h holds the types and the data access macros; see A Trap in the Header Field Names.

ReplacedLinesReason
fileio.cpp1432Assumes a file system.
osdepend.cpp769MS-DOS and VMS conditionals throughout.
acursesio.cpp578Written against mcurses.
memory.cpp463The paging cache. See below.
Thrown awayLinesReason
quetzal.cpp619Portable save format. Saving is not available.
a2z_machine.ino618Sketch. USB mass storage and story picker.
jzip.cpp171Command-line entry point.
getopt.cpp89Command-line argument parsing.
jzexe.h86Stories bound into MS-DOS executables.
license.cpp48Prints a licence banner.
jzip.h37Version banner.

Arduino Code That Had To Go

These were in the A2Z sources and had to be removed or replaced. They are listed because each one fails to compile with a message that does not point at the cause.

yield()  Replaced by ZmHostIdle().
min()  An Arduino macro. Replaced with a plain comparison.
byte  An Arduino type. The line using it was removed.
Blink()  LED helper. Removed along with fatal().
srandom()  Clashes with newlib. See below.
a2zrandom()  Removed. ZmRandom() replaces it.
randomSeed()  Removed. ZmSeedRandom() replaces it.
Important

A2Z writes its own srandom() because the AVR core has none. The ARM toolchain's newlib does have one, and the two clash at link time with the message multiple definition of 'srandom'. Both A2Z replacements were removed, and RANDOM_FUNC and SRANDOM_FUNC now go through zm_port.h to a single generator. That also means the firmware and the test harness produce the same numbers from the same seed.

How a Story File Is Divided

The next few sections describe the main difference between this port and the ones it came from. Read them before changing anything in the module.

Every Z-machine story splits into three regions. The boundaries are held in the header. The addresses shown are those of ZORK I.

Dynamic Memory
Globals, objects, the parse buffer. The game reads and writes this, so it must be in RAM.
11,859 bytes · in RAM
Base of static memory, header word $0E $2E53
Static Memory
The dictionary and the abbreviations table. The game only reads this.
read from flash in place
Base of high memory, header word $04 $4E37
High Memory
Routines and packed text. Executable code, read only.
read from flash in place
End of story file $14C00

What JZIP Does

JZIP was written for machines that read the story off a disk one page at a time. It keeps a chain of 512-byte pages in a least-recently-used cache.

A dictionary lookup is a binary search across the whole dictionary. If the dictionary were paged, every lookup would throw out the page the interpreter is running from, and the interpreter would spend its life reading the disk. So JZIP makes its resident area reach all the way up to the base of high memory, keeping dynamic and static memory in RAM at once.

For ZORK I that is 20,480 bytes, or forty pages, of which only 11,859 are ever written.

What Daisy Does Instead

The SAM3X maps its flash into the normal address space. A story compiled into the firmware as a const array can therefore be read with a plain subscript, at full speed, with no cache and no copying.

The cache, the page chain and the dictionary padding were all removed. Only the writable region goes in RAM.

JZIP resident area, ZORK I20,480 bytes
Daisy resident area, ZORK I11,859 bytes
Saved8,621 bytes

The other 73,133 bytes are read from flash where they sit.

A Trap in the Header Field Names

Warning

Two of JZIP's header field names do not mean what they look like they mean. Get them backwards and the port fails in ways that are hard to trace.

JZIP nameOffsetWhat it really is
h_data_size$04Base of high memory. 20,023 in ZORK I.
h_restart_size$0EBase of static memory. 11,859 in ZORK I.

The second one is the size of the writable region, and is the figure this port allocates.

The proof is simple. z_restart reloads h_restart_size bytes, and the save routine writes exactly h_restart_size bytes of datap. A save that left out part of dynamic memory would not restore.

Measured Cost

The figures below are read from the linked ELF file. Note that PlatformIO's reported RAM figure counts only .bss and leaves out .relocate, which on the Due is also in RAM. Both are counted here.

BuildStatic RAMFlash
DaisyOS alone28,080159,272
With interpreter and ZORK I30,848263,808
Added by this module+2,768+104,536

Of the flash added, 84,992 bytes is the story image itself. The interpreter takes about 19,544 bytes.

While a story is running, another 11,941 bytes are allocated. They are given back when it ends.

Dynamic memory (datap)11,859
Output line buffer41
Status line buffer41
Total while playing11,941
Peak RAM in use42,789 of 98,304  (43.5%)
Free at peak55,515
Note

The Z-machine stack, 1024 words or 2,048 bytes, is a static array. It is counted in the 30,848 above, not in the runtime figure.

DaisyBASIC's heap is left alone. The two never run at the same time, and the interpreter gives its memory back before the BASIC prompt returns.

Corrections to the Interpreter

Seven faults had to be fixed. Four follow from the smaller resident area described above and would not show up in a port that kept JZIP's paging. Two are old faults that a 40-column display brings out. The last was introduced by this port and was found by playing the game on the machine.

They are written down in full here, because anyone merging a later JZIP release will have to apply them again.

1 z_restart Runs Past the End of Dynamic Memory

Symptom: memory corruption after the RESTART command.

z_restart reloads dynamic memory in whole pages:

restart_size = ( h_restart_size / PAGE_SIZE ) + 1;
for ( i = 0; i < restart_size; i++ )
    read_page( i, &datap[i * PAGE_SIZE] );

For ZORK I that is 24 pages, or 12,288 bytes, written into a buffer of 11,859. The overrun is 429 bytes. JZIP gets away with it only because its datap is 20,480 bytes long.

Fix: the region is copied from the flash image by ZmReloadDynamic().

2 The Data Accessors Do Not Reach the Dictionary

Symptom: the parser recognises no words at all, and reads past the end of the allocation.

JZIP defines its accessors as plain subscripts:

#define get_byte(offset) ((zbyte_t) datap[offset])

In ZORK I the dictionary sits at $3B21 to $4E37, that is 15,137 to 20,023, which is above the 11,859-byte dynamic region. Every dictionary lookup would read past the end of datap.

Fix: get_byte, get_word, set_byte and set_word are now bounds-aware inline functions. Reads at or above zm_dyn_size are answered from the flash story image.

Writes at or above that line break the Z-machine specification. JZIP soaks them up silently into its resident copy of read-only data. Flash cannot be written at all, so such a store is now dropped and reported through ZmWriteGuard().

3 Unchecked Pointers Into Dynamic Memory

Symptom: none seen in normal play. A bad story file could read outside the allocation.

Three routines take a raw C pointer into datap from an address the story supplies: tokenise_line, z_sread_aread and z_encode.

Fix: each is now guarded by ZmDynamicRange().

4 SAVE Does Not Consume Its Branch Data

Symptom: interpret(): failing opcode: 0 right after typing SAVE.

This one needs explaining. In Z-machine versions 1 to 3, SAVE is a branch instruction. The bytes after the opcode are branch data, and the interpreter has to read them and act on them. From version 4 on, SAVE stores a result instead.

The rewritten z_save returned a status code to its caller but did neither. The branch data was left in the instruction stream and decoded as the next opcode.

Fix: every exit from z_save and z_restore, including the early refusals, now goes through one reporting point:

if ( h_type < V4 )
    conditional_jump( status == 0 );
else
    store_operand( status == 0 ? 1 : 0 );   /* 2 for restore */

This matters even though saving is unavailable, because refusing a save has to leave the interpreter running.

5 The Status Line Assumes Eighty Columns

Symptom: on a 40-column display, ZORK I prints the room name, the score and the move count on top of one another.

North of Score  0        Moves: 2      <- wrong
 West of House             S:0   M:4   <- fixed
Before and after, row 1 of the display.

z_show_status puts its fields at screen_cols - 31 and screen_cols - 15. On an 80-column screen those are columns 49 and 65. On Daisy's 40-column screen they are columns 9 and 25.

The room name "North of House" is fourteen characters and has already gone past column 9. pad_line sets status_pos = column no matter what, which rewinds the buffer instead of advancing it, and the score field lands on top of the room name.

Fix: displays narrower than 72 columns use short labels and field positions of screen_cols - 14 and screen_cols - 8, which leaves twenty-five columns for the room name.

Note

pad_line's rewinding was left in on purpose. It is what cuts an over-long room name short, and it is also what holds the status buffer to screen_cols + 1 bytes. Taking it out would cause an overflow.

6 The [MORE] Prompt Is Never Erased

Symptom: a stray [MORE] sits in the middle of the text.

JZIP prints the prompt, waits for a key, then counts on the story's next output to write over it. When that output happens to be a newline, the prompt survives and scrolls up into the story text. ZORK III shows this on its opening screen.

Fix: the cursor goes back to the start of the prompt and the line is erased once the key is pressed.

7 STOP Does Not Stop the Story

Symptom: pressing STOP or CTRL-C puts the game in a loop printing "Beg pardon?".

>
Beg pardon?

>
Beg pardon?

>
Beg pardon?
The loop, which ran until the power went off.

Ending the current read is not enough. input_line returned an empty line, the story's parser printed its complaint and asked again, and the next read returned nothing straight away, so the game sat in that loop.

What actually ends a story is interpreter_state, which interpret() tests at the top of every instruction. Nothing was setting it.

There was a second fault behind the first. ZmHostPollKey() returns 0 both for "no key waiting" and for "STOP pressed", so the timed reads could not see a stop request at all and simply waited out their timeout.

Fix: ZmHostQuitRequested() was added to the host seam, and all six places that wait for a key now set interpreter_state to STOP. The interpreter leaves its loop as soon as the current opcode finishes, before the parser ever sees the empty line.

Note

This was found on the machine, not by the test harness, which had been quietly rescuing itself. When a script ran out the harness typed quit for the story, so the abandon path was never taken. <STOP> in a script now stands in for the key and is terminal, as it is on the machine.

A Fatal Error Stops the Machine

Not a JZIP fault, but an A2Z one, and worth fixing. Its fatal() prints to the serial port and then loops forever flashing an LED. On Daisy that would mean a fault in a story file makes the computer unusable until you switch it off.

Fix: fatal() now unwinds to ZmRunStory() using setjmp and longjmp. A bad story puts the user back at the BASIC prompt with a message. A test that feeds the interpreter four kilobytes of random data checks this.

The Display

The screen driver, zm_vt52.cpp, implements JZIP's screen interface by sending VT52 escape sequences and nothing else. It mentions DaisyVideo nowhere, nor shadow RAM, nor any other part of DaisyOS.

That has two results. On the machine, the byte stream goes to the VT52 engine described below, which draws it. Under test, the same byte stream goes to a simulated screen that a test program can read back.

The driver sticks to sequences the DaisyOS terminal implements:

SequenceEffect
ESC Y r cPosition cursor. Row and column each biased by 32.
ESC JErase from cursor to end of screen.
ESC KErase from cursor to end of line.
ESC [ 2 JClear the whole screen.
ESC [ t ; b rSet scrolling region to rows t through b, 1-based.
ESC [ 7 mReverse video on.
ESC [ 0 mReverse video off.
Note

JZIP counts rows and columns from 1. VT52 counts from 0 and adds 32. Every coordinate is converted on the way out.

The scrolling region is what holds the status line still while the story text scrolls underneath it. It is reset whenever the story changes the window split.

src/daisyos/vt52.cpp draws a VT52 stream into the DaisyVideo shadow RAM. It was taken out of the display half of terminal.cpp so that anything producing terminal output can share it. It does not talk to a host. There is no serial port in it, no status bar and no menu. A caller that needs those keeps them and feeds bytes in through Vt52Write().

Printable characters are collected into a run buffer and written with a single PutStringAt and one attribute message. One video message per character is too slow to keep up with a program printing a full screen of text.

DaisyOS System Calls

The interpreter reaches DaisyOS through eleven functions and no others. They are declared in include/zmachine/zm_internal.h and written in src/zmachine/zm_host_daisy.cpp. The test harness supplies its own versions of the same eleven.

FunctionPurpose
ZmHostPutByte(c)One byte of terminal output.
ZmHostFlush()Push batched output to the display.
ZmHostGetKey()Wait for a keystroke. Returns 0 to drop the story.
ZmHostPollKey()Return a keystroke if one is waiting, else 0.
ZmHostQuitRequested()True once STOP has been pressed.
ZmHostIdle()Yield. Called once per instruction.
ZmHostFatal(s)Report an error there is no recovering from.
ZmHostSaveOpen()Open a saved game. Always fails.
ZmHostSaveRead/Write/CloseTransfer a saved game.

Here is every DaisyOS entry point the module uses. From src/daisyos/vt52.cpp, for the display:

CallHeaderPurpose
Clrscrshadow_ram.hFill the screen with a character.
PlotCharshadow_ram.hWrite one character cell.
FillCellsshadow_ram.hFill a run of cells.
PutStringAtshadow_ram.hWrite a run of characters.
VideoMsgSendPutAttribsAtCellvideo_messages.hSet inverse video.
VideoMsgSendMoveBlockvideo_messages.hScroll by block move.
AudioMsgSendToneOnaudio_messages.hSound the bell on BEL.
millisArduinoCursor blink timing.

From src/zmachine/zm_host_daisy.cpp, for the keyboard:

CallHeaderPurpose
BufferGetbuffer.hTake a key from the ring. Returns 0 when empty.
STOP_KEYkeyboard.hDrop the story.
CTRL_C_INTERNALkeyboard.hThe same.
Note

Timer TC3 scans the keyboard matrix on an interrupt and fills a lock-free ring buffer on its own. The interpreter can therefore sit in ZmHostGetKey() without losing keystrokes. It must still call Vt52Tick() while it waits, or the cursor stops blinking and the machine looks dead.

ZmHostPollKey() returns 0 both when no key is waiting and when STOP has been pressed, so a caller polling in a loop must ask ZmHostQuitRequested() to tell the two apart. Ending the read is not on its own enough to stop a story. See correction 7.

Two changes were made outside the module. Both are small, and both follow the pattern already set by INVADERS, CONWAY and TERM. In basic_internal.h, an include. In basic_execute.cpp, one arm of the immediate-command dispatcher:

if ((rest = MatchCommand(line, "zork")) != NULL) {
  RunZMachine();
  Newline();
  if (showReady) {
    PrintReady();
  }
  return true;
}

RunZMachine() takes over the display, offers the built-in stories if there is more than one, plays the one you pick, and puts the display back before it returns.

Saving and Restoring

Saving and restoring are not available. SAVE and RESTORE reply that the file could not be opened, and play carries on.

The reason is that Daisy has no writable storage of its own. The only writable medium is DaisyFile, reached over the WiFi modem.

Writing would work. CommMsgSendFprint carries up to 255 bytes in a frame, and a saved game is 13,907 bytes, or fifty-five frames.

Reading would not. CommMsgSendFget moves one byte per round trip, each one a request and an acknowledgement, at 38,400 baud. Reading 13,907 bytes that way would take several minutes.

To switch the feature on, add a block read to the DaisyFile protocol. Only the four ZmHostSave* functions need to change. The code above them is finished and is tested by the harness, which uses ordinary files.

Adding a Story

Story files are not supplied. To install one:

tools/mkstory.py ZORK1.DAT > src/zmachine/zm_stories.cpp
pio run -t upload

The tool says what it has done:

ZORK1          v3   84992 bytes flash, 11859 bytes RAM when running

You can give it several stories at once. Mind the flash budget. Each one takes 80 to 90 kilobytes of the Due's 512, of which DaisyOS itself uses about 160.

The Test Harness

The interpreter can be run without hardware. tools/zmtest/ compiles the same source files natively and runs them against a simulated 40 by 25 VT52 screen.

tools/zmtest/build.sh
tools/zmtest/run-tests.sh /path/to/stories
tools/zmtest/zmtest ZORK1.DAT

The simulated screen implements exactly the sequences daisyos/vt52.cpp implements and no others. So a sequence it cannot draw is one the real terminal would not have drawn either.

The suite makes thirty-one checks, each on what actually reaches the screen rather than on the interpreter's internal state. Runs are time-limited, because a regression in the abandon path shows up as a hang rather than as wrong output.

boot and parser5
movement and world model3
objects3
status line fits 40 columns3
save and restore5
STOP abandons the story5
restart2
[MORE] paging2
other stories2
corrupt story rejected1
Total31

Story File Header Fields

Offsets are from the start of the story file. All values are words, high byte first, unless noted.

OffsetJZIP nameContents
$00h_typeZ-machine version, 1 to 8. One byte.
$01h_configConfiguration flags. One byte.
$02h_versionRelease number.
$04h_data_sizeBase of high memory. See the warning above.
$06h_start_pcInitial program counter.
$08h_words_offsetDictionary.
$0Ah_objects_offsetObject table.
$0Ch_globals_offsetGlobal variables.
$0Eh_restart_sizeBase of static memory. See the warning above.
$10h_flagsInterpreter capability flags.
$18h_synonyms_offsetAbbreviations table.
$1Ah_file_sizeFile length, divided by the version scaler.
$1Ch_checksumSum of all bytes from $40 to the end.

Memory Map, Zork I

ItemDecimalHexRegion
Abbreviations table496$01F0dynamic
Object table688$02B0dynamic
Global variables8,817$2271dynamic
Base of static memory ($0E)11,859$2E53
Dictionary15,137$3B21static
Base of high memory ($04)20,023$4E37
Initial program counter ($06)20,229$4F05high
Story file length84,992$14C00
In RAM while playing11,859 bytes
Read from flash in place73,133 bytes

Limitations

Saving and restoring are not available. See Saving and Restoring, above.
terminal.cpp still holds its own copy of the VT52 parser. The shared engine in daisyos/vt52.cpp was taken out of it and is what the Z-machine uses, but the TERM command has not been moved over to it. Doing so would get rid of the duplication and is the obvious next job. It was left alone here because it means changing working code, which needs testing on real hardware.
Accented characters are dropped. The Z-machine's extended character set has the accented letters of Western European languages. The DaisyVideo character generator has no shapes for them.
Version 6 is not supported. Its graphics and sound have nothing to match them on this machine. Versions 1 through 5 and 7 through 8 should run. Only version 3 has been tested, that being the ZORK trilogy.
The display is forty columns. Infocom set their text for eighty. Prose reads perfectly well at forty, and the interpreter re-wraps it, but tables and diagrams inside a story, such as the maze plans in ZORK III, were laid out for a wider screen.
West of House

You are standing in an open field west of a white house, with a boarded front door. There is a small mailbox here.

The source, all three firmware images and the Python file server, can be found on GitHub.