- op_save_v5: generates Quetzal save, stores 1 on success / 0 on failure
- op_restore_v5: loads Quetzal save, stores 2 ("restored") via store byte
- _try_restore: detect V5+ in-game saves (0xBE 0x00 before PC) and process
the store byte with result 2, matching the V3 branch-on-restore pattern
- _find_story() now compares path.stem.lower() so "lostpig" matches "LostPig.z8"
- Server no longer writes its own prompt in IF mode (game handles prompting)
- Suppress phantom game output on restore (saved PC past sread causes garbage)
- Route .z5/.z8 files to EmbeddedIFSession now that V5+ is supported
Wizard Sniffer is Glulx (.gblorb), not z-machine — out of scope.
Lost Pig is V8, not V5 as originally assumed. Added milestone
section documenting the V8 support work, bugs found and fixed,
and new opcode implementations. Updated game descriptions and
version notes. Added trace_lostpig.py utility script.
Implements the opcode set needed for Lost Pig (V8):
- op_aread: V5+ input with text at byte 2, char count at byte 1,
stores terminating character
- op_save_undo/op_restore_undo: stub returning -1/0 (undo not yet
available, game continues without it)
- op_log_shift/op_art_shift: logical and arithmetic bit shifts
- op_scan_table: table search with configurable entry size and
word/byte comparison
- op_tokenize: re-tokenize text buffer against dictionary
- op_copy_table: memory copy/zero with forward/backward support
- op_set_font: returns 1 for font 1, 0 for others
- op_print_unicode/op_check_unicode: basic Unicode output support
Lost Pig now runs to completion: 101K steps, 61 unique opcodes,
3 full input cycles with room descriptions rendering correctly.
New opcodes: op_call_vn, op_call_vn2, op_call_vs2, op_catch,
op_check_arg_count. All call variants delegate to existing _call().
ZRoutine now tracks arg_count for check_arg_count.
Fixed zopdecoder double-byte operand parsing for call_vs2/call_vn2:
the old code called _parse_operands_byte() twice, but this method
reads both type byte AND operands together. The second call would
read operand data as a type byte. Refactored into _read_type_byte()
+ _parse_operand_list() so both type bytes are read before any
operand data.
Also fixed the double-byte detection: was checking opcode[0:7] (7
bits = 0x7A for call_vn2) instead of opcode_num (5 bits = 0x1A).
The check never matched, so double-byte opcodes were always
mis-parsed.
The old code inserted the object into the new parent first, then
tried to remove it from the old parent. This corrupted the sibling
chain because the object's sibling pointer was already modified.
The Z-spec says "if O already has a parent, it is first removed."
Now delegates to remove_object() before inserting.
The 0xBE prefix byte triggers extended opcode parsing. Reads the
opcode number from the next byte, then parses operand types using
the same format as VAR opcodes. Required for all V5+ games.
V8 uses the same format as V5 (object model, opcodes, stack) with
two differences: packed address scaling (×8 instead of ×4) and max
file size (512KB instead of 256KB).
zmemory: add V8 size validation and packed_address case
zobjectparser: accept version 8 alongside 4-5 in all checks
zstackmanager: allow V8 stack initialization
V6-7 remain unsupported (different packed address format with offsets).
quetzal.py: typo in _parse_umem() — cmem.dynamic_start should be
cmem._dynamic_start. Would crash on uncompressed memory restore.
zcpu.py: op_erase_window had if/if/else instead of if/elif/else.
When window_number was -1, the second if fell through to else,
calling erase_window(-1) after the correct reset path.
The Quetzal spec stores the PC pointing at the save instruction's
branch data. On restore, this branch must be processed as "save
succeeded" to advance the PC to the actual next instruction. Without
this, the branch bytes were decoded as an opcode, corrupting execution.
Detect the save opcode (0xB5) immediately before the restored PC to
distinguish in-game saves from out-of-band saves (which don't need
branch processing). Also improve error diagnostics: pop_stack now
raises ZStackPopError with frame context, and the instruction trace
dumps on all exceptions.
return_pc for each frame belongs on the caller's program_counter (the
resume address when this routine exits). varnum is the store variable
that receives the return value, stored as return_addr on the frame
itself. Also handle flags bit 4 (discard result → return_addr=None).
The Quetzal save format only stores the number of local vars the
routine declared, but the runtime indexes local_vars[0..14] for any
variable access. Restored routines had short lists, causing IndexError
on the first command after restore.
Offline diagnostic tool that loads a story + save file and shows
PC, stack frames, player location, objects, and can disassemble
at arbitrary addresses.
QuetzalParser._parse_stks() creates a new ZStackManager and sets it on
zmachine._stackmanager, but ZCpu._stackmanager and ZOpDecoder._stack
still pointed to the old empty stack. After restore, all stack ops
(local var reads, routine returns, stack pops) used the wrong stack,
causing the interpreter to crash on the first command.
Replaces the async _do_restore() (called after thread launch) with a
synchronous _try_restore() called before the thread starts. This
eliminates the race condition where restore mutates z-machine state
while the interpreter thread is running.
The restore prefix message is now part of start()'s return value
instead of being sent separately in play.py.
Unit tests for MUD UI components (screen, input stream, filesystem)
and integration tests with real zork1.z3 (session lifecycle, escape
commands, save/restore round-trip, state inspection).
EmbeddedIFSession runs the hybrid interpreter in a daemon thread,
bridged to the async MUD loop via threading.Event synchronization.
.z3 files use the embedded path; other formats fall back to dfrotz.
- MUD ZUI components: MudScreen (buffered output), MudInputStream
(thread-safe input), MudFilesystem (quetzal saves), NullAudio
- save/restore via QuetzalWriter/QuetzalParser and :: escape commands
- state inspection: get_location_name(), get_room_objects()
- error reporting for interpreter crashes
- fix quetzal parser bit slice bug: _parse_stks used [0:3] (3 bits,
max 7 locals) instead of [0:4] (4 bits, max 15) — Zork uses 15
Save/restore is now fully implemented in the hybrid interpreter. Updated
open question 7 to reflect completion, marked the what-to-do-next item as
done, and updated the milestone section to include save/restore in the
"what works" list.
Also noted the QuetzalParser off-by-one bug fix (return_pc parsing).
Verifies complete save/restore pipeline by generating save data with
QuetzalWriter and restoring it with QuetzalParser. Tests cover:
- Basic round-trip with memory, stack, and PC restoration
- Multiple nested call frames
- Preservation of unchanged memory bytes (run-length encoding)
- Empty stack (no routine frames)
Each test confirms that state modified after save is correctly
restored to original values from the save data.
Implement V3 restore opcode:
- Add QuetzalParser.load_from_bytes() to parse save data from memory
- Wire op_restore to call filesystem.restore_game() and parse result
- Validate IFhd matches current story (release/serial/checksum)
- Restore dynamic memory, call stack, and program counter
- Branch true on success, false on failure/cancellation
Fix IFF chunk padding bug:
- Add padding byte to odd-length chunks in QuetzalWriter
- Ensures proper chunk alignment for parser compatibility
Add comprehensive tests:
- Branch false when filesystem returns None
- Branch false without zmachine reference
- Branch true on successful restore
- Verify memory state matches saved values
- Handle malformed save data gracefully
Implement full save functionality for V3 z-machine:
- Fixed QuetzalWriter._generate_anno_chunk() to return bytes
- Added QuetzalWriter.generate_save_data() to produce IFF container
- Updated QuetzalWriter.write() to use new method and binary mode
- Added zmachine reference to ZCpu for QuetzalWriter access
- Added _program_counter property to ZCpu for Quetzal access
- Implemented op_save to call QuetzalWriter and filesystem
- Updated tests for op_save (success, failure, IFF validation)
- Added filesystem mock to MockUI for testing
- Added _call_stack to MockStackManager for QuetzalWriter
All tests pass. Save now generates valid IFF/FORM/IFZS data with
IFhd, CMem, Stks, and ANNO chunks.
The IFhd chunk contains 13 bytes of metadata identifying the story
and current execution state:
- Release number (2 bytes) from header
- Serial number (6 bytes) from header
- Checksum (2 bytes) from header
- Program counter (3 bytes) from CPU state
This allows save files to be validated against the story file.
op_save is a stub that always fails. QuetzalWriter chunks are stubs.
Added as open question 7, next step item, and corrected the inaccurate
claim that save/restore works.
The input detection handled both chr(8) and chr(127) but the echo
logic only checked chr(8). Most modern terminals send chr(127) for
backspace, so the cursor never moved back visually.
Property parsing had 4 classes of bug, all producing wrong addresses
for object property data:
1. Off-by-one in shortname skip: addr += 2*len missed the length byte
itself, causing property table scan to start 1 byte too early.
Affected get_prop_addr_len and get_next_property.
2. V3 property number slices bf[4:0] extracted 4 bits not 5.
Property numbers 16-31 were read as 0-15.
3. V3 property size slices bf[7:5] extracted 2 bits not 3.
Properties larger than 4 bytes got wrong sizes.
4. V5 property number/size slices bf[5:0] extracted 5 bits not 6.
Also fixed get_property_length reading size from the wrong byte in
V5 two-byte headers (was reading first byte which has property
number, instead of second byte which has size).
Root cause for all slice bugs: BitField uses Python [start:stop)
semantics but code was written as [high:low] inclusive notation.
Same class of bug as the write_global fix in commit e5329d6.
Adds comprehensive test coverage for three newly implemented Z-machine opcodes:
- op_test: Tests bitmap flag checking with all flags set, some missing, zero
flags, and identical values
- op_verify: Tests checksum verification with matching and mismatched checksums
- op_get_child: Tests getting first child of object with and without children
Also extends MockMemory with generate_checksum() and MockObjectParser with
get_child() to support the new tests.
Wrap opcode execution with try-except to catch all ZCpuError subclasses except
ZCpuQuit and ZCpuRestart, which are normal control flow. This ensures we get
trace dumps for divide by zero, not implemented, and other errors.
The hybrid interpreter can now run Zork 1, marking the first working
implementation of the embedded interpreter path. This enables levels 2-5
(inspectable/moldable/shared worlds) rather than just the opaque terminal
approach of level 1.
Spec fixes: implement op_test (bitwise AND branch), add missing branch
to op_get_child, handle call-to-address-0 as no-op in op_call/_call.
I/O fixes: correct keyboard API (keyboard_input.read_line), non-TTY
fallbacks in trivialzui, stdout flush for immediate output. Graceful
handling of unmapped ZSCII characters. Add instruction trace buffer
for debugging.
The V3 A2 table needs newline (ZSCII 13) at code 7, matching viola's
implementation. The previous fix only removed the erroneous '<' but
didn't add the newline, leaving the table at 24 chars instead of 25.