Z-Machine Foundations and Storage Sync
Pushing the Z-Machine interpreter forward with decoder work, text handling, and SAVE/RESTORE—plus content-addressable storage with S3 sync.
Z-Machine foundations and storage sync
This was a heavy day for “real interpreter” work. The Z-Machine moved forward in multiple phases: decoder improvements, Z-string text handling, stack and call frames, and the early shape of SAVE/RESTORE. It’s hard to overstate how much Z-Machine behaviour lives in tiny details. Every edge case matters.
The Z-Machine spec is a trap
The specification looks simple. It’s elegantly organized, clearly written, and covers everything you need to know. What it doesn’t tell you is that half the games in the wild rely on behaviours that are technically undefined, and the other half test edge cases that only matter on specific versions.
I spent most of my Z-Machine time on the decoder. Getting the instruction decoding right is the foundation—if you misparse an opcode, nothing else can work:
pub fn decode_instruction(memory: &[u8], pc: usize) -> Result<Instruction, String> {
let opcode_byte = memory[pc];
let mut offset = pc + 1;
let (opcode_type, opcode_num) = decode_opcode_type(opcode_byte);
let operands = match opcode_type {
OpcodeType::OP0 => vec![],
OpcodeType::OP1 => decode_operands_1op(memory, &mut offset, opcode_byte)?,
OpcodeType::OP2 => decode_operands_2op(memory, &mut offset, opcode_byte)?,
OpcodeType::VAR => decode_operands_var(memory, &mut offset)?,
OpcodeType::EXT => return Err("Extended opcodes not supported".to_string()),
};
let store_var = if opcode_stores_result(opcode_type, opcode_num) {
let var = memory[offset];
offset += 1;
Some(var)
} else {
None
};
let branch = if opcode_has_branch(opcode_type, opcode_num) {
decode_branch(memory, &mut offset)?
} else {
BranchInfo::default()
};
Ok(Instruction {
opcode: opcode_num,
opcode_type,
operands,
store_var,
branch,
length: offset - pc,
..Default::default()
})
}
It looks compact, but every branch and operand type maps to real game behaviour. That’s where Z-Machine bugs hide. (For the full story, see the Z-Machine deep dive.)
REXX and Scheme too
I also added a REXX interpreter and expanded Scheme support. Different languages with different expectations, but they all live on the same platform. The goal is to make adding a new language feel repeatable rather than heroic.
Forth-C took a step forward too—I’m experimenting with a Forth that compiles to C, which has a cleaner build story for cases where you want native performance without the WASM layer.
Content-addressable storage
On the infrastructure side, I implemented content-addressable storage with S3 sync. The emulator isn’t just a local toy anymore—if you’re going to store game saves and user files, it needs a real story.
Content-addressable means files are stored by their hash. Upload the same file twice, you get the same blob. This deduplicates naturally and makes sync straightforward: if you have the hash, you have the file.
The S3 sync means local changes propagate to the cloud and vice versa. Your dungeon save travels with you. That’s the day the project started to look “server-grade.”
Flow control across the modem boundary
One of the fixes I made was getting proper CTS-based backpressure working. In a dial-up world, the modem can’t always accept bytes at full speed. When CTS drops, you queue; when CTS rises, you drain:
private handleCtsChange(ctsValue: boolean): void {
const wasReady = this.ctsReady;
this.ctsReady = ctsValue;
if (ctsValue && !wasReady && this.outputQueue.length > 0) {
this.drainOutputQueue();
} else if (!ctsValue && wasReady) {
this.log('[ConnectionManager] CTS low, queueing subsequent output');
}
}
It’s not glamorous, but it’s the difference between a modem that feels real and one that occasionally drops characters for no visible reason. (The full story is in CTS Flow Control.)
A shared CPU state format
One piece that quietly simplifies everything is a shared save/load format across CPU cores:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CpuState {
pub architecture: String,
pub version: u32,
pub registers: Vec<u8>,
pub memory: Vec<u8>,
pub pc: u32,
pub sp: u32,
pub flags: u32,
pub halted: bool,
pub cycles: u64,
#[serde(default)]
pub metadata: serde_json::Value,
}
Once this exists, “save state” isn’t a one-off per emulator. It’s a contract. The Z80 and 6502 and 8088 all serialize the same way, and the frontend can treat them uniformly.
The changes
- Advanced Z-Machine support: decoder, text system, call frames, SAVE/RESTORE
- Added REXX interpreter and expanded Scheme support
- Built Forth-C in phases with tests and tooling
- Implemented content-addressable storage with S3 sync
- Refactored modem layers and audio routing
What I was going for
I wanted to move beyond “toy” interpreters into something that could hold real software and real state, with storage that could scale.
What went sideways
Timing bugs in CTS/BUSY handling needed quick fixes. The 8088 core needed another pass on opcode flags—small errors that only show up when you run real programs. The usual “working with real data” problems.
What’s next
This was the foundation week. Next step was making it all play nicely together.
Previous: 2026-01-25