Skip to main content
Back to articles
2026 / 01
| 6 min read

Deep Dive: The Z-Machine Interpreter

Building a Z-Machine transpiler in Rust: opcode decoding, Z-strings, branch offsets, and the constraints that make Infocom's bytecode tick.

emulator z-machine infocom rust interpreters
On this page

The bug that cost me three hours was two bytes. Every backward jump landed two bytes past where it should have, and I only saw the pattern once I logged the PC before and after each branch. The Z-Machine Standards Document is explicit about it — section 4.7.2 says a branch goes to “Address after branch data + Offset - 2” — and I had been reading the offset as relative to the opcode, with no - 2 anywhere.

What I built here is a transpiler in Rust that converts Z-Machine bytecode into ZIL AST, then runs that AST through an existing ZIL interpreter. Most Z-Machine implementations interpret the bytecode directly. Going through ZIL costs performance and buys visibility: I can dump the generated ZIL and read what a story file is doing, instruction by instruction.

Opcode Shapes: The Decoder Is the Choke Point

Z-Machine instruction decoding is awkward not because there are many opcodes, but because the opcode shape is packed into the same byte as the opcode number. The first byte decides whether this is OP0, OP1, OP2, or VAR, and that decides how many operands follow and how to read them.

The rule set:

  • 0xxxxxxx → long form (OP2), operand types encoded in bits 5-6
  • 10ttnnnn → short form (OP1 or OP0 depending on operand type bits)
  • 11xxxxxx → variable form (VAR), types byte follows
  • 0xBE → extended (V5+ only)

In Rust, this becomes a pure function that I can test in isolation:

fn decode_opcode_type(byte: u8) -> (OpcodeType, u8) {
    if byte == 0xBE {
        (OpcodeType::EXT, byte)
    } else if byte & 0xC0 == 0xC0 {
        // VAR opcode: 11xxxxxx
        (OpcodeType::VAR, byte & 0x1F)
    } else if byte & 0xC0 == 0x80 {
        // Short form: 10xxxxxx
        let op_type = (byte >> 4) & 0x03;
        if op_type == 0x03 {
            (OpcodeType::OP0, byte & 0x0F)
        } else {
            (OpcodeType::OP1, byte & 0x0F)
        }
    } else {
        // Long form: 0xxxxxxx
        (OpcodeType::OP2, byte & 0x1F)
    }
}

I keep this function pure because everything downstream depends on it. If je and jl swap places because of a mask error, nothing crashes; the game just quietly plays wrong. Silent failure is the reason this piece gets tested more than it looks like it deserves.

Z-Strings: Three Characters per Word

Infocom’s text encoding packs three 5-bit Z-characters into a 16-bit word, and the high bit of the word marks the last one. That single bit is the terminator, so a decoder has to read words in sequence, unpack Z-characters, and stop exactly when the bit appears.

The alphabets are small and the shifts are transient:

  • A0 (default): lowercase a–z
  • A1 (shift 4): uppercase A–Z
  • A2 (shift 5): punctuation, digits, special characters

Abbreviations add recursion: a Z-character in the 1–3 range triggers a lookup in a separate table, and the result decodes as another Z-string.

The failure mode is subtle. If you try to skip a Z-string without decoding it, to find the next instruction boundary say, you will get the length wrong: the terminator bit only means anything once you’ve read each word in order. My disassembler misaligned after every inline PRINT instruction until I stopped trying to be clever about it.

Branch Offsets and the Off-by-Two Trap

Branch operands are 1–2 bytes. Bit 7 says whether to branch on true or false; bit 6 set means a one-byte branch with a 6-bit unsigned offset, and bit 6 clear means a two-byte branch with a signed 14-bit offset. The target is the address after the branch data, plus the offset, minus two.

The sign extension is the other trap:

// Two-byte offset: 14 bits, signed
let unsigned = (((branch_byte & 0x3F) as u16) << 8) | (low_byte as u16);
let signed = if unsigned & 0x2000 != 0 {
    (unsigned as i16) | !0x3FFF  // Sign-extend from 14 bits
} else {
    unsigned as i16
};

Two offsets are reserved: 0 means “return false” and 1 means “return true.” Those aren’t jumps at all. They’re early returns compressed into the branch encoding, which is the kind of trick you play when every byte of a story file is expensive.

Writing these rules out as named cases in the code is what made the off-by-two bugs stop.

Variables: Three Storage Classes

The storage model is compact and fixed:

VariableStorage
0Evaluation stack (push/pop)
1–15Locals (per-routine, up to 15)
16–255Globals (240 words at a fixed address)

The interpreter has to know which class a variable belongs to at decode time so it can emit the right ZIL symbol or stack operation. In the transpiler, the stack becomes STACK, locals get a period prefix (.L1), and globals get a comma prefix (,G0).

Get this wrong and variables leak between routines or globals overwrite locals, and the only symptom is a game that feels buggy without ever failing.

Save/Restore as a Text Protocol

The Z-Machine spec expects save and restore to be portable across platforms, and I did not want file handling inside the core interpreter. So save is a text protocol: the interpreter serializes state to JSON, writes it into the output buffer between ##SAVE_STATE## and ##END_SAVE## markers, and the web worker watches for those markers and performs the actual file operation.

"SAVE" => {
    match self.serialize_state() {
        Ok(json) => {
            self.output_buffer
                .push_str(&format!("##SAVE_STATE##{}##END_SAVE##\r\n", json));
            Ok(EvalResult::Value(Value::Bool(true)))
        }
        Err(e) => {
            self.output_buffer
                .push_str(&format!("?SAVE ERROR: {}\r\n", e));
            Ok(EvalResult::Value(Value::Bool(false)))
        }
    }
}

Escaping data through the output stream is not elegant, and it keeps the interpreter free of filesystem access while the UI keeps control of storage. The whole contract is a predictable marker string. Restore works the same way through ##RESTORE_REQUEST##, with the worker handling the file picker and injecting the state back.

Versions and Constraints

Eight Z-Machine versions exist. This transpiler targets V3, the version most Infocom titles shipped on, with the decoder structured so V5+ can be added later. The constraints matter more than the version numbers:

  • Story files are capped at 128K for V1–V3, 256K for V4–V5, and 512K for V6–V8
  • File length in the header is scaled by version: multiply by 2, 4, or 8
  • Packed addresses compress routine pointers, with a version-dependent multiplier
  • The instruction set is tuned for text: a small core, dense encodings, an assumption of very little memory

Once those are in your head, the rest is bookkeeping. The packed addresses, the 5-bit text encoding, the 14-bit branch offsets: none of them are strange decisions if you remember these games had to fit on a floppy and run in 48K.


Most of my bugs came from reading the spec in a hurry and assuming I already knew what a word like “after” meant. The Z-Machine is unusually well documented for a forty-year-old proprietary format, and nearly every hour I lost was an hour I could have skipped by reading one more paragraph.

The transpiler is slower than interpreting bytecode directly. That was the trade I wanted: when a story file misbehaves, I get to read the ZIL instead of guessing at the bytes.

Related files:

  • languages/zil/zil-wasm/src/zmachine/opcodes.rs — opcode decoder
  • languages/zil/zil-wasm/src/zmachine/text.rs — Z-string encoding/decoding
  • languages/zil/zil-wasm/src/zmachine/transpiler.rs — bytecode to ZIL AST
  • languages/zil/zil-wasm/src/zmachine/story.rs — story file loader and header parsing
  • languages/zil/zil-wasm/src/zmachine/symbols.rs — symbol table generation