Z-Machine Foundations and Storage Sync
Z-Machine decoder work, text handling, and SAVE/RESTORE, plus content-addressable storage with S3 sync.
The Z-Machine decoder kept getting operand boundaries wrong, which meant the PC would walk into the middle of an instruction and everything after that was noise. Most of the day went into making the decode path agree with what real story files contain rather than with my first reading of the spec.
The core decode path is short and carries a lot. It classifies the opcode, decodes operands, then checks for store and branch metadata:
pub fn decode_instruction(memory: &[u8], pc: usize) -> Result<Instruction, String> {
if pc >= memory.len() {
return Err("PC out of bounds".to_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) {
if offset < memory.len() {
let var = memory[offset];
offset += 1;
Some(var)
} else {
None
}
} 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()
})
}
There was no single big bug today, just a couple of dozen small ones where I had skimmed a sentence in the spec: branch offset calculation, undefined local handling, operand type encoding in the push instruction. Each fix was two or three lines, and each one moved the failure further into the story file.
With the decoder stable, the rest can hang off it: Z-string decoding, call frames, and the early SAVE/RESTORE shape. Stack and call frames went in today (Phase 4 in my tracker), so the interpreter can push and pop execution contexts. That’s still short of running a real game, but it is enough to step through the opening turns of Zork without the interpreter losing its place.
I also folded in a shared CPU state format so save and load stop being bespoke per core. The contract is blunt: registers, memory, program counter, and enough metadata to rehydrate the machine without guessing.
#[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,
}
The serial stack needed a similar correction. CTS had been advisory in my implementation; it is backpressure. The connection manager now queues output when CTS drops and drains it when CTS returns, which keeps the modem boundary honest instead of mostly fine.
private handleCtsChange(ctsValue: boolean): void {
const wasReady = this.ctsReady;
this.ctsReady = ctsValue;
if (ctsValue && !wasReady && this.outputQueue.length > 0) {
this.log(`[ConnectionManager] CTS high, draining ${this.outputQueue.length} queued chunks`);
this.drainOutputQueue();
} else if (!ctsValue && wasReady) {
this.log('[ConnectionManager] CTS low, queueing subsequent output');
}
}
On the storage side, the cloud layer is now content-addressable with SHA-256, and larger blobs live in S3 under hash-based keys. Deduplication comes for free rather than as a later addition. The frontend adapter keeps a local cache, queues writes while offline, and syncs by comparing hashes when the socket is available. The content hash is the file’s identity; everything else is metadata.
REXX and Scheme progressed in parallel: lexer, parser, and WASM integration for REXX, R5RS compliance work for Scheme. Forth-C also progressed, mostly to keep proving that a non-Rust core is a first-class path and not a special case.
Tomorrow the web app moves to web/, which will force every path alias and import to be explicit. The parser migrations to Pest/Pratt are queued after that. The decoder work isn’t finished — extended opcodes, object manipulation, and the full text system are all still ahead — but today’s fixes mean I can step through a story file without the PC drifting into garbage.
Previous: 2026-01-25