Compare commits

..

36 commits

Author SHA1 Message Date
8097bbcf55
Document save/restore as open gap in hybrid interpreter
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.
2026-02-09 23:13:47 -05:00
ec4e53b2d4
Fix backspace echo for terminals that send chr(127)
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.
2026-02-09 23:11:43 -05:00
1b08c36b85
Update investigation and journey docs with session 3 findings 2026-02-09 23:06:43 -05:00
bbd70e8577
Truncate words to dictionary resolution before lookup 2026-02-09 23:04:56 -05:00
6e62ca203b
Fix return value storage in finish_routine 2026-02-09 23:04:54 -05:00
fa31f39a65
Fix signed comparison in op_jl 2026-02-09 23:04:52 -05:00
f8f5ac7ad0
Add z-machine garbled output investigation doc and debug script 2026-02-09 22:54:32 -05:00
127ca5f56e
Fix BitField slice and off-by-one bugs in zobjectparser.py
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.
2026-02-09 22:54:32 -05:00
5ffbe660fb
Flush stdout after character echo in readline 2026-02-09 22:03:06 -05:00
2ce82e7d87
Add unit tests for op_test, op_verify, and op_get_child
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.
2026-02-09 21:52:08 -05:00
2cf303dd67
Add --debug flag to Zork 1 smoke test 2026-02-09 21:47:39 -05:00
48727c4690
Log warning for undefined ZSCII characters 2026-02-09 21:47:20 -05:00
8ba1701c9f
Dump instruction trace for all error exceptions, not just illegal opcode
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.
2026-02-09 21:47:13 -05:00
0f5fada29e
Remove duplicate routine_address == 0 check from op_call
The _call() method already handles this case, so the check in op_call is redundant.
2026-02-09 21:46:59 -05:00
e6b63622fa
Document Zork 1 milestone in if-journey
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.
2026-02-09 21:46:53 -05:00
9a0bacd581
Document write_word permission model in zmemory 2026-02-09 21:46:52 -05:00
1bcb83961a
Guard against word-not-found in sread tokenization
If text.find(word_str, offset) returns -1, use the offset as a fallback.
2026-02-09 21:46:45 -05:00
28a097997c
Add explanatory comment to ZStackBottom sentinel 2026-02-09 21:46:40 -05:00
eeb65e0cc2
Remove debug print statements from zcpu opcode dispatch
The exception that follows provides the context, so these prints are redundant.
2026-02-09 21:46:34 -05:00
e5329d6788
Fix 7-bit truncation bug in write_global corrupting game state 2026-02-09 21:45:54 -05:00
311a67e80a
Fix bugs found running Zork 1 through hybrid interpreter
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.
2026-02-09 21:36:30 -05:00
8d749fbb7e
Add missing newline to A2 alphabet table for V3
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.
2026-02-09 21:17:04 -05:00
9116777603
Fix A2 alphabet table with extra character shifting symbols
DEFAULT_A2 had 26 chars instead of 25 due to an erroneous '<'. This
shifted all symbol mappings, causing periods to render as exclamation
marks and other garbled punctuation in Zork 1 output.
2026-02-09 21:11:12 -05:00
e1c6a92368
Fix Python 3 integer division in zmachine modules
Python 2 `/` did integer division, Python 3 returns float. Changed to
`//` in zobjectparser (attribute byte offset) and zmemory (address
bounds checks). Zork 1 hit this on the first test_attr opcode.
2026-02-09 21:07:16 -05:00
8eb2371ce1
Add stack and local_vars to ZStackBottom sentinel
ZStackBottom lacked attributes that normal stack operations need when
returning from a subroutine to the top level. Zork 1 hit this on the
first subroutine return during startup.
2026-02-09 21:05:27 -05:00
61765fa6ba
Allow game writes to header region in dynamic memory
write_word() was routing all header writes through game_set_header()
which enforced overly strict per-byte permissions. Zork 1 legitimately
writes to header address 2 on startup. Now uses the same dynamic/static
boundary check that viola does, matching the reference implementation.
2026-02-09 21:03:10 -05:00
e0e2e84dc2
Add smoke test script for running Zork 1 through hybrid interpreter
Loads zork1.z3, creates ZMachine with TrivialZUI, and runs
interactively with graceful error handling for unimplemented opcodes.
2026-02-09 20:59:07 -05:00
d218929f02
Add step() method to ZCpu for async MUD integration
Extracts the run loop body into step() which executes one instruction
and returns True/False for continue/halt. run() now delegates to step().
2026-02-09 20:59:03 -05:00
fb8cbf7219
Implement op_verify and wire ZLexer into sread for Zork 1
op_verify now performs actual checksum validation against the header
instead of raising NotImplemented. ZLexer is injected into ZCpu and
sread tokenizes input into the parse buffer per the V3 spec.
2026-02-09 20:57:15 -05:00
205e2716dd
Update if-journey.rst with hybrid interpreter progress 2026-02-09 20:44:22 -05:00
72dd047b7b
Port 5 complex opcodes to hybrid z-machine interpreter
Implement op_sread (text input), op_save/restore (file I/O stubs),
op_restart (exception-based reset), op_input_stream and op_sound_effect
(no-op stubs). Add ZCpuRestart exception. All implementations follow TDD
with comprehensive unit tests.
2026-02-09 20:44:22 -05:00
c76ee337d3
Port 4 medium opcodes to hybrid z-machine interpreter
Implements op_print_addr, op_print_num, op_ret, and op_show_status following
TDD approach with tests first. Each opcode now properly decodes/prints text,
handles signed numbers, returns from routines, or acts as a no-op as appropriate.
2026-02-09 20:44:22 -05:00
1b9d84f41a
Port 10 object tree opcodes to hybrid z-machine interpreter
Implemented 10 opcodes for object tree manipulation: get_sibling,
test_attr, set_attr, clear_attr, jin, remove_obj, print_obj,
get_prop_addr, get_next_prop, and get_prop_len.

Added 6 supporting methods to ZObjectParser: set_attribute,
clear_attribute, remove_object, get_property_data_address,
get_next_property, and get_property_length.

Fixed bug in insert_object where sibling chain walk never advanced
the prev pointer, potentially causing infinite loops.

Added 16 unit tests with MockObjectParser to verify CPU opcode
behavior. All tests passing.
2026-02-09 20:44:21 -05:00
5ea030a0ac
Re-copy fixed repos/zvm source into src/mudlib/zmachine
Copies the cleaned-up zvm source (ruff-compliant, ty-clean) back into
the zmachine module. Adds __init__.py with proper exports and updates
.gitignore for debug.log/disasm.log.
2026-02-09 20:44:21 -05:00
dcc952d4c5
Port 12 trivial opcodes to hybrid z-machine interpreter 2026-02-09 20:44:21 -05:00
677ddac89f
Add z-machine opcode tracing script 2026-02-09 18:51:52 -05:00
29 changed files with 7220 additions and 4 deletions

View file

@ -20,7 +20,10 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
- `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec
- `scripts/` - standalone tools (map renderer, etc)
- `build/` - generated output (gitignored)
- `repos/` - symlinked reference repos (telnetlib3, miniboa). gitignored, not our code
- `repos/` - symlinked reference repos, gitignored, not our code. includes:
- `repos/viola/` - DFillmore/viola z-machine interpreter (working, global state)
- `repos/zvm/` - sussman/zvm z-machine interpreter (clean architecture, half-built)
- `repos/telnetlib3/`, `repos/miniboa/` - telnet libraries
## Docs

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ data
.worktrees
.testmondata
*.z*
debug.log
disasm.log

View file

@ -194,7 +194,11 @@ Things we haven't figured out yet. Update this as questions get answered.
1. V3 opcode footprint
~~~~~~~~~~~~~~~~~~~~~~
How many of the ~62 missing zvm opcodes are actually exercised by V3 games? V3 uses a smaller subset. If we target V3 first, the hybrid might need 30 ported, not 62. Research: run a V3 game through viola with opcode tracing, collect the set.
How many of the ~62 missing zvm opcodes are actually exercised by V3 games? V3 uses a smaller subset. If we target V3 first, the hybrid might need 30 ported, not 62.
UPDATE: Opcode tracing (via ``scripts/trace_zmachine.py``) found Zork 1 uses 69 opcodes. zvm had 36 implemented. 33 were ported from viola. All 69 are now implemented in the hybrid interpreter (``src/mudlib/zmachine/``).
All V3 gaps have been resolved. sread tokenization works correctly. save/restore is not yet functional (see question 7).
2. zvm/viola memory layout compatibility
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -227,23 +231,59 @@ Infocom games are abandonware but not legally free. Modern IF games (Lost Pig, W
How hard is it to add words to a z-machine dictionary at runtime? The dictionary is in static memory. Adding words means expanding it, which means relocating it if there's no space. Is this practical?
7. Save/restore in the hybrid interpreter
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``op_save`` is a stub that always branches false (game prints "Failed."). The infrastructure is mostly there — ``TrivialFilesystem.save_game()`` prompts for a filename and writes to disk, ``QuetzalParser`` can read save files — but two pieces are missing:
- ``QuetzalWriter`` chunk generators (``ifhd``, ``cmem``, ``stks``) are all stubs returning ``"0"``
- ``op_save`` doesn't collect game state or call the filesystem
To make save work: implement ``QuetzalWriter`` (XOR-compress dynamic memory against original story, serialize stack frames into Quetzal format), then wire ``op_save`` to generate the bytes and call ``self._ui.filesystem.save_game(data)``. Restore should be simpler since ``QuetzalParser`` already works — just need to wire ``op_restore`` to call ``filesystem.restore_game()`` and apply the parsed state.
what to do next
---------------
Concrete next steps, roughly ordered. Update as items get done.
- [ ] trace V3 opcode usage: run zork through viola with opcode logging, get the actual set of opcodes a real game uses. this tells us how much porting work the hybrid path actually requires.
- [x] trace V3 opcode usage: run zork through viola with opcode logging, get the actual set of opcodes a real game uses. this tells us how much porting work the hybrid path actually requires. (done — found 69 opcodes, see ``scripts/trace_zmachine.py``)
- [ ] compare memory layouts: look at how viola and zvm represent z-machine memory, object tables, string tables. determine if opcode porting is mechanical translation or deeper adaptation.
- [ ] prototype the hybrid: pick 5-10 common opcodes, port them from viola to zvm's architecture. see how the pattern feels. if it's smooth, the hybrid is viable. if every opcode is a battle, reconsider.
- [x] prototype the hybrid: pick 5-10 common opcodes, port them from viola to zvm's architecture. see how the pattern feels. if it's smooth, the hybrid is viable. if every opcode is a battle, reconsider. (done — all 69 Zork 1 opcodes ported, hybrid interpreter lives in ``src/mudlib/zmachine/``)
- [x] build level 1 prototype: regardless of interpreter choice, implement the terminal object, IF mode, and subprocess dfrotz path. this proves the MUD-side architecture (mode stack, spectators, save/restore) independently of the interpreter question. (done — see ``docs/how/if-terminal.txt``)
- [ ] implement save/restore: finish ``QuetzalWriter`` chunk generators and wire ``op_save``/``op_restore`` to the filesystem layer. restore should be easier since ``QuetzalParser`` already works.
- [ ] study MojoZork's multiplayer model: read the MultiZork source for how it handles multiple players in one z-machine. document the pattern for our eventual level 4.
- [x] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available). (zork1.z3 bundled in content/stories/)
milestone — Zork 1 playable in hybrid interpreter
--------------------------------------------------
The hybrid interpreter (zvm architecture + ported viola opcodes) can now run Zork 1. This is the first working implementation targeting levels 2-5 — inspectable, moldable, and shared worlds. Level 1 (terminal mode) uses subprocess dfrotz; this is the embedded path.
What works:
- 69 V3 opcodes ported, all Zork 1-required opcodes implemented
- key implementations: ``op_test`` (conditional logic), ``op_verify`` (story file checksums), ``sread`` with ZLexer tokenization (parsing player input)
- ``step()`` method for async MUD integration — single instruction at a time, no blocking loop
- instruction trace deque (last 20 instructions) for debugging state errors
- smoke test: ``scripts/run_zork1.py`` runs the game headless, exercises core opcode paths
- parser and lexer: all Zork 1 commands work (look, open mailbox, read leaflet, inventory, take, drop, navigation)
- the interpreter is fully playable for Zork 1 (save/restore not yet wired — see open question 7)
What this enables:
- Read z-machine state (object tree, variables) from MUD code (level 2)
- Write z-machine state, inject items, modify world (level 3)
- Multiplayer instances (level 4, following MojoZork patterns)
- Entity bridge (level 5, further out)
The step-based execution model means IF sessions can run in the async MUD game loop without blocking. Each player command advances their z-machine instance by N instructions (until output or a stopping condition). The trace deque captures the last 20 instructions for debugging unexpected state.
related documents
-----------------

View file

@ -0,0 +1,344 @@
Z-Machine Garbled Output Investigation
========================================
Context
-------
Branch: ``zmachine-zork1-integration``
The hybrid z-machine interpreter runs Zork 1 but produces garbled output:
- Room name "West of House" prints correctly
- Room description shows "er " instead of full text
- After "look" command, output starts correctly then becomes garbage with ? characters
- "There is a small mailbox here." prints correctly
Investigation
-------------
The z-string decoder (ZStringTranslator, ZCharTranslator, ZsciiTranslator in
zstring.py) is correct. The abbreviation table loads correctly. All 96
abbreviations decode to the right text.
The diagnostic script (``scripts/debug_zstrings.py``) revealed the problem:
- ``op_print_paddr paddr=0x0051 (byte=0x00a2)`` decodes to "er "
- Address 0x00a2 is in the abbreviation table area, not where the room
description should be
- The z-string at 0x00a2 really is "er " - the decoder is right, the address
is wrong
Root cause: bugs in ``zobjectparser.py`` property parsing.
Bugs Found
----------
All bugs are in ``zobjectparser.py``.
Bug 1: Off-by-one in shortname skip (ROOT CAUSE)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``get_prop_addr_len()`` line 392::
addr += 2 * self._memory[addr]
This skips the shortname data but not the length byte. Compare with
``get_all_properties()`` which does it correctly::
shortname_length = self._memory[addr]
addr += 1 # skip length byte
addr += 2 * shortname_length # skip shortname data
The off-by-one means the property table scan starts 1 byte too early, reading
the last byte of the shortname z-string as if it were a property header. This
corrupts every subsequent property lookup.
Bug 2: BitField slice wrong width for V3 property number
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``get_prop_addr_len()`` line 399 and ``get_all_properties()`` line 446::
pnum = bf[4:0]
BitField slice ``[4:0]`` has start > stop, so it swaps to ``[0:4]`` = 4 bits.
But V3 property numbers are 5 bits (bits 0-4). Should be ``bf[0:5]``.
This means property numbers 16-31 are read as 0-15 (bit 4 is lost).
Bug 3: BitField slice wrong width for V3 property size
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``get_prop_addr_len()`` line 400, ``get_all_properties()`` line 447, and
``get_property_length()`` line 555::
size = bf[7:5] + 1
BitField slice ``[7:5]`` swaps to ``[5:7]`` = 2 bits. But V3 property sizes are
3 bits (bits 5-7). Should be ``bf[5:8]``.
This means properties with size > 4 bytes are read with the wrong size (e.g.,
size 5 reads as size 1), causing the property iterator to skip too few bytes.
Bug 4: Same BitField slice issue in V5 path
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``get_prop_addr_len()`` lines 409, 413::
pnum = bf[5:0] # swaps to [0:5] = 5 bits, should be [0:6] = 6 bits
size = bf2[5:0] # swaps to [0:5] = 5 bits, should be [0:6] = 6 bits
Compare with ``get_all_properties()`` V5 path which correctly uses ``bf[0:6]``.
Also in ``get_property_length()`` line 563: same ``bf2[5:0]`` issue.
How to Fix
----------
All fixes are in ``zobjectparser.py``:
1. Line 392: change ``addr += 2 * self._memory[addr]`` to::
addr += 1 + 2 * self._memory[addr]
2. Lines 399, 446: change ``bf[4:0]`` to ``bf[0:5]``
3. Lines 400, 447, 555: change ``bf[7:5]`` to ``bf[5:8]``
4. Lines 409, 413, 563: change ``bf[5:0]`` to ``bf[0:6]``
The pattern: BitField uses Python slice semantics ``[start:stop)`` where the
number of bits extracted is ``stop - start``. The buggy code used ``[high:low]``
notation thinking it would extract bits high through low inclusive, but the swap
logic makes it extract fewer bits than intended.
This is the same class of bug as the write_global fix in commit e5329d6
(BitField slice extracting 7 bits not 8).
Diagnostic Tools
----------------
``scripts/debug_zstrings.py`` traces all z-string decoding with caller info.
Run with::
echo -e "look\nquit\ny" | python3 scripts/debug_zstrings.py 2>/tmp/trace.log
Trace goes to stderr, game output to stdout.
Session 2: Additional Bugs Found and Fixed
-------------------------------------------
Bug 5: Same off-by-one in get_next_property
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``get_next_property()`` had the same shortname skip bug as Bug 1::
addr += 2 * self._memory[addr]
Same fix: ``addr += 1 + 2 * self._memory[addr]``
Also had the same BitField slice bugs (Bugs 2, 4) for the V3/V5
property number return values.
Bug 6: get_property_length reads wrong byte for V5 two-byte headers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In the V5 two-byte property header path, the code read the size from
the first header byte (which contains the property number) instead of
the second header byte (which contains the size)::
bf2 = BitField(self._memory[data_address - 2]) # WRONG: first byte
size = bf2[0:6]
The second byte was already read into ``bf`` at ``data_address - 1``.
Fix: use ``bf[0:6]`` directly instead of creating ``bf2``.
Bug 7: op_dec_chk and op_inc_chk use unsigned comparison
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``zcpu.py``, both ``op_dec_chk`` and ``op_inc_chk`` compared 16-bit
values as unsigned integers instead of signed. The Z-machine spec
requires signed comparison.
Effect: when a counter decremented from 0 to 65535 (wrapping), the check
``65535 < 0`` evaluated false (unsigned), causing infinite loops in Z-code
that counted down past zero. This made Zork 1's word-printing loop run
forever, dumping raw memory bytes to the screen as characters.
Fix: wrap both operands with ``_make_signed()`` before comparing.
What's Fixed Now
-----------------
After all fixes:
- Room descriptions display correctly on startup
- The infinite garbage output loop is fixed (dec_chk now works with signed)
- ``just check`` passes (516 tests)
What's Still Broken
--------------------
Commands like "look", "open mailbox", "quit" all produce::
You used the word "look" in a way that I don't understand.
The parser finds words in the dictionary (dict_addr is correct, e.g.
"look" at 0x44f2) and stores them in the parse buffer correctly. But the
game's Z-code parser can't match them to valid verb syntax.
Instruction tracing after ``op_sread`` shows the game's parser:
1. Reads parse buffer correctly (loadw(0x2551, 1) -> dict_addr 17650)
2. Checks dict_addr against specific word addresses (je comparisons) -
these are special-word checks, none match
3. Calls a function with the dict_addr that reads ``loadb(17650, 4)`` to
get the word's flags byte (0x41) from the dictionary entry
4. Tests individual flag bits (16, 64, 8, 32, 128, 4) against 0x41
5. Bit 6 (64) matches - the function extracts data from byte 5 (0xc2)
6. Despite finding the word type, the parser still rejects the command
The bug is likely in how the parser uses the extracted word-type data to
look up verb syntax. Possible areas:
- The ``and`` / ``je`` sequence that extracts a sub-field from the flags
byte may produce the wrong value
- The verb number extracted from byte 5 (0xc2 = 194) may need signed
interpretation or bit masking
- Another opcode (``op_jl``, ``op_jg``, ``op_sub``) may have a similar
unsigned-vs-signed bug
- The ``call`` opcode may have incorrect return value handling
Debugging approach for next session:
1. Extend the instruction trace past 100 steps to see what happens after
the word-type function returns
2. Compare the execution trace against dfrotz instruction trace (add
``-t`` flag to dfrotz for tracing)
3. Check ``op_jl`` and ``op_jg`` for the same unsigned comparison bug
4. Check ``op_sub`` and ``op_mul`` for signed arithmetic issues
5. Look at how the ``and`` result (step 21 in trace) flows through to
the verb syntax lookup
Diagnostic Tools
~~~~~~~~~~~~~~~~
``scripts/debug_zstrings.py`` traces all z-string decoding with caller info.
Run with::
echo -e "look\nquit\ny" | python3 scripts/debug_zstrings.py 2>/tmp/trace.log
Trace goes to stderr, game output to stdout.
To add instruction tracing after sread, add to ``zcpu.py``::
# In step(), after decoding:
print(f"PC={pc:#06x} {handler_name}({', '.join(str(a) for a in args)})",
file=sys.stderr)
Session 3: Parser Fixed - Interpreter Now Playable
---------------------------------------------------
Bug 8: op_jl uses unsigned comparison
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``zcpu.py``, ``op_jl`` (jump if less-than) compared raw 16-bit values
without ``_make_signed()``, the same class of bug as Bug 7 (op_dec_chk
and op_inc_chk).
Additionally, ``op_jl`` had a non-standard signature::
def op_jl(self, a, *others):
for b in others:
if a < b:
...
The Z-machine spec says JL takes exactly 2 operands. Compare with
``op_jg`` which correctly uses ``def op_jg(self, a, b)``.
Fix: add ``_make_signed()`` calls to both operands, normalize signature
to ``(self, a, b)`` matching ``op_jg``.
This alone did not fix the parser - needed in combination with Bug 9.
Bug 9: finish_routine return value storage - THE PARSER BUG
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Two sub-bugs in ``finish_routine()`` in ``zstackmanager.py``:
**Range check used decimal instead of hex**::
if result_variable < 10: # WRONG: should be 0x10 (16)
self.set_local_variable(result_variable, result)
Z-machine variable numbering: 0 = stack push, 1-15 = locals, 16+ = globals.
The check ``< 10`` meant local variables 10-15 were written as globals.
**Missing index adjustment for 1-based variable numbering**::
self.set_local_variable(result_variable, result) # WRONG
Z-machine variables are 1-based (variable 1 = first local), but
``set_local_variable()`` uses 0-based indexing (index 0 = first local).
Should be ``set_local_variable(result_variable - 1, result)``.
Both ``_read_variable()`` and ``_write_result()`` in ``zcpu.py``
correctly used ``addr - 1`` and ``< 0x10``, but ``finish_routine()``
in ``zstackmanager.py`` did not.
Effect: when a function returned a value to a local variable, it went
to the wrong slot. The caller read the correct slot and got stale data
(0 or the initialization value from the function call).
This is why every verb was recognized by the dictionary but rejected by
the parser. The word-type checker function returned the correct type,
but the return value landed in the wrong local variable. The parser saw
0 and could not match any syntax pattern.
Room descriptions worked because those code paths used stack returns
(variable 0) or global returns, not local variable returns.
Fix: change ``< 10`` to ``< 0x10``, add ``- 1`` to the
``set_local_variable`` call.
Bug 10: zlexer does not truncate words for dictionary lookup
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In ``zlexer.py``, V3 dictionary entries store words truncated to 6
characters (4 bytes of Z-string = 2 Z-words). V4+ uses 9 characters.
``parse_input()`` looked up full-length input words, so "mailbox" (7
characters) did not match "mailbo" (6 characters) in the dictionary.
Fix: truncate lookup key to ``6 if version <= 3 else 9`` before
``dict.get()``. The original word is preserved in the return list for
correct parse buffer positions.
What's Fixed Now
~~~~~~~~~~~~~~~~
After all fixes:
- All Zork 1 commands work: look, open mailbox, read leaflet, go north,
inventory, quit, take, drop
- Navigation, object manipulation, multi-word commands, game logic all
functional
- The interpreter is playable
Diagnostic Tools
----------------
``scripts/debug_zstrings.py`` traces all z-string decoding with caller info.
Run with::
echo -e "look\nquit\ny" | python3 scripts/debug_zstrings.py 2>/tmp/trace.log
Trace goes to stderr, game output to stdout.
To add instruction tracing after sread, add to ``zcpu.py``::
# In step(), after decoding:
print(f"PC={pc:#06x} {handler_name}({', '.join(str(a) for a in args)})",
file=sys.stderr)

189
scripts/debug_zstrings.py Executable file
View file

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Diagnostic script to trace z-string decoding in Zork 1.
This script loads Zork 1 and traces the z-string decoding pipeline:
1. Dumps all abbreviations from the abbreviation table
2. Monkey-patches ZStringFactory.get() to log every string decode
3. Runs the game with piped input to capture string decoding during gameplay
"""
import inspect
import sys
from pathlib import Path
def main():
# Add src to path so we can import mudlib
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from mudlib.zmachine import ZMachine
from mudlib.zmachine.trivialzui import create_zui
from mudlib.zmachine.zstring import ZStringFactory
print("=" * 80)
print("Z-STRING DECODING DIAGNOSTIC FOR ZORK 1")
print("=" * 80)
print()
story_path = project_root / "content" / "stories" / "zork1.z3"
if not story_path.exists():
print(f"ERROR: Story file not found at {story_path}")
sys.exit(1)
with open(story_path, "rb") as f:
story_bytes = f.read()
print(f"Loaded {len(story_bytes)} bytes from {story_path.name}")
print()
# Create ZUI and ZMachine
ui = create_zui()
zmachine = ZMachine(story_bytes, ui, debugmode=False)
# Access the string factory
string_factory = zmachine._stringfactory
# PART 1: Dump all abbreviations
print("=" * 80)
print("ABBREVIATION TABLE DUMP")
print("=" * 80)
print()
abbrevs = string_factory.zchr._abbrevs
if not abbrevs:
print("No abbreviations found (version 1 game or failed to load)")
else:
print(f"Total abbreviations: {len(abbrevs)}")
print()
for table in range(3):
print(f"--- Table {table} ---")
for index in range(32):
key = (table, index)
if key in abbrevs:
zscii_codes = abbrevs[key]
text = string_factory.zscii.get(zscii_codes)
# Show ZSCII codes (truncate if too long)
if len(zscii_codes) > 20:
zscii_display = str(zscii_codes[:20]) + "..."
else:
zscii_display = str(zscii_codes)
# Show text (truncate if too long)
if len(text) > 60:
text_display = repr(text[:60]) + "..."
else:
text_display = repr(text)
print(f" [{table},{index:2d}] ZSCII={zscii_display}")
print(f" Text={text_display}")
print()
# PART 2: Monkey-patch ZStringFactory.get() to trace all string decodes
print("=" * 80)
print("STRING DECODING TRACE")
print("=" * 80)
print()
print("Format: [opcode args] -> addr")
print(" zchars -> zscii -> text")
print()
def traced_get(self, addr):
# Get caller information using inspect
stack = inspect.stack()
caller_name = "unknown"
caller_info = ""
# Walk up the stack to find the opcode function
for frame_info in stack[1:]: # Skip our own frame
func_name = frame_info.function
if func_name.startswith("op_"):
caller_name = func_name
# Try to extract arguments from the frame
frame_locals = frame_info.frame.f_locals
# For op_print_addr, show the addr argument
if func_name == "op_print_addr" and "addr" in frame_locals:
caller_info = f" addr={frame_locals['addr']:#06x}"
# For op_print_paddr, show packed address and conversion
elif func_name == "op_print_paddr" and "string_paddr" in frame_locals:
paddr = frame_locals["string_paddr"]
# Show both the packed address and what it converts to
# For z3, byte_addr = paddr * 2
byte_addr = paddr * 2
caller_info = f" paddr={paddr:#06x} (byte={byte_addr:#06x})"
# For op_print_obj, show the object number
elif func_name == "op_print_obj" and "obj" in frame_locals:
caller_info = f" obj={frame_locals['obj']}"
# For op_print, note it's inline (z-string follows opcode)
elif func_name == "op_print":
caller_info = " (inline z-string)"
break
# Get z-chars from ZStringTranslator
zchars = self.zstr.get(addr)
# Convert z-chars to ZSCII codes
zscii_codes = self.zchr.get(zchars)
# Convert ZSCII to Unicode text
text = self.zscii.get(zscii_codes)
# Log to stderr so it doesn't interfere with game output
# Truncate long lists/strings for readability
if len(zchars) > 20:
zchars_display = str(zchars[:20]) + f"... (len={len(zchars)})"
else:
zchars_display = str(zchars)
if len(zscii_codes) > 20:
zscii_display = str(zscii_codes[:20]) + f"... (len={len(zscii_codes)})"
else:
zscii_display = str(zscii_codes)
text_display = repr(text[:80]) + "..." if len(text) > 80 else repr(text)
print(f"[{caller_name}{caller_info}] -> {addr:#06x}", file=sys.stderr)
print(f" zchars={zchars_display}", file=sys.stderr)
print(f" zscii={zscii_display}", file=sys.stderr)
print(f" text={text_display}", file=sys.stderr)
print(file=sys.stderr)
return text
# Apply the monkey patch
ZStringFactory.get = traced_get # type: ignore[assignment]
# PART 3: Run the game with piped input
print("=" * 80)
print("GAME OUTPUT (running interpreter with tracing enabled)")
print("=" * 80)
print()
print(
"Note: String decode traces are written to stderr and "
"interleaved with game output."
)
print("To separate them, run: python3 scripts/debug_zstrings.py 2>trace.log")
print()
print(
"To see just the trace: "
"python3 scripts/debug_zstrings.py 2>&1 >/dev/null | grep zchars"
)
print()
print("Press Ctrl+C to stop the game.")
print()
try:
zmachine.run()
except KeyboardInterrupt:
print("\n[Interrupted by user]")
except Exception as e:
print(f"\n[Game ended: {type(e).__name__}: {e}]")
print()
print("=" * 80)
print("DIAGNOSTIC COMPLETE")
print("=" * 80)
if __name__ == "__main__":
main()

110
scripts/run_zork1.py Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env -S uv run --script
"""Smoke test for hybrid z-machine interpreter with Zork 1."""
import argparse
import sys
import traceback
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description="Run Zork 1 in Z-machine interpreter")
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
args = parser.parse_args()
# Add src to path so we can import mudlib
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from mudlib.zmachine import ZMachine
from mudlib.zmachine.trivialzui import create_zui
from mudlib.zmachine.zcpu import (
ZCpuError,
ZCpuNotImplemented,
ZCpuQuit,
ZCpuRestart,
)
print("=" * 60)
print("Z-Machine Interpreter Test: Zork 1")
print("=" * 60)
print()
print("Loading story file: content/stories/zork1.z3")
story_path = project_root / "content" / "stories" / "zork1.z3"
if not story_path.exists():
print(f"ERROR: Story file not found at {story_path}")
sys.exit(1)
with open(story_path, "rb") as f:
story_bytes = f.read()
print(f"Loaded {len(story_bytes)} bytes")
print()
print("Creating ZUI and ZMachine instance...")
if args.debug:
print("Debug mode: ENABLED")
ui = create_zui()
zmachine = ZMachine(story_bytes, ui, debugmode=args.debug)
print("Starting interpreter...")
print("(Press Ctrl+C to exit)")
print()
print("-" * 60)
print()
try:
zmachine.run()
print()
print("-" * 60)
print("Interpreter exited normally.")
except ZCpuQuit:
print()
print("-" * 60)
print("Game quit via QUIT opcode.")
except ZCpuRestart:
print()
print("-" * 60)
print("Game requested restart via RESTART opcode.")
print("(Restart not implemented in this test script)")
except ZCpuNotImplemented as e:
print()
print("-" * 60)
print("FAILED: Unimplemented opcode encountered")
print(f"Error: {e}")
print()
traceback.print_exc()
sys.exit(1)
except ZCpuError as e:
print()
print("-" * 60)
print("FAILED: Z-Machine CPU error")
print(f"Error: {e}")
print()
traceback.print_exc()
sys.exit(1)
except KeyboardInterrupt:
print()
print()
print("-" * 60)
print("Interrupted by user (Ctrl+C)")
except Exception as e:
print()
print("-" * 60)
print("FAILED: Unexpected error")
print(f"Error: {e}")
print()
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

621
scripts/trace_opcodes.py Normal file
View file

@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""Z-Machine bytecode disassembler and opcode tracer.
Performs recursive-descent disassembly of a z-machine story file,
following all reachable code paths to catalog every opcode used.
Cross-references findings against zvm implementation status.
"""
import argparse
import struct
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
@dataclass
class OpcodeInfo:
"""Metadata about an opcode."""
name: str
stores: bool = False
branches: bool = False
terminal: bool = False
inline_string: bool = False
is_call: bool = False
# Complete V3 opcode definitions
OP2_OPCODES = {
1: OpcodeInfo("je", branches=True),
2: OpcodeInfo("jl", branches=True),
3: OpcodeInfo("jg", branches=True),
4: OpcodeInfo("dec_chk", branches=True),
5: OpcodeInfo("inc_chk", branches=True),
6: OpcodeInfo("jin", branches=True),
7: OpcodeInfo("test", branches=True),
8: OpcodeInfo("or", stores=True),
9: OpcodeInfo("and", stores=True),
10: OpcodeInfo("test_attr", branches=True),
11: OpcodeInfo("set_attr"),
12: OpcodeInfo("clear_attr"),
13: OpcodeInfo("store"),
14: OpcodeInfo("insert_obj"),
15: OpcodeInfo("loadw", stores=True),
16: OpcodeInfo("loadb", stores=True),
17: OpcodeInfo("get_prop", stores=True),
18: OpcodeInfo("get_prop_addr", stores=True),
19: OpcodeInfo("get_next_prop", stores=True),
20: OpcodeInfo("add", stores=True),
21: OpcodeInfo("sub", stores=True),
22: OpcodeInfo("mul", stores=True),
23: OpcodeInfo("div", stores=True),
24: OpcodeInfo("mod", stores=True),
}
OP1_OPCODES = {
0: OpcodeInfo("jz", branches=True),
1: OpcodeInfo("get_sibling", stores=True, branches=True),
2: OpcodeInfo("get_child", stores=True, branches=True),
3: OpcodeInfo("get_parent", stores=True),
4: OpcodeInfo("get_prop_len", stores=True),
5: OpcodeInfo("inc"),
6: OpcodeInfo("dec"),
7: OpcodeInfo("print_addr"),
8: OpcodeInfo("call_1s", stores=True, is_call=True),
9: OpcodeInfo("remove_obj"),
10: OpcodeInfo("print_obj"),
11: OpcodeInfo("ret", terminal=True),
12: OpcodeInfo("jump", terminal=True),
13: OpcodeInfo("print_paddr"),
14: OpcodeInfo("load", stores=True),
15: OpcodeInfo("not", stores=True),
}
OP0_OPCODES = {
0: OpcodeInfo("rtrue", terminal=True),
1: OpcodeInfo("rfalse", terminal=True),
2: OpcodeInfo("print", inline_string=True),
3: OpcodeInfo("print_ret", inline_string=True, terminal=True),
4: OpcodeInfo("nop"),
5: OpcodeInfo("save", branches=True),
6: OpcodeInfo("restore", branches=True),
7: OpcodeInfo("restart", terminal=True),
8: OpcodeInfo("ret_popped", terminal=True),
9: OpcodeInfo("pop"),
10: OpcodeInfo("quit", terminal=True),
11: OpcodeInfo("new_line"),
12: OpcodeInfo("show_status"),
13: OpcodeInfo("verify", branches=True),
}
VAR_OPCODES = {
0: OpcodeInfo("call_vs", stores=True, is_call=True),
1: OpcodeInfo("storew"),
2: OpcodeInfo("storeb"),
3: OpcodeInfo("put_prop"),
4: OpcodeInfo("sread"),
5: OpcodeInfo("print_char"),
6: OpcodeInfo("print_num"),
7: OpcodeInfo("random", stores=True),
8: OpcodeInfo("push"),
9: OpcodeInfo("pull"),
10: OpcodeInfo("split_window"),
11: OpcodeInfo("set_window"),
19: OpcodeInfo("output_stream"),
20: OpcodeInfo("input_stream"),
21: OpcodeInfo("sound_effect"),
22: OpcodeInfo("read_char", stores=True),
}
# ZVM implementation status (opcodes with real logic, not stubs)
ZVM_IMPLEMENTED = {
("2OP", 1),
("2OP", 2),
("2OP", 4),
("2OP", 5),
("2OP", 8),
("2OP", 9),
("2OP", 13),
("2OP", 14),
("2OP", 15),
("2OP", 16),
("2OP", 17),
("2OP", 20),
("2OP", 21),
("2OP", 22),
("2OP", 23),
("1OP", 0),
("1OP", 2),
("1OP", 3),
("1OP", 5),
("1OP", 8),
("1OP", 12),
("1OP", 13),
("0OP", 0),
("0OP", 1),
("0OP", 2),
("0OP", 3),
("VAR", 0),
("VAR", 1),
("VAR", 3),
("VAR", 5),
("VAR", 7),
("VAR", 8),
("VAR", 10),
("VAR", 11),
("VAR", 19),
("VAR", 22),
}
class ZMachine:
"""Z-Machine story file reader and disassembler."""
def __init__(self, story_path: Path, verbose: bool = False):
self.story_path = story_path
self.verbose = verbose
self.data = story_path.read_bytes()
self.version = self.data[0]
self.entry_point = self.read_word(0x06)
self.static_mem_base = self.read_word(0x0E)
# Disassembly state
self.visited_addrs = set()
self.visited_routines = set()
self.worklist = []
self.opcode_counts = defaultdict(int)
self.instruction_count = 0
self.routines_from_entrypoint = 0
self.routines_from_scan = 0
def read_byte(self, addr: int) -> int:
"""Read a single byte."""
return self.data[addr]
def read_word(self, addr: int) -> int:
"""Read a 16-bit big-endian word."""
return struct.unpack_from(">H", self.data, addr)[0]
def read_signed_word(self, addr: int) -> int:
"""Read a 16-bit signed big-endian word."""
val = self.read_word(addr)
return val if val < 0x8000 else val - 0x10000
def unpack_routine_addr(self, packed: int) -> int:
"""Convert packed routine address to byte address."""
if self.version <= 3:
return packed * 2
elif self.version <= 5:
return packed * 4
else:
return packed * 8
def parse_operands(
self, pc: int, opcode_byte: int
) -> tuple[list[tuple[int, bool]], int]:
"""Parse operands and return (operands, bytes_consumed).
Each operand is (value, is_constant) tuple.
"""
operands = []
pos = pc
if opcode_byte < 0x80:
# Long form 2OP
op1_type = (opcode_byte >> 6) & 1
op2_type = (opcode_byte >> 5) & 1
if op1_type == 0: # small constant
operands.append((self.read_byte(pos), True))
pos += 1
else: # variable
operands.append((self.read_byte(pos), False))
pos += 1
if op2_type == 0: # small constant
operands.append((self.read_byte(pos), True))
pos += 1
else: # variable
operands.append((self.read_byte(pos), False))
pos += 1
elif opcode_byte < 0xB0:
# Short form 1OP or 0OP
op_type = (opcode_byte >> 4) & 3
if op_type == 0: # large constant
operands.append((self.read_word(pos), True))
pos += 2
elif op_type == 1: # small constant
operands.append((self.read_byte(pos), True))
pos += 1
elif op_type == 2: # variable
operands.append((self.read_byte(pos), False))
pos += 1
# op_type == 3: 0OP, no operands
else:
# Variable form
types_byte = self.read_byte(pos)
pos += 1
for i in range(4):
op_type = (types_byte >> (6 - i * 2)) & 3
if op_type == 3: # omitted
break
elif op_type == 0: # large constant
operands.append((self.read_word(pos), True))
pos += 2
elif op_type == 1: # small constant
operands.append((self.read_byte(pos), True))
pos += 1
elif op_type == 2: # variable
operands.append((self.read_byte(pos), False))
pos += 1
return operands, pos - pc
def parse_zstring(self, addr: int) -> int:
"""Parse z-string and return length in bytes."""
pos = addr
while True:
word = self.read_word(pos)
pos += 2
if word & 0x8000:
break
return pos - addr
def parse_branch(self, pc: int) -> tuple[int | None, int]:
"""Parse branch data and return (target_addr, bytes_consumed)."""
branch_byte = self.read_byte(pc)
short_form = (branch_byte & 0x40) != 0
if short_form:
offset = branch_byte & 0x3F
bytes_consumed = 1
else:
offset = ((branch_byte & 0x3F) << 8) | self.read_byte(pc + 1)
if offset >= 0x2000:
offset -= 0x4000
bytes_consumed = 2
# Compute target
if offset == 0 or offset == 1:
# Return true/false - terminal for this path
return None, bytes_consumed
else:
target = pc + bytes_consumed + offset - 2
return target, bytes_consumed
def decode_instruction(self, addr: int) -> tuple[str, str, int, list[int]]:
"""Decode instruction at addr.
Returns (opclass, name, next_addr, targets).
targets is a list of addresses to visit next.
"""
if addr in self.visited_addrs:
return "", "", addr, []
self.visited_addrs.add(addr)
self.instruction_count += 1
opcode_byte = self.read_byte(addr)
pc = addr + 1
# Determine form and opcode
if opcode_byte < 0x80:
# Long form 2OP
opcode_num = opcode_byte & 0x1F
opclass = "2OP"
info = OP2_OPCODES.get(opcode_num)
elif opcode_byte < 0xC0:
# Short form (includes 0xB0-0xBF which are 0OP)
op_type = (opcode_byte >> 4) & 3
if op_type == 3:
# 0OP
opcode_num = opcode_byte & 0x0F
opclass = "0OP"
info = OP0_OPCODES.get(opcode_num)
else:
# 1OP
opcode_num = opcode_byte & 0x0F
opclass = "1OP"
info = OP1_OPCODES.get(opcode_num)
elif opcode_byte < 0xE0:
# Variable form 2OP (0xC0-0xDF)
opcode_num = opcode_byte & 0x1F
opclass = "2OP"
info = OP2_OPCODES.get(opcode_num)
else:
# Variable form VAR (0xE0-0xFF)
opcode_num = opcode_byte & 0x1F
opclass = "VAR"
info = VAR_OPCODES.get(opcode_num)
if info is None:
# Unknown opcode
return opclass, f"unknown_{opcode_num}", pc, []
# Track opcode
self.opcode_counts[(opclass, opcode_num)] += 1
if self.verbose:
print(
f" {addr:05x}: {opclass}:{opcode_num:02d} {info.name}",
file=sys.stderr,
)
# Parse operands
operands, operand_bytes = self.parse_operands(pc, opcode_byte)
pc += operand_bytes
# Handle inline z-string
if info.inline_string:
string_bytes = self.parse_zstring(pc)
pc += string_bytes
# Handle store byte
if info.stores:
pc += 1
# Handle branch
targets = []
if info.branches:
branch_target, branch_bytes = self.parse_branch(pc)
pc += branch_bytes
if branch_target is not None:
targets.append(branch_target)
# Handle call (follow routine + continue after call)
if info.is_call and operands:
packed_addr, is_const = operands[0]
if is_const and packed_addr != 0:
routine_addr = self.unpack_routine_addr(packed_addr)
if routine_addr not in self.visited_routines and routine_addr < len(
self.data
):
self.visited_routines.add(routine_addr)
targets.append(routine_addr)
# Handle jump (terminal but has target)
if opcode_num == 12 and opclass == "1OP" and operands:
offset, _ = operands[0]
if offset >= 0x8000:
offset -= 0x10000
jump_target = pc + offset - 2
targets.append(jump_target)
return opclass, info.name, pc, targets
# Add fall-through if not terminal
if not info.terminal:
targets.append(pc)
return opclass, info.name, pc, targets
def disassemble_routine(self, addr: int):
"""Disassemble a routine starting at addr."""
if addr >= len(self.data):
return
# Parse routine header
num_locals = self.read_byte(addr)
pc = addr + 1
# Skip local variable initial values (V3 only)
if self.version <= 3:
pc += num_locals * 2
# Add first instruction to worklist
self.worklist.append(pc)
def scan_data_for_routines(self):
"""Scan dynamic memory for packed addresses pointing to routines.
Globals and object property tables live in dynamic memory and
contain packed routine addresses. This catches routines reachable
only through indirect calls (variable operands in CALL opcodes).
"""
high_mem = self.read_word(0x04)
found = 0
# Scan every word in dynamic memory (globals, property tables)
for offset in range(0, self.static_mem_base - 1, 2):
packed = self.read_word(offset)
if packed == 0:
continue
addr = self.unpack_routine_addr(packed)
# Must point into the code region
if addr < high_mem or addr >= len(self.data) - 1:
continue
# Must not already be a known routine
if addr in self.visited_routines:
continue
# Must look like a valid routine header (local count 0-15)
num_locals = self.read_byte(addr)
if num_locals > 15:
continue
# First instruction must be within bounds
first_instr = addr + 1
if self.version <= 3:
first_instr += num_locals * 2
if first_instr >= len(self.data):
continue
self.visited_routines.add(addr)
self.disassemble_routine(addr)
found += 1
return found
def _process_worklist(self):
"""Process the instruction worklist until empty."""
while self.worklist:
addr = self.worklist.pop()
if addr in self.visited_addrs or addr >= len(self.data):
continue
opclass, name, next_addr, targets = self.decode_instruction(addr)
for target in targets:
if target >= len(self.data) or target < 0:
continue
if target in self.visited_routines:
num_locals = self.read_byte(target)
first_instr = target + 1
if self.version <= 3:
first_instr += num_locals * 2
if first_instr not in self.visited_addrs:
self.worklist.append(first_instr)
else:
if target not in self.visited_addrs:
self.worklist.append(target)
def disassemble_all(self):
"""Perform complete recursive-descent disassembly."""
# Entry point is a byte address of the first instruction (V1-5)
self.worklist.append(self.entry_point)
# Phase 1: recursive descent from entry point
self._process_worklist()
self.reachable_routines = len(self.visited_routines)
# Phase 2: scan dynamic memory for packed routine addresses
self.scanned_routines = self.scan_data_for_routines()
self._process_worklist()
def generate_report(self) -> str:
"""Generate analysis report."""
lines = []
lines.append("=" * 70)
lines.append("Z-MACHINE OPCODE TRACE REPORT")
lines.append("=" * 70)
lines.append("")
lines.append(f"Story file: {self.story_path}")
lines.append(f"Version: {self.version}")
lines.append(f"Entry point: ${self.entry_point:04x}")
lines.append(f"Story size: {len(self.data)} bytes")
lines.append("")
lines.append("DISASSEMBLY STATISTICS")
lines.append("-" * 70)
lines.append(
f"Routines found (entry-point reachable): {self.reachable_routines}"
)
lines.append(f"Routines found (data scan): {self.scanned_routines}")
lines.append(f"Total routines: {len(self.visited_routines)}")
lines.append(f"Instructions decoded: {self.instruction_count}")
lines.append(f"Unique opcodes: {len(self.opcode_counts)}")
lines.append("")
# Opcodes by class
lines.append("OPCODES FOUND IN STORY")
lines.append("-" * 70)
for opclass_name, opcode_dict in [
("2OP", OP2_OPCODES),
("1OP", OP1_OPCODES),
("0OP", OP0_OPCODES),
("VAR", VAR_OPCODES),
]:
lines.append(f"\n{opclass_name} opcodes:")
found = [
(num, opcode_dict[num].name, self.opcode_counts[(opclass_name, num)])
for num in sorted(opcode_dict.keys())
if (opclass_name, num) in self.opcode_counts
]
if found:
for num, name, count in found:
lines.append(f" {num:2d} {name:20s} (used {count} times)")
else:
lines.append(" (none)")
lines.append("")
lines.append("GAP ANALYSIS: ZVM IMPLEMENTATION STATUS")
lines.append("-" * 70)
implemented = []
missing = []
for (opclass, opcode_num), count in sorted(self.opcode_counts.items()):
if opclass == "2OP":
name = OP2_OPCODES[opcode_num].name
elif opclass == "1OP":
name = OP1_OPCODES[opcode_num].name
elif opclass == "0OP":
name = OP0_OPCODES[opcode_num].name
elif opclass == "VAR":
name = VAR_OPCODES[opcode_num].name
else:
name = "unknown"
key = (opclass, opcode_num)
if key in ZVM_IMPLEMENTED:
implemented.append((opclass, opcode_num, name, count))
else:
missing.append((opclass, opcode_num, name, count))
lines.append("\nImplemented in zvm:")
for opclass, num, name, count in implemented:
lines.append(f" {opclass}:{num:2d} {name:20s} (used {count} times)")
lines.append("\nMissing from zvm (need porting):")
for opclass, num, name, count in missing:
lines.append(f" {opclass}:{num:2d} {name:20s} (used {count} times)")
lines.append("")
lines.append("SUMMARY")
lines.append("-" * 70)
total = len(self.opcode_counts)
impl_count = len(implemented)
missing_count = len(missing)
lines.append(
f"{total} unique opcodes found in story, "
f"{impl_count} already in zvm, {missing_count} need porting"
)
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Trace z-machine opcodes in a story file"
)
parser.add_argument(
"story",
nargs="?",
default="content/stories/zork1.z3",
help="Path to z-machine story file (default: content/stories/zork1.z3)",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print each opcode as it's found",
)
args = parser.parse_args()
story_path = Path(args.story)
if not story_path.exists():
print(f"Error: Story file not found: {story_path}", file=sys.stderr)
sys.exit(1)
if args.verbose:
print(f"Disassembling {story_path}...", file=sys.stderr)
zm = ZMachine(story_path, verbose=args.verbose)
zm.disassemble_all()
print(zm.generate_report())
if __name__ == "__main__":
main()

View file

@ -0,0 +1,27 @@
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the ZVM project nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,8 @@
"""Hybrid z-machine interpreter based on sussman/zvm.
Original: https://github.com/sussman/zvm (BSD license)
Extended with opcode implementations ported from DFillmore/viola.
"""
from .zmachine import ZMachine
__all__ = ["ZMachine"]

View file

@ -0,0 +1,62 @@
#
# A helper class to access individual bits of a bitfield in a Pythonic
# way.
#
# Inspired from a recipe at:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/113799
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class BitField:
"""An bitfield gives read/write access to the individual bits of a
value, in array and slice notation.
Conversion back to an int value is also supported, and a method is
provided to print the value in binary for debug purposes.
For all indexes, 0 is the LSB (Least Significant Bit)."""
def __init__(self, value=0):
"""Initialize a bitfield object from a number or string value."""
if isinstance(value, str):
self._d = ord(value)
else:
self._d = value
def __getitem__(self, index):
"""Get the value of a single bit or slice."""
if isinstance(index, slice):
start, stop = index.start, index.stop
if start > stop:
(start, stop) = (stop, start)
mask = (1 << (stop - start)) - 1
return (self._d >> start) & mask
else:
return (self._d >> index) & 1
def __setitem__(self, index, value):
"""Set the value of a single bit or slice."""
if isinstance(value, str):
value = ord(value)
if isinstance(index, slice):
start, stop = index.start, index.stop
mask = (1 << (stop - start)) - 1
value = (value & mask) << start
mask = mask << start
self._d = (self._d & ~mask) | value
return (self._d >> start) & mask
else:
value = (value) << index
mask = (1) << index
self._d = (self._d & ~mask) | value
def __int__(self):
"""Return the whole bitfield as an integer."""
return self._d
def to_str(self, len):
"""Print the binary representation of the bitfield."""
return "".join([f"{self[i]}" for i in range(len - 1, -1, -1)])

354
src/mudlib/zmachine/glk.py Normal file
View file

@ -0,0 +1,354 @@
#
# This module defines a ctypes foreign function interface to a Glk
# library that has been built as a shared library. For more information
# on the Glk API, see http://www.eblong.com/zarf/glk/.
#
# Note that the way this module interfaces with a Glk library is
# slightly different from the standard; the standard interface
# actually assumes that a Glk library is in fact not a library, but a
# "front-end" program that is statically linked to a Glk back-end,
# also known as a Glk "program", by calling the back-end's glk_main()
# function.
#
# Instead of this, we assume that the Glk library is actually a shared
# library that is initialized by some external source--be it a Python
# script or a compiled program--and used by this module. Note that
# this is actually the way some Glk libraries, such as WindowsGlk, are
# made to function.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import ctypes
# These are ctypes-style declarations that reflect the Glk.h file,
# which defines the Glk API, version 0.7.0. The most recent version
# of Glk.h can be found here:
#
# http://www.eblong.com/zarf/glk/glk.h
#
# Note that there are ctypes extension libraries that can do this kind
# of thing for us (that is, take a .h file and automatically generate
# a ctypes wrapper from it); however, the only one that exists at the
# time of this writing is ctypeslib, which has dependencies that would
# make our build process quite complex. Given the relatively small
# size of the Glk API and the freedom we get from hand-coding the
# interface ourselves, we're not using ctypeslib.
glsi32 = ctypes.c_int32
glui32 = ctypes.c_uint32
winid_t = ctypes.c_void_p
strid_t = ctypes.c_void_p
frefid_t = ctypes.c_void_p
schanid_t = ctypes.c_void_p
# TRUE, FALSE, and NULL aren't defined in glk.h, but are mentioned in
# Section 1.9 of the Glk spec 0.7.0.
TRUE = 1
FALSE = 0
NULL = ctypes.pointer(glui32(0))
gestalt_Version = 0
gestalt_CharInput = 1
gestalt_LineInput = 2
gestalt_CharOutput = 3
gestalt_CharOutput_CannotPrint = 0
gestalt_CharOutput_ApproxPrint = 1
gestalt_CharOutput_ExactPrint = 2
gestalt_MouseInput = 4
gestalt_Timer = 5
gestalt_Graphics = 6
gestalt_DrawImage = 7
gestalt_Sound = 8
gestalt_SoundVolume = 9
gestalt_SoundNotify = 10
gestalt_Hyperlinks = 11
gestalt_HyperlinkInput = 12
gestalt_SoundMusic = 13
gestalt_GraphicsTransparency = 14
gestalt_Unicode = 15
evtype_None = 0
evtype_Timer = 1
evtype_CharInput = 2
evtype_LineInput = 3
evtype_MouseInput = 4
evtype_Arrange = 5
evtype_Redraw = 6
evtype_SoundNotify = 7
evtype_Hyperlink = 8
class event_t(ctypes.Structure):
_fields_ = [("type", glui32), ("win", winid_t), ("val1", glui32), ("val2", glui32)]
keycode_Unknown = 0xFFFFFFFF
keycode_Left = 0xFFFFFFFE
keycode_Right = 0xFFFFFFFD
keycode_Up = 0xFFFFFFFC
keycode_Down = 0xFFFFFFFB
keycode_Return = 0xFFFFFFFA
keycode_Delete = 0xFFFFFFF9
keycode_Escape = 0xFFFFFFF8
keycode_Tab = 0xFFFFFFF7
keycode_PageUp = 0xFFFFFFF6
keycode_PageDown = 0xFFFFFFF5
keycode_Home = 0xFFFFFFF4
keycode_End = 0xFFFFFFF3
keycode_Func1 = 0xFFFFFFEF
keycode_Func2 = 0xFFFFFFEE
keycode_Func3 = 0xFFFFFFED
keycode_Func4 = 0xFFFFFFEC
keycode_Func5 = 0xFFFFFFEB
keycode_Func6 = 0xFFFFFFEA
keycode_Func7 = 0xFFFFFFE9
keycode_Func8 = 0xFFFFFFE8
keycode_Func9 = 0xFFFFFFE7
keycode_Func10 = 0xFFFFFFE6
keycode_Func11 = 0xFFFFFFE5
keycode_Func12 = 0xFFFFFFE4
keycode_MAXVAL = 28
style_Normal = 0
style_Emphasized = 1
style_Preformatted = 2
style_Header = 3
style_Subheader = 4
style_Alert = 5
style_Note = 6
style_BlockQuote = 7
style_Input = 8
style_User1 = 9
style_User2 = 10
style_NUMSTYLES = 11
class stream_result_t(ctypes.Structure):
_fields_ = [("readcount", glui32), ("writecount", glui32)]
wintype_AllTypes = 0
wintype_Pair = 1
wintype_Blank = 2
wintype_TextBuffer = 3
wintype_TextGrid = 4
wintype_Graphics = 5
winmethod_Left = 0x00
winmethod_Right = 0x01
winmethod_Above = 0x02
winmethod_Below = 0x03
winmethod_DirMask = 0x0F
winmethod_Fixed = 0x10
winmethod_Proportional = 0x20
winmethod_DivisionMask = 0xF0
fileusage_Data = 0x00
fileusage_SavedGame = 0x01
fileusage_Transcript = 0x02
fileusage_InputRecord = 0x03
fileusage_TypeMask = 0x0F
fileusage_TextMode = 0x100
fileusage_BinaryMode = 0x000
filemode_Write = 0x01
filemode_Read = 0x02
filemode_ReadWrite = 0x03
filemode_WriteAppend = 0x05
seekmode_Start = 0
seekmode_Current = 1
seekmode_End = 2
stylehint_Indentation = 0
stylehint_ParaIndentation = 1
stylehint_Justification = 2
stylehint_Size = 3
stylehint_Weight = 4
stylehint_Oblique = 5
stylehint_Proportional = 6
stylehint_TextColor = 7
stylehint_BackColor = 8
stylehint_ReverseColor = 9
stylehint_NUMHINTS = 10
stylehint_just_LeftFlush = 0
stylehint_just_LeftRight = 1
stylehint_just_Centered = 2
stylehint_just_RightFlush = 3
# Function prototypes for the core Glk API. It is a list of 3-tuples; each
# item in the list represents a function prototype, and each 3-tuple
# is in the form (result_type, function_name, arg_types).
CORE_GLK_LIB_API = [
(None, "glk_exit", ()),
(None, "glk_tick", ()),
(glui32, "glk_gestalt", (glui32, glui32)),
(glui32, "glk_gestalt_ext", (glui32, glui32, ctypes.POINTER(glui32), glui32)),
(winid_t, "glk_window_get_root", ()),
(winid_t, "glk_window_open", (winid_t, glui32, glui32, glui32, glui32)),
(None, "glk_window_close", (winid_t, ctypes.POINTER(stream_result_t))),
(
None,
"glk_window_get_size",
(winid_t, ctypes.POINTER(glui32), ctypes.POINTER(glui32)),
),
(None, "glk_window_set_arrangement", (winid_t, glui32, glui32, winid_t)),
(
None,
"glk_window_get_arrangement",
(
winid_t,
ctypes.POINTER(glui32),
ctypes.POINTER(glui32),
ctypes.POINTER(winid_t),
),
),
(winid_t, "glk_window_iterate", (winid_t, ctypes.POINTER(glui32))),
(glui32, "glk_window_get_rock", (winid_t,)),
(glui32, "glk_window_get_type", (winid_t,)),
(winid_t, "glk_window_get_parent", (winid_t,)),
(winid_t, "glk_window_get_sibling", (winid_t,)),
(None, "glk_window_clear", (winid_t,)),
(None, "glk_window_move_cursor", (winid_t, glui32, glui32)),
(strid_t, "glk_window_get_stream", (winid_t,)),
(None, "glk_window_set_echo_stream", (winid_t, strid_t)),
(strid_t, "glk_window_get_echo_stream", (winid_t,)),
(None, "glk_set_window", (winid_t,)),
(strid_t, "glk_stream_open_file", (frefid_t, glui32, glui32)),
(strid_t, "glk_stream_open_memory", (ctypes.c_char_p, glui32, glui32, glui32)),
(None, "glk_stream_close", (strid_t, ctypes.POINTER(stream_result_t))),
(strid_t, "glk_stream_iterate", (strid_t, ctypes.POINTER(glui32))),
(glui32, "glk_stream_get_rock", (strid_t,)),
(None, "glk_stream_set_position", (strid_t, glsi32, glui32)),
(glui32, "glk_stream_get_position", (strid_t,)),
(None, "glk_stream_set_current", (strid_t,)),
(strid_t, "glk_stream_get_current", ()),
(None, "glk_put_char", (ctypes.c_ubyte,)),
(None, "glk_put_char_stream", (strid_t, ctypes.c_ubyte)),
(None, "glk_put_string", (ctypes.c_char_p,)),
(None, "glk_put_string_stream", (strid_t, ctypes.c_char_p)),
(None, "glk_put_buffer", (ctypes.c_char_p, glui32)),
(None, "glk_put_buffer_stream", (strid_t, ctypes.c_char_p, glui32)),
(None, "glk_set_style", (glui32,)),
(None, "glk_set_style_stream", (strid_t, glui32)),
(glsi32, "glk_get_char_stream", (strid_t,)),
(glui32, "glk_get_line_stream", (strid_t, ctypes.c_char_p, glui32)),
(glui32, "glk_get_buffer_stream", (strid_t, ctypes.c_char_p, glui32)),
(None, "glk_stylehint_set", (glui32, glui32, glui32, glsi32)),
(None, "glk_stylehint_clear", (glui32, glui32, glui32)),
(glui32, "glk_style_distinguish", (winid_t, glui32, glui32)),
(glui32, "glk_style_measure", (winid_t, glui32, glui32, ctypes.POINTER(glui32))),
(frefid_t, "glk_fileref_create_temp", (glui32, glui32)),
(frefid_t, "glk_fileref_create_by_name", (glui32, ctypes.c_char_p, glui32)),
(frefid_t, "glk_fileref_create_by_prompt", (glui32, glui32, glui32)),
(frefid_t, "glk_fileref_create_from_fileref", (glui32, frefid_t, glui32)),
(None, "glk_fileref_destroy", (frefid_t,)),
(frefid_t, "glk_fileref_iterate", (frefid_t, ctypes.POINTER(glui32))),
(glui32, "glk_fileref_get_rock", (frefid_t,)),
(None, "glk_fileref_delete_file", (frefid_t,)),
(glui32, "glk_fileref_does_file_exist", (frefid_t,)),
(None, "glk_select", (ctypes.POINTER(event_t),)),
(None, "glk_select_poll", (ctypes.POINTER(event_t),)),
(None, "glk_request_timer_events", (glui32,)),
(None, "glk_request_line_event", (winid_t, ctypes.c_char_p, glui32, glui32)),
(None, "glk_request_char_event", (winid_t,)),
(None, "glk_request_mouse_event", (winid_t,)),
(None, "glk_cancel_line_event", (winid_t, ctypes.POINTER(event_t))),
(None, "glk_cancel_char_event", (winid_t,)),
(None, "glk_cancel_mouse_event", (winid_t,)),
]
# Function prototypes for the optional Unicode extension of the Glk
# API.
UNICODE_GLK_LIB_API = [
(None, "glk_put_char_uni", (glui32,)),
(None, "glk_put_string_uni", (ctypes.POINTER(glui32),)),
(None, "glk_put_buffer_uni", (ctypes.POINTER(glui32), glui32)),
(None, "glk_put_char_stream_uni", (strid_t, glui32)),
(None, "glk_put_string_stream_uni", (strid_t, ctypes.POINTER(glui32))),
(None, "glk_put_buffer_stream_uni", (strid_t, ctypes.POINTER(glui32), glui32)),
(glsi32, "glk_get_char_stream_uni", (strid_t,)),
(glui32, "glk_get_buffer_stream_uni", (strid_t, ctypes.POINTER(glui32), glui32)),
(glui32, "glk_get_line_stream_uni", (strid_t, ctypes.POINTER(glui32), glui32)),
(strid_t, "glk_stream_open_file_uni", (frefid_t, glui32, glui32)),
(
strid_t,
"glk_stream_open_memory_uni",
(ctypes.POINTER(glui32), glui32, glui32, glui32),
),
(None, "glk_request_char_event_uni", (winid_t,)),
(
None,
"glk_request_line_event_uni",
(winid_t, ctypes.POINTER(glui32), glui32, glui32),
),
]
class GlkLib:
"""Encapsulates the ctypes interface to a Glk shared library. When
instantiated, it wraps the shared library with the appropriate
function prototypes from the Glk API to reduce the chance of
mis-calls that may result in segfaults (this effectively simulates
the strong type-checking a C compiler would perform)."""
def __init__(self, lib_name):
"""Instantiates the instance, binding it to the given shared
library (which is referenced by name)."""
self._dll = ctypes.CDLL(lib_name)
self.__bind_prototypes(CORE_GLK_LIB_API)
if self.glk_gestalt(gestalt_Unicode, 0) == 1: # type: ignore[unresolved-attribute]
self.__bind_prototypes(UNICODE_GLK_LIB_API)
else:
self.__bind_not_implemented_prototypes(UNICODE_GLK_LIB_API)
def __bind_prototypes(self, function_prototypes):
"""Create function prototypes from the given list of 3-tuples
of the form (result_type, function_name, arg_types), bind them
to functions in our shared library, and then bind the function
instances as methods to this object."""
for function_prototype in function_prototypes:
result_type, function_name, arg_types = function_prototype
prototype = ctypes.CFUNCTYPE(result_type, *arg_types)
function = prototype((function_name, self._dll))
setattr(self, function_name, function)
def __bind_not_implemented_prototypes(self, function_prototypes):
"""Create functions with the names from the given list of
3-tuples of the form (result_type, function_name, arg_types)
that simply raise NotImplementedError, and bind them to this
object. This should be used when a Glk library doesn't
support some optional extension of the Glk API."""
def notImplementedFunction(*args, **kwargs):
raise NotImplementedError("Function not implemented by this Glk library.")
for function_prototype in function_prototypes:
_, function_name, _ = function_prototype
setattr(self, function_name, notImplementedFunction)
def glk_char_to_lower(self, ch):
raise NotImplementedError("Use unicode.lower() instead.")
def glk_char_to_upper(self, ch):
raise NotImplementedError("Use unicode.upper() instead.")
def glk_buffer_to_lower_case_uni(self, buf, len, numchars):
raise NotImplementedError("Use unicode.lower() instead.")
def glk_buffer_to_upper_case_uni(self, buf, len, numchars):
raise NotImplementedError("Use unicode.upper() instead.")
def glk_buffer_to_title_case_uni(self, buf, len, numchars, lowerrest):
raise NotImplementedError("Use unicode.title() instead.")

View file

@ -0,0 +1,462 @@
#
# A class which knows how to write and parse 'Quetzal' files, which is
# the standard save-file format for modern Z-machine implementations.
# This allows ZVM's saved games to load in other interpreters, and
# vice versa.
#
# The Quetzal format is documented at:
# http://www.ifarchive.org/if-archive/infocom/\
# interpreters/specification/savefile_14.txt
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Woohoo! Python has a module to parse IFF files, which is a generic
# interchange format. A Quetzal file is in fact a type of IFF file.
import chunk
import os
from . import bitfield, zstackmanager
from .zlogging import log
# The general format of Queztal is that of a "FORM" IFF file, which is
# a container class for 'chunks'.
#
# "FORM", 4 bytes of container total-length, "IFZS",
# 4-byte chunkname, 4-byte length, length bytes of data
# 4-byte chunkname, 4-byte length, length bytes of data
# 4-byte chunkname, 4-byte length, length bytes of data
# ...
class QuetzalError(Exception):
"General exception for Quetzal classes."
pass
class QuetzalMalformedChunk(QuetzalError):
"Malformed chunk detected."
class QuetzalNoSuchSavefile(QuetzalError):
"Cannot locate save-game file."
class QuetzalUnrecognizedFileFormat(QuetzalError):
"Not a valid Quetzal file."
class QuetzalIllegalChunkOrder(QuetzalError):
"IFhd chunk came after Umem/Cmem/Stks chunks (see section 5.4)."
class QuetzalMismatchedFile(QuetzalError):
"Quetzal file dosen't match current game."
class QuetzalMemoryOutOfBounds(QuetzalError):
"Decompressed dynamic memory has gone out of bounds."
class QuetzalMemoryMismatch(QuetzalError):
"Savefile's dynamic memory image is incorrectly sized."
class QuetzalStackFrameOverflow(QuetzalError):
"Stack frame parsing went beyond bounds of 'Stks' chunk."
class QuetzalParser:
"""A class to read a Quetzal save-file and modify a z-machine."""
def __init__(self, zmachine):
log("Creating new instance of QuetzalParser")
self._zmachine = zmachine
self._seen_mem_or_stks = False
self._last_loaded_metadata = {} # metadata for tests & debugging
def _parse_ifhd(self, data):
"""Parse a chunk of type IFhd, and check that the quetzal file
really belongs to the current story (by comparing release number,
serial number, and checksum.)"""
# Spec says that this chunk *must* come before memory or stack chunks.
if self._seen_mem_or_stks:
raise QuetzalIllegalChunkOrder
bytes = data
if len(bytes) != 13:
raise QuetzalMalformedChunk
chunk_release = (data[0] << 8) + data[1]
chunk_serial = data[2:8]
chunk_checksum = (data[8] << 8) + data[9]
chunk_pc = (data[10] << 16) + (data[11] << 8) + data[12]
self._zmachine._opdecoder.program_counter = chunk_pc
log(f" Found release number {chunk_release}")
log(f" Found serial number {int(chunk_serial)}")
log(f" Found checksum {chunk_checksum}")
log(f" Initial program counter value is {chunk_pc}")
self._last_loaded_metadata["release number"] = chunk_release
self._last_loaded_metadata["serial number"] = chunk_serial
self._last_loaded_metadata["checksum"] = chunk_checksum
self._last_loaded_metadata["program counter"] = chunk_pc
# Verify the save-file params against the current z-story header
mem = self._zmachine._mem
if mem.read_word(2) != chunk_release:
raise QuetzalMismatchedFile
serial_bytes = chunk_serial
if serial_bytes != mem[0x12:0x18]:
raise QuetzalMismatchedFile
mem_checksum = mem.read_word(0x1C)
if mem_checksum != 0 and (mem_checksum != chunk_checksum):
raise QuetzalMismatchedFile
log(" Quetzal file correctly verifies against original story.")
def _parse_cmem(self, data):
"""Parse a chunk of type Cmem. Decompress an image of dynamic
memory, and place it into the ZMachine."""
log(" Decompressing dynamic memory image")
self._seen_mem_or_stks = True
# Just duplicate the dynamic memory block of the pristine story image,
# and then make tweaks to it as we decode the runlength-encoding.
pmem = self._zmachine._pristine_mem
cmem = self._zmachine._mem
savegame_mem = list(pmem[pmem._dynamic_start : (pmem._dynamic_end + 1)])
memlen = len(savegame_mem)
memcounter = 0
log(f" Dynamic memory length is {memlen}")
self._last_loaded_metadata["memory length"] = memlen
runlength_bytes = data
bytelen = len(runlength_bytes)
bytecounter = 0
log(" Decompressing dynamic memory image")
while bytecounter < bytelen:
byte = runlength_bytes[bytecounter]
if byte != 0:
savegame_mem[memcounter] = byte ^ pmem[memcounter]
memcounter += 1
bytecounter += 1
log(f" Set byte {memcounter}:{savegame_mem[memcounter]}")
else:
bytecounter += 1
num_extra_zeros = runlength_bytes[bytecounter]
memcounter += 1 + num_extra_zeros
bytecounter += 1
log(f" Skipped {1 + num_extra_zeros} unchanged bytes")
if memcounter >= memlen:
raise QuetzalMemoryOutOfBounds
# If memcounter finishes less then memlen, that's totally fine, it
# just means there are no more diffs to apply.
cmem[cmem._dynamic_start : (cmem._dynamic_end + 1)] = savegame_mem
log(" Successfully installed new dynamic memory.")
def _parse_umem(self, data):
"""Parse a chunk of type Umem. Suck a raw image of dynamic memory
and place it into the ZMachine."""
### TODO: test this by either finding an interpreter that ouptuts
## this type of chunk, or by having own QuetzalWriter class
## (optionally) do it.
log(" Loading uncompressed dynamic memory image")
self._seen_mem_or_stks = True
cmem = self._zmachine._mem
dynamic_len = (cmem._dynamic_end - cmem.dynamic_start) + 1
log(f" Dynamic memory length is {dynamic_len}")
self._last_loaded_metadata["dynamic memory length"] = dynamic_len
savegame_mem = [ord(x) for x in data]
if len(savegame_mem) != dynamic_len:
raise QuetzalMemoryMismatch
cmem[cmem._dynamic_start : (cmem._dynamic_end + 1)] = savegame_mem
log(" Successfully installed new dynamic memory.")
def _parse_stks(self, data):
"""Parse a chunk of type Stks."""
log(" Begin parsing of stack frames")
# Our strategy here is simply to create an entirely new
# ZStackManager object and populate it with a series of ZRoutine
# stack-frames parses from the quetzal file. We then attach this
# new ZStackManager to our z-machine, and allow the old one to be
# garbage collected.
stackmanager = zstackmanager.ZStackManager(self._zmachine._mem)
self._seen_mem_or_stks = True
bytes = data
total_len = len(bytes)
ptr = 0
# Read successive stack frames:
while ptr < total_len:
log(" Parsing stack frame...")
return_pc = (bytes[ptr] << 16) + (bytes[ptr + 1] << 8) + bytes[ptr + 3]
ptr += 3
flags_bitfield = bitfield.BitField(bytes[ptr])
ptr += 1
_varnum = bytes[ptr] ### TODO: tells us which variable gets the result
ptr += 1
_argflag = bytes[ptr]
ptr += 1
evalstack_size = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
# read anywhere from 0 to 15 local vars
local_vars = []
for _i in range(flags_bitfield[0:3]):
var = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
local_vars.append(var)
log(f" Found {len(local_vars)} local vars")
# least recent to most recent stack values:
stack_values = []
for _i in range(evalstack_size):
val = (bytes[ptr] << 8) + bytes[ptr + 1]
ptr += 2
stack_values.append(val)
log(f" Found {len(stack_values)} local stack values")
### Interesting... the reconstructed stack frames have no 'start
### address'. I guess it doesn't matter, since we only need to
### pop back to particular return addresses to resume each
### routine.
### TODO: I can exactly which of the 7 args is "supplied", but I
### don't understand where the args *are*??
routine = zstackmanager.ZRoutine(
0, return_pc, self._zmachine._mem, [], local_vars, stack_values
)
stackmanager.push_routine(routine)
log(" Added new frame to stack.")
if ptr > total_len:
raise QuetzalStackFrameOverflow
self._zmachine._stackmanager = stackmanager
log(" Successfully installed all stack frames.")
def _parse_intd(self, data):
"""Parse a chunk of type IntD, which is interpreter-dependent info."""
log(" Begin parsing of interpreter-dependent metadata")
bytes = [ord(x) for x in data]
_os_id = bytes[0:3]
_flags = bytes[4]
_contents_id = bytes[5]
_reserved = bytes[6:8]
_interpreter_id = bytes[8:12]
_private_data = bytes[12:]
### TODO: finish this
# The following 3 chunks are totally optional metadata, and are
# artifacts of the larger IFF standard. We're not required to do
# anything when we see them, though maybe it would be nice to print
# them to the user?
def _parse_auth(self, data):
"""Parse a chunk of type AUTH. Display the author."""
log(f"Author of file: {data}")
self._last_loaded_metadata["author"] = data
def _parse_copyright(self, data):
"""Parse a chunk of type (c) . Display the copyright."""
log(f"Copyright: (C) {data}")
self._last_loaded_metadata["copyright"] = data
def _parse_anno(self, data):
"""Parse a chunk of type ANNO. Display any annotation"""
log(f"Annotation: {data}")
self._last_loaded_metadata["annotation"] = data
# --------- Public APIs -----------
def get_last_loaded(self):
"""Return a list of metadata about the last loaded Quetzal file, for
debugging and test verification."""
return self._last_loaded_metadata
def load(self, savefile_path):
"""Parse each chunk of the Quetzal file at SAVEFILE_PATH,
initializing associated zmachine subsystems as needed."""
self._last_loaded_metadata = {}
if not os.path.isfile(savefile_path):
raise QuetzalNoSuchSavefile
log(f"Attempting to load saved game from '{savefile_path}'")
self._file = open(savefile_path, "rb") # noqa: SIM115
# The python 'chunk' module is pretty dumb; it doesn't understand
# the FORM chunk and the way it contains nested chunks.
# Therefore, we deliberately seek 12 bytes into the file so that
# we can start sucking out chunks. This also allows us to
# validate that the FORM type is "IFZS".
header = self._file.read(4)
if header != b"FORM":
raise QuetzalUnrecognizedFileFormat
bytestring = self._file.read(4)
self._len = bytestring[0] << 24
self._len += bytestring[1] << 16
self._len += bytestring[2] << 8
self._len += bytestring[3]
log(f"Total length of FORM data is {self._len}")
self._last_loaded_metadata["total length"] = self._len
type = self._file.read(4)
if type != b"IFZS":
raise QuetzalUnrecognizedFileFormat
try:
while 1:
c = chunk.Chunk(self._file)
chunkname = c.getname()
chunksize = c.getsize()
data = c.read(chunksize)
log(f"** Found chunk ID {chunkname}: length {chunksize}")
self._last_loaded_metadata[chunkname] = chunksize
if chunkname == b"IFhd":
self._parse_ifhd(data)
elif chunkname == b"CMem":
self._parse_cmem(data)
elif chunkname == b"UMem":
self._parse_umem(data)
elif chunkname == b"Stks":
self._parse_stks(data)
elif chunkname == b"IntD":
self._parse_intd(data)
elif chunkname == b"AUTH":
self._parse_auth(data)
elif chunkname == b"(c) ":
self._parse_copyright(data)
elif chunkname == b"ANNO":
self._parse_anno(data)
else:
# spec says to ignore and skip past unrecognized chunks
pass
except EOFError:
pass
self._file.close()
log("Finished parsing Quetzal file.")
# ------------------------------------------------------------------------------
class QuetzalWriter:
"""A class to write the current state of a z-machine into a
Quetzal-format file."""
def __init__(self, zmachine):
log("Creating new instance of QuetzalWriter")
self._zmachine = zmachine
def _generate_ifhd_chunk(self):
"""Return a chunk of type IFhd, containing metadata about the
zmachine and story being played."""
### TODO: write this. payload must be *exactly* 13 bytes, even if
### it means padding the program counter.
### Some old infocom games don't have checksums stored in header.
### If not, generate it from the *original* story file memory
### image and put it into this chunk. See ZMemory.generate_checksum().
pass
return "0"
def _generate_cmem_chunk(self):
"""Return a compressed chunk of data representing the compressed
image of the zmachine's main memory."""
### TODO: debug this when ready
return "0"
# XOR the original game image with the current one
diffarray = list(self._zmachine._pristine_mem)
for index in range(len(self._zmachine._pristine_mem._total_size)):
diffarray[index] = (
self._zmachine._pristine_mem[index] ^ self._zmachine._mem[index]
)
log(f"XOR array is {diffarray}")
# Run-length encode the resulting list of 0's and 1's.
result = []
zerocounter = 0
for index in range(len(diffarray)):
if diffarray[index] == 0:
zerocounter += 1
continue
else:
if zerocounter > 0:
result.append(0)
result.append(zerocounter)
zerocounter = 0
result.append(diffarray[index])
return result
def _generate_stks_chunk(self):
"""Return a stacks chunk, describing the stack state of the
zmachine at this moment."""
### TODO: write this
return "0"
def _generate_anno_chunk(self):
"""Return an annotation chunk, containing metadata about the ZVM
interpreter which created the savefile."""
### TODO: write this
return "0"
# --------- Public APIs -----------
def write(self, savefile_path):
"""Write the current zmachine state to a new Quetzal-file at
SAVEFILE_PATH."""
log(f"Attempting to write game-state to '{savefile_path}'")
self._file = open(savefile_path, "w") # noqa: SIM115
ifhd_chunk = self._generate_ifhd_chunk()
cmem_chunk = self._generate_cmem_chunk()
stks_chunk = self._generate_stks_chunk()
anno_chunk = self._generate_anno_chunk()
_total_chunk_size = (
len(ifhd_chunk) + len(cmem_chunk) + len(stks_chunk) + len(anno_chunk)
)
# Write main FORM chunk to hold other chunks
self._file.write("FORM")
### TODO: self._file_write(total_chunk_size) -- spread it over 4 bytes
self._file.write("IFZS")
# Write nested chunks.
for chunk_data in (ifhd_chunk, cmem_chunk, stks_chunk, anno_chunk):
self._file.write(chunk_data)
log("Wrote a chunk.")
self._file.close()
log("Done writing game-state to savefile.")

View file

@ -0,0 +1,406 @@
#
# A trivial user interface for a Z-Machine that uses (mostly) stdio for
# everything and supports little to no optional features.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# TODO: There are a few edge-cases in this UI implementation in
# regards to word-wrapping. For example, if keyboard input doesn't
# terminate in a newline, then word-wrapping can be temporarily thrown
# off; the text I/O performed by the audio and filesystem doesn't
# really communicate with the screen object, which means that
# operations performed by them can temporarily throw off word-wrapping
# as well.
import sys
from functools import reduce
from . import zaudio, zfilesystem, zscreen, zstream, zui
from .zlogging import log
class TrivialAudio(zaudio.ZAudio):
def __init__(self):
zaudio.ZAudio.__init__(self)
self.features = {
"has_more_than_a_bleep": False,
}
def play_bleep(self, bleep_type):
if bleep_type == zaudio.BLEEP_HIGH:
sys.stdout.write("AUDIO: high-pitched bleep\n")
elif bleep_type == zaudio.BLEEP_LOW:
sys.stdout.write("AUDIO: low-pitched bleep\n")
else:
raise AssertionError(f"Invalid bleep_type: {str(bleep_type)}")
class TrivialScreen(zscreen.ZScreen):
def __init__(self):
zscreen.ZScreen.__init__(self)
self.__styleIsAllUppercase = False
# Current column of text being printed.
self.__curr_column = 0
# Number of rows displayed since we last took input; needed to
# keep track of when we need to display the [MORE] prompt.
self.__rows_since_last_input = 0
def split_window(self, height):
log(f"TODO: split window here to height {height}")
def select_window(self, window):
log(f"TODO: select window {window} here")
def set_cursor_position(self, x, y):
log(f"TODO: set cursor position to ({x},{y}) here")
def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT):
for _row in range(self._rows):
sys.stdout.write("\n")
self.__curr_column = 0
self.__rows_since_last_input = 0
def set_font(self, font_number):
if font_number == zscreen.FONT_NORMAL:
return font_number
else:
# We aren't going to support anything but the normal font.
return None
def set_text_style(self, style):
# We're pretty much limited to stdio here; even if we might be
# able to use terminal hackery under Unix, supporting styled text
# in a Windows console is problematic [1]. The closest thing we
# can do is have our "bold" style be all-caps, so we'll do that.
#
# [1] http://mail.python.org/pipermail/tutor/2004-February/028474.html
if style == zscreen.STYLE_BOLD:
self.__styleIsAllUppercase = True
else:
self.__styleIsAllUppercase = False
def __show_more_prompt(self):
"""Display a [MORE] prompt, wait for the user to press a key, and
then erase the [MORE] prompt, leaving the cursor at the same
position that it was at before the call was made."""
assert self.__curr_column == 0, "Precondition: current column must be zero."
MORE_STRING = "[MORE]"
sys.stdout.write(MORE_STRING)
_read_char()
# Erase the [MORE] prompt and reset the cursor position.
sys.stdout.write(f"\r{' ' * len(MORE_STRING)}\r")
self.__rows_since_last_input = 0
def on_input_occurred(self, newline_occurred=False):
"""Callback function that should be called whenever keyboard input
has occurred; this is so we can keep track of when we need to
display a [MORE] prompt."""
self.__rows_since_last_input = 0
if newline_occurred:
self.__curr_column = 0
def __unbuffered_write(self, string):
"""Write the given string, inserting newlines at the end of
columns as appropriate, and displaying [MORE] prompts when
appropriate. This function does not perform word-wrapping."""
for char in string:
newline_printed = False
sys.stdout.write(char)
sys.stdout.flush()
if char == "\n":
newline_printed = True
else:
self.__curr_column += 1
if self.__curr_column == self._columns:
sys.stdout.write("\n")
newline_printed = True
if newline_printed:
self.__rows_since_last_input += 1
self.__curr_column = 0
if (
self.__rows_since_last_input == self._rows
and self._rows != zscreen.INFINITE_ROWS
):
self.__show_more_prompt()
def write(self, string):
if self.__styleIsAllUppercase:
# Apply our fake "bold" transformation.
string = string.upper()
if self.buffer_mode:
# This is a hack to get words to wrap properly, based on our
# current cursor position.
# First, add whitespace padding up to the column of text that
# we're at.
string = (" " * self.__curr_column) + string
# Next, word wrap our current string.
string = _word_wrap(string, self._columns - 1)
# Now remove the whitespace padding.
string = string[self.__curr_column :]
self.__unbuffered_write(string)
class TrivialKeyboardInputStream(zstream.ZInputStream):
def __init__(self, screen):
zstream.ZInputStream.__init__(self)
self.__screen = screen
self.features = {
"has_timed_input": False,
}
def read_line(
self,
original_text=None,
max_length=0,
terminating_characters=None,
timed_input_routine=None,
timed_input_interval=0,
):
result = _read_line(original_text, terminating_characters)
if max_length > 0:
result = result[:max_length]
# TODO: The value of 'newline_occurred' here is not accurate,
# because terminating_characters may include characters other than
# carriage return.
self.__screen.on_input_occurred(newline_occurred=True)
return str(result)
def read_char(self, timed_input_routine=None, timed_input_interval=0):
result = _read_char()
self.__screen.on_input_occurred()
return ord(result)
class TrivialFilesystem(zfilesystem.ZFilesystem):
def __report_io_error(self, exception):
sys.stdout.write(f"FILESYSTEM: An error occurred: {exception}\n")
def save_game(self, data, suggested_filename=None):
success = False
sys.stdout.write("Enter a name for the saved game (hit enter to cancel): ")
filename = _read_line(suggested_filename)
if filename:
try:
with open(filename, "wb") as file_obj:
file_obj.write(data)
success = True
except OSError as e:
self.__report_io_error(e)
return success
def restore_game(self):
data = None
sys.stdout.write(
"Enter the name of the saved game to restore (hit enter to cancel): "
)
filename = _read_line()
if filename:
try:
with open(filename, "rb") as file_obj:
data = file_obj.read()
except OSError as e:
self.__report_io_error(e)
return data
def open_transcript_file_for_writing(self):
file_obj = None
sys.stdout.write("Enter a name for the transcript file (hit enter to cancel): ")
filename = _read_line()
if filename:
try:
file_obj = open(filename, "w") # noqa: SIM115
except OSError as e:
self.__report_io_error(e)
return file_obj
def open_transcript_file_for_reading(self):
file_obj = None
sys.stdout.write(
"Enter the name of the transcript file to read (hit enter to cancel): "
)
filename = _read_line()
if filename:
try:
file_obj = open(filename) # noqa: SIM115
except OSError as e:
self.__report_io_error(e)
return file_obj
def create_zui():
"""Creates and returns a ZUI instance representing a trivial user
interface."""
audio = TrivialAudio()
screen = TrivialScreen()
keyboard_input = TrivialKeyboardInputStream(screen)
filesystem = TrivialFilesystem()
return zui.ZUI(audio, screen, keyboard_input, filesystem)
# Keyboard input functions
_INTERRUPT_CHAR = chr(3)
_BACKSPACE_CHAR = chr(8)
_DELETE_CHAR = chr(127)
def _win32_read_char():
"""Win32-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
import msvcrt
return str(msvcrt.getch()) # type: ignore[possibly-missing-attribute]
def _unix_read_char():
"""Unix-specific function that reads a character of input from the
keyboard and returns it without printing it to the screen."""
# This code was excised from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/134892
import termios
import tty
fd = sys.stdin.fileno()
# Check if stdin is a TTY - if not, use simple read
if not sys.stdin.isatty():
return sys.stdin.read(1)
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return str(ch)
def _read_char():
"""Reads a character of input from the keyboard and returns it
without printing it to the screen."""
if sys.platform == "win32":
_platform_read_char = _win32_read_char
else:
# We're not running on Windows, so assume we're running on Unix.
_platform_read_char = _unix_read_char
char = _platform_read_char()
if char == _INTERRUPT_CHAR:
raise KeyboardInterrupt()
else:
return char
def _read_line(original_text=None, terminating_characters=None):
"""Reads a line of input with the given unicode string of original
text, which is editable, and the given unicode string of terminating
characters (used to terminate text input). By default,
terminating_characters is a string containing the carriage return
character ('\r')."""
if original_text is None:
original_text = ""
if not terminating_characters:
terminating_characters = "\r"
assert isinstance(original_text, str)
assert isinstance(terminating_characters, str)
# If stdin is not a TTY, use simple line reading
if not sys.stdin.isatty():
line = sys.stdin.readline()
if not line: # EOF
raise EOFError("End of input")
# Strip newline but keep the content
return line.rstrip("\n\r")
chars_entered = len(original_text)
sys.stdout.write(original_text)
string = original_text
finished = False
while not finished:
char = _read_char()
if char in (_BACKSPACE_CHAR, _DELETE_CHAR):
if chars_entered > 0:
chars_entered -= 1
string = string[:-1]
else:
continue
elif char in terminating_characters:
finished = True
else:
string += char
chars_entered += 1
if char == "\r":
char_to_print = "\n"
elif char in (_BACKSPACE_CHAR, _DELETE_CHAR):
char_to_print = f"{_BACKSPACE_CHAR} {_BACKSPACE_CHAR}"
else:
char_to_print = char
sys.stdout.write(char_to_print)
sys.stdout.flush()
return string
# Word wrapping helper function
def _word_wrap(text, width):
"""
A word-wrap function that preserves existing line breaks
and most spaces in the text. Expects that existing line
breaks are posix newlines (\n).
"""
# This code was taken from:
# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/148061
return reduce(
lambda line, word, width=width: "{}{}{}".format(
line,
" \n"[
(
len(line) - line.rfind("\n") - 1 + len(word.split("\n", 1)[0])
>= width
)
],
word,
),
text.split(" "),
)

View file

@ -0,0 +1,76 @@
#
# A template class representing the audio interface of a z-machine.
#
# Third-party programs are expected to subclass ZAudio and override
# all the methods, then pass an instance of their class to be driven
# by the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Constants for simple bleeps. These are human-readable names for the
# first two sound effect numbers for the Z-Machine's 'sound_effect'
# opcode.
BLEEP_HIGH = 1
BLEEP_LOW = 2
# Constants for sound effects. These are human-readable names for
# the 'effect' operand of the Z-Machine's 'sound_effect' opcode.
EFFECT_PREPARE = 1
EFFECT_START = 2
EFFECT_STOP = 3
EFFECT_FINISH = 4
class ZAudio:
def __init__(self):
"""Constructor of the audio system."""
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_more_than_a_bleep": False,
}
def play_bleep(self, bleep_type):
"""Plays a bleep sound of the given type:
BLEEP_HIGH - a high-pitched bleep
BLEEP_LOW - a low-pitched bleep
"""
raise NotImplementedError()
def play_sound_effect(self, id, effect, volume, repeats, routine=None):
"""The given effect happens to the given sound number. The id
must be 3 or above is supplied by the ZAudio object for the
particular game in question.
The effect can be:
EFFECT_PREPARE - prepare a sound effect for playing
EFFECT_START - start a sound effect
EFFECT_STOP - stop a sound effect
EFFECT_FINISH - finish a sound effect
The volume is an integer from 1 to 8 (8 being loudest of
these). The volume level -1 means 'loudest possible'.
The repeats specify how many times for the sound to repeatedly
play itself, if it is provided.
The routine, if supplied, is a Python function that will be called
once the sound has finished playing. Note that this routine may
be called from any thread. The routine should have the following
form:
def on_sound_finished(id)
where 'id' is the id of the sound that finished playing.
This method should only be implemented if the
has_more_than_a_bleep feature is enabled."""
raise NotImplementedError()

1040
src/mudlib/zmachine/zcpu.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
#
# A template class representing the interactions that the end-user has
# with the filesystem in a z-machine.
#
# Third-party programs are expected to subclass ZFilesystem and
# override all the methods, then pass an instance of their class to be
# driven by the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class ZFilesystem:
"""Encapsulates the interactions that the end-user has with the
filesystem."""
def save_game(self, data, suggested_filename=None):
"""Prompt for a filename (possibly using suggested_filename), and
attempt to write DATA as a saved-game file. Return True on
success, False on failure.
Note that file-handling errors such as 'disc corrupt' and 'disc
full' should be reported directly to the player by the method in
question method, and they should also cause this function to
return False. If the user clicks 'cancel' or its equivalent,
this function should return False."""
raise NotImplementedError()
def restore_game(self):
"""Prompt for a filename, and return file's contents. (Presumably
the interpreter will attempt to use those contents to restore a
saved game.) Returns None on failure.
Note that file-handling errors such as 'disc corrupt' and 'disc
full' should be reported directly to the player by the method in
question method, and they should also cause this function to
return None. The error 'file not found' should cause this function
to return None. If the user clicks 'cancel' or its equivalent,
this function should return None."""
raise NotImplementedError()
def open_transcript_file_for_writing(self):
"""Prompt for a filename in which to save either a full game
transcript or just a list of the user's commands. Return standard
python file object that can be written to.
If an error occurs, or if the user clicks 'cancel' or its
equivalent, return None."""
raise NotImplementedError()
def open_transcript_file_for_reading(self):
"""Prompt for a filename contain user commands, which can be used
to drive the interpreter. Return standard python file object that
can be read from.
If an error occurs, or if the user clicks 'cancel' or its
equivalent, return None."""
raise NotImplementedError()

View file

@ -0,0 +1,136 @@
#
# A class for parsing word dictionaries and performing lexical
# analysis of user input. (See section 13 of the z-machine spec.)
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import re
from .zstring import ZsciiTranslator, ZStringFactory
class ZLexerError(Exception):
"General exception for ZLexer class"
# Note that the specification describes tokenisation as a process
# whereby the user's input is divided into words, each word converted
# to a z-string, then searched for in the 'standard' dictionary. This
# is really inefficient. Therefore, because the standard dictionary
# is immutable (lives in static memory), this class parses and loads
# it *once* into a private python dictionary. We can then forever do
# O(1) lookups of unicode words, rather than O(N) lookups of
# zscii-encoded words.
# Note that the main API here (tokenise_input()) can work with any
# dictionary, not just the standard one.
class ZLexer:
def __init__(self, mem):
self._memory = mem
self._stringfactory = ZStringFactory(self._memory)
self._zsciitranslator = ZsciiTranslator(self._memory)
# Load and parse game's 'standard' dictionary from static memory.
dict_addr = self._memory.read_word(0x08)
self._num_entries, self._entry_length, self._separators, entries_addr = (
self._parse_dict_header(dict_addr)
)
self._dict = self.get_dictionary(dict_addr)
def _parse_dict_header(self, address):
"""Parse the header of the dictionary at ADDRESS. Return the
number of entries, the length of each entry, a list of zscii
word separators, and an address of the beginning the entries."""
addr = address
num_separators = self._memory[addr]
separators = self._memory[(addr + 1) : (addr + num_separators)]
addr += 1 + num_separators
entry_length = self._memory[addr]
addr += 1
num_entries = self._memory.read_word(addr)
addr += 2
return num_entries, entry_length, separators, addr
def _tokenise_string(self, string, separators):
"""Split unicode STRING into a list of words, and return the list.
Whitespace always counts as a word separator, but so do any
unicode characters provided in the list of SEPARATORS. Note,
however, that instances of these separators caunt as words
themselves."""
# re.findall(r'[,.;]|\w+', 'abc, def')
sep_string = ""
for sep in separators:
sep_string += sep
regex = r"\w+" if sep_string == "" else rf"[{sep_string}]|\w+"
return re.findall(regex, string)
# --------- Public APIs -----------
def get_dictionary(self, address):
"""Load a z-machine-format dictionary at ADDRESS -- which maps
zstrings to bytestrings -- into a python dictionary which maps
unicode strings to the address of the word in the original
dictionary. Return the new dictionary."""
dict = {}
num_entries, entry_length, separators, addr = self._parse_dict_header(address)
for _i in range(0, num_entries):
text_key = self._stringfactory.get(addr)
dict[text_key] = addr
addr += entry_length
return dict
def parse_input(self, string, dict_addr=None):
"""Given a unicode string, parse it into words based on a dictionary.
if DICT_ADDR is provided, use the custom dictionary at that
address to do the analysis, otherwise default to using the game's
'standard' dictionary.
The dictionary plays two roles: first, it specifies separator
characters beyond the usual space character. Second, we need to
look up each word in the dictionary and return the address.
Return a list of lists, each list being of the form
[word, byte_address_of_word_in_dictionary (or 0 if not in dictionary)]
"""
if dict_addr is None:
zseparators = self._separators
dict = self._dict
else:
num_entries, entry_length, zseparators, addr = self._parse_dict_header(
dict_addr
)
dict = self.get_dictionary(dict_addr)
# Our list of word separators are actually zscii codes that must
# be converted to unicode before we can use them.
separators = []
for code in zseparators:
separators.append(self._zsciitranslator.ztou(code))
token_list = self._tokenise_string(string, separators)
# Truncate to dictionary resolution (6 chars V1-3, 9 chars V4+)
max_word_len = 6 if self._memory.version <= 3 else 9
final_list = []
for word in token_list:
lookup_key = word[:max_word_len]
byte_addr = dict.get(lookup_key, 0)
final_list.append([word, byte_addr])
return final_list

View file

@ -0,0 +1,46 @@
#
# Logging assistance. This provides a logging facility for the rest of
# the Z-Machine. As a Z-Machine is inherently I/O intensive, dumb screen
# dumping is no longer adequate. This logging facility, based on
# python's logging module, provides file logging.
#
import logging
# Top-level initialization
logging.getLogger().setLevel(logging.DEBUG)
# Create the logging objects regardless. If debugmode is False, then
# they won't actually do anything when used.
mainlog_handler = logging.FileHandler("debug.log", "a")
mainlog_handler.setLevel(logging.DEBUG)
mainlog_handler.setFormatter(logging.Formatter("%(asctime)s: %(message)s"))
logging.getLogger("mainlog").addHandler(mainlog_handler)
# We'll store the disassembly in a separate file, for better
# readability.
disasm_handler = logging.FileHandler("disasm.log", "a")
disasm_handler.setLevel(logging.DEBUG)
disasm_handler.setFormatter(logging.Formatter("%(message)s"))
logging.getLogger("disasm").addHandler(disasm_handler)
mainlog = logging.getLogger("mainlog")
mainlog.info("*** Log reopened ***")
disasm = logging.getLogger("disasm")
disasm.info("*** Log reopened ***")
# Pubilc routines used by other modules
def set_debug(state):
if state:
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.getLogger().setLevel(logging.CRITICAL)
def log(msg):
mainlog.debug(msg)
def log_disasm(pc, opcode_type, opcode_num, opcode_name, args):
disasm.debug(f"{pc:06x} {opcode_type}:{opcode_num:02x} {opcode_name} {args}")

View file

@ -0,0 +1,52 @@
# The Z-Machine black box. It initializes the whole Z computer, loads
# a story, and starts execution of the cpu.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zlogging
from .zcpu import ZCpu
from .zlexer import ZLexer
from .zmemory import ZMemory
from .zobjectparser import ZObjectParser
from .zopdecoder import ZOpDecoder
from .zstackmanager import ZStackManager
from .zstreammanager import ZStreamManager
from .zstring import ZStringFactory
class ZMachineError(Exception):
"""General exception for ZMachine class"""
class ZMachine:
"""The Z-Machine black box."""
def __init__(self, story, ui, debugmode=False):
zlogging.set_debug(debugmode)
self._pristine_mem = ZMemory(story) # the original memory image
self._mem = ZMemory(story) # the memory image which changes during play
self._stringfactory = ZStringFactory(self._mem)
self._objectparser = ZObjectParser(self._mem)
self._stackmanager = ZStackManager(self._mem)
self._opdecoder = ZOpDecoder(self._mem, self._stackmanager)
self._opdecoder.program_counter = self._mem.read_word(0x06)
self._ui = ui
self._stream_manager = ZStreamManager(self._mem, self._ui)
self._lexer = ZLexer(self._mem)
self._cpu = ZCpu(
self._mem,
self._opdecoder,
self._stackmanager,
self._objectparser,
self._stringfactory,
self._stream_manager,
self._ui,
self._lexer,
)
# --------- Public APIs -----------
def run(self):
return self._cpu.run()

View file

@ -0,0 +1,338 @@
#
# A class which represents the z-machine's main memory bank.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from .zlogging import log
# This class that represents the "main memory" of the z-machine. It's
# readable and writable through normal indexing and slice notation,
# just like a typical python 'sequence' object (e.g. mem[342] and
# mem[22:90]). The class validates memory layout, enforces read-only
# areas of memory, and also the ability to return both word-addresses
# and 'packed' addresses.
class ZMemoryError(Exception):
"General exception for ZMemory class"
pass
class ZMemoryIllegalWrite(ZMemoryError):
"Tried to write to a read-only part of memory"
def __init__(self, address):
super().__init__(f"Illegal write to address {address}")
class ZMemoryBadInitialization(ZMemoryError):
"Failure to initialize ZMemory class"
pass
class ZMemoryOutOfBounds(ZMemoryError):
"Accessed an address beyond the bounds of memory."
pass
class ZMemoryBadMemoryLayout(ZMemoryError):
"Static plus dynamic memory exceeds 64k"
pass
class ZMemoryBadStoryfileSize(ZMemoryError):
"Story is too large for Z-machine version."
pass
class ZMemoryUnsupportedVersion(ZMemoryError):
"Unsupported version of Z-story file."
pass
class ZMemory:
# A list of 64 tuples describing who's allowed to tweak header-bytes.
# Index into the list is the header-byte being tweaked.
# List value is a tuple of the form
#
# [minimum_z_version, game_allowed, interpreter_allowed]
#
# Note: in section 11.1 of the spec, we should technically be
# enforcing authorization by *bit*, not by byte. Maybe do this
# someday.
HEADER_PERMS = (
[1, 0, 0],
[3, 0, 1],
None,
None,
[1, 0, 0],
None,
[1, 0, 0],
None,
[1, 0, 0],
None,
[1, 0, 0],
None,
[1, 0, 0],
None,
[1, 0, 0],
None,
[1, 1, 1],
[1, 1, 1],
None,
None,
None,
None,
None,
None,
[2, 0, 0],
None,
[3, 0, 0],
None,
[3, 0, 0],
None,
[4, 1, 1],
[4, 1, 1],
[4, 0, 1],
[4, 0, 1],
[5, 0, 1],
None,
[5, 0, 1],
None,
[5, 0, 1],
[5, 0, 1],
[6, 0, 0],
None,
[6, 0, 0],
None,
[5, 0, 1],
[5, 0, 1],
[5, 0, 0],
None,
[6, 0, 1],
None,
[1, 0, 1],
None,
[5, 0, 0],
None,
[5, 0, 0],
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
def __init__(self, initial_string):
"""Construct class based on a string that represents an initial
'snapshot' of main memory."""
if initial_string is None:
raise ZMemoryBadInitialization
# Copy string into a _memory sequence that represents main memory.
self._total_size = len(initial_string)
self._memory = bytearray(initial_string)
# Figure out the different sections of memory
self._static_start = self.read_word(0x0E)
self._static_end = min(0x0FFFF, self._total_size)
self._dynamic_start = 0
self._dynamic_end = self._static_start - 1
self._high_start = self.read_word(0x04)
self._high_end = self._total_size
self._global_variable_start = self.read_word(0x0C)
# Dynamic + static must not exceed 64k
dynamic_plus_static = (self._dynamic_end - self._dynamic_start) + (
self._static_end - self._static_start
)
if dynamic_plus_static > 65534:
raise ZMemoryBadMemoryLayout
# What z-machine version is this story file?
self.version = self._memory[0]
# Validate game size
if 1 <= self.version <= 3:
if self._total_size > 131072:
raise ZMemoryBadStoryfileSize
elif 4 <= self.version <= 5:
if self._total_size > 262144:
raise ZMemoryBadStoryfileSize
else:
raise ZMemoryUnsupportedVersion
log("Memory system initialized, map follows")
log(f" Dynamic memory: {self._dynamic_start:x} - {self._dynamic_end:x}")
log(f" Static memory: {self._static_start:x} - {self._static_end:x}")
log(f" High memory: {self._high_start:x} - {self._high_end:x}")
log(f" Global variable start: {self._global_variable_start:x}")
def _check_bounds(self, index):
if isinstance(index, slice):
start, stop = index.start, index.stop
else:
start, stop = index, index
if not ((0 <= start < self._total_size) and (0 <= stop < self._total_size)):
raise ZMemoryOutOfBounds
def _check_static(self, index):
"""Throw error if INDEX is within the static-memory area."""
if isinstance(index, slice):
start, stop = index.start, index.stop
else:
start, stop = index, index
if (
self._static_start <= start <= self._static_end
and self._static_start <= stop <= self._static_end
):
raise ZMemoryIllegalWrite(index)
def print_map(self):
"""Pretty-print a description of the memory map."""
print("Dynamic memory: ", self._dynamic_start, "-", self._dynamic_end)
print(" Static memory: ", self._static_start, "-", self._static_end)
print(" High memory: ", self._high_start, "-", self._high_end)
def __getitem__(self, index):
"""Return the byte value stored at address INDEX.."""
self._check_bounds(index)
return self._memory[index]
def __setitem__(self, index, value):
"""Set VALUE in memory address INDEX."""
self._check_bounds(index)
self._check_static(index)
self._memory[index] = value
def __getslice__(self, start, end):
"""Return a sequence of bytes from memory."""
self._check_bounds(start)
self._check_bounds(end)
return self._memory[start:end]
def __setslice__(self, start, end, sequence):
"""Set a range of memory addresses to SEQUENCE."""
self._check_bounds(start)
self._check_bounds(end - 1)
self._check_static(start)
self._check_static(end - 1)
self._memory[start:end] = sequence
def word_address(self, address):
"""Return the 'actual' address of word address ADDRESS."""
if address < 0 or address > (self._total_size // 2):
raise ZMemoryOutOfBounds
return address * 2
def packed_address(self, address):
"""Return the 'actual' address of packed address ADDRESS."""
if 1 <= self.version <= 3:
if address < 0 or address > (self._total_size // 2):
raise ZMemoryOutOfBounds
return address * 2
elif 4 <= self.version <= 5:
if address < 0 or address > (self._total_size // 4):
raise ZMemoryOutOfBounds
return address * 4
else:
raise ZMemoryUnsupportedVersion
def read_word(self, address):
"""Return the 16-bit value stored at ADDRESS, ADDRESS+1."""
if address < 0 or address >= (self._total_size - 1):
raise ZMemoryOutOfBounds
return (self._memory[address] << 8) + self._memory[(address + 1)]
def write_word(self, address, value):
"""Write the given 16-bit value at ADDRESS, ADDRESS+1.
_check_static() is sufficient for permission checking because the
dynamic memory region (which includes the header) is always writable
by the game per Z-machine spec.
"""
if address < 0 or address >= (self._total_size - 1):
raise ZMemoryOutOfBounds
value_msb = (value >> 8) & 0xFF
value_lsb = value & 0xFF
self._check_static(address)
self._check_static(address + 1)
self._memory[address] = value_msb
self._memory[address + 1] = value_lsb
# Normal sequence syntax cannot be used to set bytes in the 64-byte
# header. Instead, the interpreter or game must call one of the
# following APIs.
def interpreter_set_header(self, address, value):
"""Possibly allow the interpreter to set header ADDRESS to VALUE."""
if address < 0 or address > 63:
raise ZMemoryOutOfBounds
perm_tuple = self.HEADER_PERMS[address]
if perm_tuple is None:
raise ZMemoryIllegalWrite(address)
if self.version >= perm_tuple[0] and perm_tuple[2]:
self._memory[address] = value
else:
raise ZMemoryIllegalWrite(address)
def game_set_header(self, address, value):
"""Possibly allow the game code to set header ADDRESS to VALUE."""
if address < 0 or address > 63:
raise ZMemoryOutOfBounds
perm_tuple = self.HEADER_PERMS[address]
if perm_tuple is None:
raise ZMemoryIllegalWrite(address)
if self.version >= perm_tuple[0] and perm_tuple[1]:
self._memory[address] = value
else:
raise ZMemoryIllegalWrite(address)
# The ZPU will need to read and write global variables. The 240
# global variables are located at a place determined by the header.
def read_global(self, varnum):
"""Return 16-bit value of global variable VARNUM. Incoming VARNUM
must be between 0x10 and 0xFF."""
if not (0x10 <= varnum <= 0xFF):
raise ZMemoryOutOfBounds
actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
return self.read_word(actual_address)
def write_global(self, varnum, value):
"""Write 16-bit VALUE to global variable VARNUM. Incoming VARNUM
must be between 0x10 and 0xFF."""
if not (0x10 <= varnum <= 0xFF):
raise ZMemoryOutOfBounds
if not (0x00 <= value <= 0xFFFF):
raise ZMemoryIllegalWrite(value)
log(f"Write {value} to global variable {varnum}")
actual_address = self._global_variable_start + ((varnum - 0x10) * 2)
self._memory[actual_address] = (value >> 8) & 0xFF
self._memory[actual_address + 1] = value & 0xFF
# The 'verify' opcode and the QueztalWriter class both need to have
# a checksum of memory generated.
def generate_checksum(self):
"""Return a checksum value which represents all the bytes of
memory added from $0040 upwards, modulo $10000."""
count = 0x40
total = 0
while count < self._total_size:
total += self._memory[count]
count += 1
return total % 0x10000

View file

@ -0,0 +1,591 @@
#
# A class which knows how to parse objects in the object tree.
# Implements section 12 of Z-code specification.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# This part of of the z-machine is where it becomes really clear that
# the original authoris were MIT Lisp-heads. :-) They've got a tree
# of objects going, where each object is basically a linked list of
# siblings. Specifically, each object contains a pointer to a parent,
# a pointer to its "next sibling" in the list, and a pointer to the
# head of its own children-list.
from .bitfield import BitField
from .zlogging import log
from .zstring import ZStringFactory
class ZObjectError(Exception):
"General exception for ZObject class"
pass
class ZObjectIllegalObjectNumber(ZObjectError):
"Illegal object number given."
pass
class ZObjectIllegalAttributeNumber(ZObjectError):
"Illegal attribute number given."
pass
class ZObjectIllegalPropertyNumber(ZObjectError):
"Illegal property number given."
pass
class ZObjectIllegalPropertySet(ZObjectError):
"Illegal set of a property whose size is not 1 or 2."
pass
class ZObjectIllegalVersion(ZObjectError):
"Unsupported z-machine version."
pass
class ZObjectIllegalPropLength(ZObjectError):
"Illegal property length."
pass
class ZObjectMalformedTree(ZObjectError):
"Object tree is malformed."
pass
# The interpreter should only need exactly one instance of this class.
class ZObjectParser:
def __init__(self, zmem):
self._memory = zmem
self._propdefaults_addr = zmem.read_word(0x0A)
self._stringfactory = ZStringFactory(self._memory)
if 1 <= self._memory.version <= 3:
self._objecttree_addr = self._propdefaults_addr + 62
elif 4 <= self._memory.version <= 5:
self._objecttree_addr = self._propdefaults_addr + 126
else:
raise ZObjectIllegalVersion
def _get_object_addr(self, objectnum):
"""Return address of object number OBJECTNUM."""
result = 0
if 1 <= self._memory.version <= 3:
if not (1 <= objectnum <= 255):
raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (9 * (objectnum - 1))
elif 4 <= self._memory.version <= 5:
if not (1 <= objectnum <= 65535):
log(f"error: there is no object {objectnum}")
raise ZObjectIllegalObjectNumber
result = self._objecttree_addr + (14 * (objectnum - 1))
else:
raise ZObjectIllegalVersion
log(f"address of object {objectnum} is {result}")
return result
def _get_parent_sibling_child(self, objectnum):
"""Return [parent, sibling, child] object numbers of object OBJECTNUM."""
addr = self._get_object_addr(objectnum)
result = 0
if 1 <= self._memory.version <= 3:
addr += 4 # skip past attributes
result = self._memory[addr : addr + 3]
elif 4 <= self._memory.version <= 5:
addr += 6 # skip past attributes
result = [
self._memory.read_word(addr),
self._memory.read_word(addr + 2),
self._memory.read_word(addr + 4),
]
else:
raise ZObjectIllegalVersion
log(
f"parent/sibling/child of object {objectnum} is "
f"{result[0]}, {result[1]}, {result[2]}"
)
return result
def _get_proptable_addr(self, objectnum):
"""Return address of property table of object OBJECTNUM."""
addr = self._get_object_addr(objectnum)
# skip past attributes and relatives
if 1 <= self._memory.version <= 3:
addr += 7
elif 4 <= self._memory.version <= 5:
addr += 12
else:
raise ZObjectIllegalVersion
return self._memory.read_word(addr)
def _get_default_property_addr(self, propnum):
"""Return address of default value for property PROPNUM."""
addr = self._propdefaults_addr
if 1 <= self._memory.version <= 3:
if not (1 <= propnum <= 31):
raise ZObjectIllegalPropertyNumber
elif 4 <= self._memory.version <= 5:
if not (1 <= propnum <= 63):
raise ZObjectIllegalPropertyNumber
else:
raise ZObjectIllegalVersion
return addr + (2 * (propnum - 1))
# --------- Public APIs -----------
def get_attribute(self, objectnum, attrnum):
"""Return value (0 or 1) of attribute number ATTRNUM of object
number OBJECTNUM."""
object_addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
if not (0 <= attrnum <= 31):
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)])
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
bf = BitField(self._memory[object_addr + (attrnum // 8)])
else:
raise ZObjectIllegalVersion
return bf[7 - (attrnum % 8)]
def set_attribute(self, objectnum, attrnum):
"""Set attribute number ATTRNUM of object number OBJECTNUM to 1."""
object_addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
if not (0 <= attrnum <= 31):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
else:
raise ZObjectIllegalVersion
bf[7 - (attrnum % 8)] = 1
self._memory[object_addr + byte_offset] = int(bf)
def clear_attribute(self, objectnum, attrnum):
"""Clear attribute number ATTRNUM of object number OBJECTNUM to 0."""
object_addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
if not (0 <= attrnum <= 31):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
elif 4 <= self._memory.version <= 5:
if not (0 <= attrnum <= 47):
raise ZObjectIllegalAttributeNumber
byte_offset = attrnum // 8
bf = BitField(self._memory[object_addr + byte_offset])
else:
raise ZObjectIllegalVersion
bf[7 - (attrnum % 8)] = 0
self._memory[object_addr + byte_offset] = int(bf)
def get_all_attributes(self, objectnum):
"""Return a list of all attribute numbers that are set on object
OBJECTNUM"""
if 1 <= self._memory.version <= 3:
max = 32
elif 4 <= self._memory.version <= 5:
max = 48
else:
raise ZObjectIllegalVersion
# really inefficient, but who cares?
attrs = []
for i in range(0, max):
if self.get_attribute(objectnum, i):
attrs.append(i)
return attrs
def get_parent(self, objectnum):
"""Return object number of parent of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return parent
def get_child(self, objectnum):
"""Return object number of child of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return child
def get_sibling(self, objectnum):
"""Return object number of sibling of object number OBJECTNUM."""
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return sibling
def set_parent(self, objectnum, new_parent_num):
"""Make OBJECTNUM's parent pointer point to NEW_PARENT_NUM."""
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 4] = new_parent_num
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 6, new_parent_num)
else:
raise ZObjectIllegalVersion
def set_child(self, objectnum, new_child_num):
"""Make OBJECTNUM's child pointer point to NEW_PARENT_NUM."""
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 6] = new_child_num
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 10, new_child_num)
else:
raise ZObjectIllegalVersion
def set_sibling(self, objectnum, new_sibling_num):
"""Make OBJECTNUM's sibling pointer point to NEW_PARENT_NUM."""
addr = self._get_object_addr(objectnum)
if 1 <= self._memory.version <= 3:
self._memory[addr + 5] = new_sibling_num
elif 4 <= self._memory.version <= 5:
self._memory.write_word(addr + 8, new_sibling_num)
else:
raise ZObjectIllegalVersion
def remove_object(self, objectnum):
"""Detach object OBJECTNUM from its parent (unlink from sibling chain)."""
parent = self.get_parent(objectnum)
if parent == 0:
# Object has no parent, nothing to remove
return
sibling = self.get_sibling(objectnum)
# Check if this object is the first child
if self.get_child(parent) == objectnum:
# Make sibling the new first child
self.set_child(parent, sibling)
else:
# Walk the sibling chain to find the object before this one
prev = self.get_child(parent)
current = self.get_sibling(prev)
while current != 0:
if current == objectnum:
# Link prev to our sibling, removing us from chain
self.set_sibling(prev, sibling)
break
prev = current
current = self.get_sibling(current)
else:
# Shouldn't happen - object claimed parent but not in chain
raise ZObjectMalformedTree
# Clear this object's parent
self.set_parent(objectnum, 0)
self.set_sibling(objectnum, 0)
def insert_object(self, parent_object, new_child):
"""Prepend object NEW_CHILD to the list of PARENT_OBJECT's children."""
# Remember all the original pointers within the new_child
[p, s, c] = self._get_parent_sibling_child(new_child)
# First insert new_child intto the parent_object
original_child = self.get_child(parent_object)
self.set_sibling(new_child, original_child)
self.set_parent(new_child, parent_object)
self.set_child(parent_object, new_child)
if p == 0: # no need to 'remove' new_child, since it wasn't in a tree
return
# Hunt down and remove the new_child from its old location
item = self.get_child(p)
if item == 0:
# new_object claimed to have parent p, but p has no children!?
raise ZObjectMalformedTree
elif item == new_child: # done! new_object was head of list
self.set_child(p, s) # note that s might be 0, that's fine.
else: # walk across list of sibling links
prev = item
current = self.get_sibling(item)
while current != 0:
if current == new_child:
self.set_sibling(prev, s) # s might be 0, that's fine.
break
prev = current
current = self.get_sibling(current)
else:
# we reached the end of the list, never got a match
raise ZObjectMalformedTree
def get_shortname(self, objectnum):
"""Return 'short name' of object number OBJECTNUM as ascii string."""
addr = self._get_proptable_addr(objectnum)
return self._stringfactory.get(addr + 1)
def get_prop(self, objectnum, propnum):
"""Return either a byte or word value of property PROPNUM of
object OBJECTNUM."""
(addr, size) = self.get_prop_addr_len(objectnum, propnum)
if size == 1:
return self._memory[addr]
elif size == 2:
return self._memory.read_word(addr)
else:
raise ZObjectIllegalPropLength
def get_prop_addr_len(self, objectnum, propnum):
"""Return address & length of value for property number PROPNUM of
object number OBJECTNUM. If object has no such property, then
return the address & length of the 'default' value for the property."""
# start at the beginning of the object's proptable
addr = self._get_proptable_addr(objectnum)
# skip past the shortname of the object
addr += 1 + 2 * self._memory[addr]
pnum = 0
if 1 <= self._memory.version <= 3:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[0:5]
size = bf[5:8] + 1
if pnum == propnum:
return (addr, size)
addr += size
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[0:6]
if bf[7]:
bf2 = BitField(self._memory[addr])
addr += 1
size = bf2[0:6]
else:
size = 2 if bf[6] else 1
if pnum == propnum:
return (addr, size)
addr += size
else:
raise ZObjectIllegalVersion
# property list ran out, so return default propval instead.
default_value_addr = self._get_default_property_addr(propnum)
return (default_value_addr, 2)
def get_all_properties(self, objectnum):
"""Return a dictionary of all properties listed in the property
table of object OBJECTNUM. (Obviously, this discounts 'default'
property values.). The dictionary maps property numbers to (addr,
len) propval tuples."""
proplist = {}
# start at the beginning of the object's proptable
addr = self._get_proptable_addr(objectnum)
# skip past the shortname of the object
shortname_length = self._memory[addr]
addr += 1
addr += 2 * shortname_length
if 1 <= self._memory.version <= 3:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[0:5]
size = bf[5:8] + 1
proplist[pnum] = (addr, size)
addr += size
elif 4 <= self._memory.version <= 5:
while self._memory[addr] != 0:
bf = BitField(self._memory[addr])
addr += 1
pnum = bf[0:6]
if bf[7]:
bf2 = BitField(self._memory[addr])
addr += 1
size = bf2[0:6]
if size == 0:
size = 64
else:
size = 2 if bf[6] else 1
proplist[pnum] = (addr, size)
addr += size
else:
raise ZObjectIllegalVersion
return proplist
def set_property(self, objectnum, propnum, value):
"""Set a property on an object."""
proplist = self.get_all_properties(objectnum)
if propnum not in proplist:
raise ZObjectIllegalPropertyNumber
addr, size = proplist[propnum]
if size == 1:
self._memory[addr] = value & 0xFF
elif size == 2:
self._memory.write_word(addr, value)
else:
raise ZObjectIllegalPropertySet
def get_property_data_address(self, objectnum, propnum):
"""Return the address of property PROPNUM's data bytes for object
OBJECTNUM. Return 0 if the object doesn't have that property."""
try:
addr, size = self.get_prop_addr_len(objectnum, propnum)
# get_prop_addr_len returns default property addr if not found
# We need to check if this is the actual property or default
proplist = self.get_all_properties(objectnum)
if propnum in proplist:
return addr
else:
return 0
except ZObjectIllegalPropLength:
return 0
def get_next_property(self, objectnum, propnum):
"""If PROPNUM is 0, return the first property number of object OBJECTNUM.
Otherwise, return the property number after PROPNUM in the property list.
Return 0 if there are no more properties."""
if propnum == 0:
# Return first property number
addr = self._get_proptable_addr(objectnum)
# Skip past the shortname
addr += 1 + 2 * self._memory[addr]
# Read first property number
if self._memory[addr] == 0:
return 0
if 1 <= self._memory.version <= 3:
bf = BitField(self._memory[addr])
return bf[0:5]
elif 4 <= self._memory.version <= 5:
bf = BitField(self._memory[addr])
return bf[0:6]
else:
raise ZObjectIllegalVersion
else:
# Find the property after propnum
proplist = self.get_all_properties(objectnum)
if propnum not in proplist:
raise ZObjectIllegalPropertyNumber
# Properties are stored in descending order
# Find the next lower property number
sorted_props = sorted(proplist.keys(), reverse=True)
try:
idx = sorted_props.index(propnum)
if idx + 1 < len(sorted_props):
return sorted_props[idx + 1]
else:
return 0
except ValueError:
return 0
def get_property_length(self, data_address):
"""Given a property DATA address, return the length of that property's data.
Return 0 if data_address is 0."""
if data_address == 0:
return 0
# The size byte is just before the data address
size_addr = data_address - 1
if 1 <= self._memory.version <= 3:
bf = BitField(self._memory[size_addr])
size = bf[5:8] + 1
return size
elif 4 <= self._memory.version <= 5:
bf = BitField(self._memory[size_addr])
if bf[7]:
# Two-byte header: size is in bits 0-5 of this byte
size = bf[0:6]
if size == 0:
size = 64
return size
else:
# One size byte
return 2 if bf[6] else 1
else:
raise ZObjectIllegalVersion
def describe_object(self, objectnum):
"""For debugging purposes, pretty-print everything known about
object OBJECTNUM."""
print("Object number:", objectnum)
print(" Short name:", self.get_shortname(objectnum))
print(" Parent:", self.get_parent(objectnum), end=" ")
print(" Sibling:", self.get_sibling(objectnum), end=" ")
print(" Child:", self.get_child(objectnum))
print(" Attributes:", self.get_all_attributes(objectnum))
print(" Properties:")
proplist = self.get_all_properties(objectnum)
for key in list(proplist.keys()):
(addr, len) = proplist[key]
print(f" [{key:2d}] :", end=" ")
for i in range(0, len):
print(f"{self._memory[addr + i]:02X}", end=" ")
print()

View file

@ -0,0 +1,244 @@
#
# A class which represents the Program Counter and decodes instructions
# to be executed by the ZPU. Implements section 4 of Z-code specification.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from .bitfield import BitField
from .zlogging import log
class ZOperationError(Exception):
"General exception for ZOperation class"
pass
# Constants defining the known instruction types. These types are
# related to the number of operands the opcode has: for each operand
# count, there is a separate opcode table, and the actual opcode
# number is an index into that table.
OPCODE_0OP = 0
OPCODE_1OP = 1
OPCODE_2OP = 2
OPCODE_VAR = 3
OPCODE_EXT = 4
# Mapping of those constants to strings describing the opcode
# classes. Used for pretty-printing only.
OPCODE_STRINGS = {
OPCODE_0OP: "0OP",
OPCODE_1OP: "1OP",
OPCODE_2OP: "2OP",
OPCODE_VAR: "VAR",
OPCODE_EXT: "EXT",
}
# Constants defining the possible operand types.
LARGE_CONSTANT = 0x0
SMALL_CONSTANT = 0x1
VARIABLE = 0x2
ABSENT = 0x3
class ZOpDecoder:
def __init__(self, zmem, zstack):
""
self._memory = zmem
self._stack = zstack
self._parse_map = {}
self.program_counter = self._memory.read_word(0x6)
def _get_pc(self):
byte = self._memory[self.program_counter]
self.program_counter += 1
return byte
def get_next_instruction(self):
"""Decode the opcode & operands currently pointed to by the
program counter, and appropriately increment the program counter
afterwards. A decoded operation is returned to the caller in the form:
[opcode-class, opcode-number, [operand, operand, operand, ...]]
If the opcode has no operands, the operand list is present but empty."""
opcode = self._get_pc()
log(f"Decode opcode {opcode:x}")
# Determine the opcode type, and hand off further parsing.
if self._memory.version == 5 and opcode == 0xBE:
# Extended opcode
return self._parse_opcode_extended()
opcode = BitField(opcode)
if opcode[7] == 0:
# Long opcode
return self._parse_opcode_long(opcode)
elif opcode[6] == 0:
# Short opcode
return self._parse_opcode_short(opcode)
else:
# Variable opcode
return self._parse_opcode_variable(opcode)
def _parse_opcode_long(self, opcode):
"""Parse an opcode of the long form."""
# Long opcodes are always 2OP. The types of the two operands are
# encoded in bits 5 and 6 of the opcode.
log("Opcode is long")
LONG_OPERAND_TYPES = [SMALL_CONSTANT, VARIABLE]
operands = [
self._parse_operand(LONG_OPERAND_TYPES[opcode[6]]),
self._parse_operand(LONG_OPERAND_TYPES[opcode[5]]),
]
return (OPCODE_2OP, opcode[0:5], operands)
def _parse_opcode_short(self, opcode):
"""Parse an opcode of the short form."""
# Short opcodes can have either 1 operand, or no operand.
log("Opcode is short")
operand_type = opcode[4:6]
operand = self._parse_operand(operand_type)
if operand is None: # 0OP variant
log("Opcode is 0OP variant")
return (OPCODE_0OP, opcode[0:4], [])
else:
log("Opcode is 1OP variant")
return (OPCODE_1OP, opcode[0:4], [operand])
def _parse_opcode_variable(self, opcode):
"""Parse an opcode of the variable form."""
log("Opcode is variable")
if opcode[5]:
log("Variable opcode of VAR kind")
opcode_type = OPCODE_VAR
else:
log("Variable opcode of 2OP kind")
opcode_type = OPCODE_2OP
opcode_num = opcode[0:5]
# Parse the types byte to retrieve the operands.
operands = self._parse_operands_byte()
# Special case: opcodes 12 and 26 have a second operands byte.
if opcode[0:7] == 0xC or opcode[0:7] == 0x1A:
log("Opcode has second operand byte")
operands += self._parse_operands_byte()
return (opcode_type, opcode_num, operands)
def _parse_opcode_extended(self):
"""Parse an extended opcode (v5+ feature)."""
raise NotImplementedError("Extended opcodes (v5+) not yet implemented")
def _parse_operand(self, operand_type):
"""Read and return an operand of the given type.
This assumes that the operand is in memory, at the address pointed
by the Program Counter."""
assert operand_type <= 0x3
if operand_type == LARGE_CONSTANT:
log("Operand is large constant")
operand = self._memory.read_word(self.program_counter)
self.program_counter += 2
elif operand_type == SMALL_CONSTANT:
log("Operand is small constant")
operand = self._get_pc()
elif operand_type == VARIABLE:
variable_number = self._get_pc()
log(f"Operand is variable {variable_number}")
if variable_number == 0:
log("Operand value comes from stack")
operand = self._stack.pop_stack() # TODO: make sure this is right.
elif variable_number < 16:
log("Operand value comes from local variable")
operand = self._stack.get_local_variable(variable_number - 1)
else:
log("Operand value comes from global variable")
operand = self._memory.read_global(variable_number)
elif operand_type == ABSENT:
log("Operand is absent")
operand = None
if operand is not None:
log(f"Operand value: {operand}")
return operand
def _parse_operands_byte(self):
"""Parse operands given by the operand byte and return a list of
values.
"""
operand_byte = BitField(self._get_pc())
operands = []
for operand_type in [
operand_byte[6:8],
operand_byte[4:6],
operand_byte[2:4],
operand_byte[0:2],
]:
operand = self._parse_operand(operand_type)
if operand is None:
break
operands.append(operand)
return operands
# Public funcs that the ZPU may also need to call, depending on the
# opcode being executed:
def get_zstring(self):
"""For string opcodes, return the address of the zstring pointed
to by the PC. Increment PC just past the text."""
start_addr = self.program_counter
bf = BitField(0)
while True:
bf.__init__(self._memory[self.program_counter])
self.program_counter += 2
if bf[7] == 1:
break
return start_addr
def get_store_address(self):
"""For store opcodes, read byte pointed to by PC and return the
variable number in which the operation result should be stored.
Increment the PC as necessary."""
return self._get_pc()
def get_branch_offset(self):
"""For branching opcodes, examine address pointed to by PC, and
return two values: first, either True or False (indicating whether
to branch if true or branch if false), and second, the address to
jump to. Increment the PC as necessary."""
bf = BitField(self._get_pc())
branch_if_true = bool(bf[7])
if bf[6]:
branch_offset = bf[0:6]
else:
# We need to do a little magic here. The branch offset is
# written as a signed 14-bit number, with signed meaning '-n' is
# written as '65536-n'. Or in this case, as we have 14 bits,
# '16384-n'.
#
# So, if the MSB (ie. bit 13) is set, we have a negative
# number. We take the value, and substract 16384 to get the
# actual offset as a negative integer.
#
# If the MSB is not set, we just extract the value and return it.
#
# Can you spell "Weird" ?
branch_offset = self._get_pc() + (bf[0:5] << 8)
if bf[5]:
branch_offset -= 8192
log(f"Branch if {branch_if_true} to offset {branch_offset:+d}")
return branch_if_true, branch_offset

View file

@ -0,0 +1,290 @@
#
# A template class representing the screen of a z-machine.
#
# Third-party programs are expected to subclass zscreen and override all
# the methods, then pass an instance of their class to be driven by
# the main z-machine engine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zstream
# Constants for window numbers.
#
# TODO: The Z-Machine standard mentions upper and lower windows and
# window numbers, but never appears to define a mapping between the
# two. So the following values are simply a best guess and may need
# to be changed in the future.
WINDOW_UPPER = 1
WINDOW_LOWER = 2
# Constants for fonts. These are human-readable names for the font ID
# numbers as described in section 8.1.2 of the Z-Machine Standards
# Document.
FONT_NORMAL = 1
FONT_PICTURE = 2
FONT_CHARACTER_GRAPHICS = 3
FONT_FIXED_PITCH = 4
# Constants for text styles. These are human-readable names for the
# 'style' operand of the Z-Machine's 'set_text_style' opcode.
STYLE_ROMAN = 0
STYLE_REVERSE_VIDEO = 1
STYLE_BOLD = 2
STYLE_ITALIC = 4
STYLE_FIXED_PITCH = 8
# Constants for colors. These are human-readable names for the color
# codes as described in section 8.3.1 of the Z-Machine Standards
# Document. Note that the colors defined by Z-Machine Version 6 are
# not defined here, since we are not currently supporting that
# version.
COLOR_CURRENT = 0
COLOR_DEFAULT = 1
COLOR_BLACK = 2
COLOR_RED = 3
COLOR_GREEN = 4
COLOR_YELLOW = 5
COLOR_BLUE = 6
COLOR_MAGENTA = 7
COLOR_CYAN = 8
COLOR_WHITE = 9
# The number of screen rows that represents an "infinite" screen
# height; a screen with its rows set to this value should never
# display a [MORE] prompt, as described in section 8.4.1 of the
# Z-Machine Standards Document.
INFINITE_ROWS = 255
class ZScreenObserver:
"""Observer that is notified of changes in the state of a ZScreen
object.
Note that all methods in this class may be called by any thread at
any time, so they should take any necessary precautions to ensure
the integrity of any data they modify."""
def on_screen_size_change(self, zscreen):
"""Called when the screen size of a ZScreen changes."""
pass
def on_font_size_change(self, zscreen):
"""Called when the font size of a ZScreen changes."""
pass
class ZScreen(zstream.ZBufferableOutputStream):
"""Subclass of zstream.ZBufferableOutputStream that provides an
abstraction of a computer screen."""
def __init__(self):
"Constructor for the screen."
zstream.ZBufferableOutputStream.__init__(self)
# The size of the screen.
self._columns = 79
self._rows = 24
# The size of the current font, in characters
self._fontheight = 1
self._fontwidth = 1
# List of our observers; clients can directly append to and remove
# from this.
self.observers = []
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_status_line": False,
"has_upper_window": False,
"has_graphics_font": False,
"has_text_colors": False,
}
# Window Management
#
# The z-machine has 2 windows for displaying text, "upper" and
# "lower". (The upper window has an inital height of 0.)
#
# The upper window is not necessarily where the "status line"
# appears; see section 8.6.1.1 of the Z-Machine Standards Document.
#
# The UI is responsible for making the lower window scroll properly,
# as well as wrapping words ("buffering"). The upper window,
# however, should *never* scroll or wrap words.
#
# The UI is also responsible for displaying [MORE] prompts when
# printing more text than the screen's rows can display. (Note: if
# the number of screen rows is INFINITE_ROWS, then it should never
# prompt [MORE].)
def get_screen_size(self):
"""Return the current size of the screen as [rows, columns]."""
return [self._rows, self._columns]
def select_window(self, window):
"""Select a window to be the 'active' window, and move that
window's cursor to the upper left.
WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER.
This method should only be implemented if the
has_upper_window feature is enabled."""
raise NotImplementedError()
def split_window(self, height):
"""Make the upper window appear and be HEIGHT lines tall. To
'unsplit' a window, call with a height of 0 lines.
This method should only be implemented if the has_upper_window
feature is enabled."""
raise NotImplementedError()
def set_cursor_position(self, x, y):
"""Set the cursor to (row, column) coordinates (X,Y) in the
current window, where (1,1) is the upper-left corner.
This function only does something if the current window is the
upper window; if the current window is the lower window, this
function has no effect.
This method should only be implemented if the has_upper_window
feature is enabled, as the upper window is the only window that
supports cursor positioning."""
raise NotImplementedError()
def erase_window(self, window=WINDOW_LOWER, color=COLOR_CURRENT):
"""Erase WINDOW to background COLOR.
WINDOW should be one of WINDOW_UPPER or WINDOW_LOWER.
If the has_upper_window feature is not supported, WINDOW is
ignored (in such a case, this function should clear the entire
screen).
COLOR should be one of the COLOR_* constants.
If the has_text_colors feature is not supported, COLOR is ignored."""
raise NotImplementedError()
def erase_line(self):
"""Erase from the current cursor position to the end of its line
in the current window.
This method should only be implemented if the has_upper_window
feature is enabled, as the upper window is the only window that
supports cursor positioning."""
raise NotImplementedError()
# Status Line
#
# These routines are only called if the has_status_line capability
# is set. Specifically, one of them is called whenever the
# show_status opcode is executed, and just before input is read from
# the user.
def print_status_score_turns(self, text, score, turns):
"""Print a status line in the upper window, as follows:
On the left side of the status line, print TEXT.
On the right side of the status line, print SCORE/TURNS.
This method should only be implemented if the has_status_line
feature is enabled.
"""
raise NotImplementedError()
def print_status_time(self, hours, minutes):
"""Print a status line in the upper window, as follows:
On the left side of the status line, print TEXT.
On the right side of the status line, print HOURS:MINUTES.
This method should only be implemented if the has_status_line
feature is enabled.
"""
raise NotImplementedError()
# Text Appearances
#
def get_font_size(self):
"""Return the current font's size as [width, height]."""
return [self._fontwidth, self._fontheight]
def set_font(self, font_number):
"""Set the current window's font to one of
FONT_NORMAL - normal font
FONT_PICTURE - picture font (IGNORE, this means nothing)
FONT_CHARACTER_GRAPHICS - character graphics font
FONT_FIXED_WIDTH - fixed-width font
If a font is not available, return None. Otherwise, set the
new font, and return the number of the *previous* font.
The only font that must be supported is FONT_NORMAL; all others
are optional, as per section 8.1.3 of the Z-Machine Standards
Document."""
raise NotImplementedError()
def set_text_style(self, style):
"""Set the current text style to the given text style.
STYLE is a sequence, each element of which should be one of the
following values:
STYLE_ROMAN - Roman
STYLE_REVERSE_VIDEO - Reverse video
STYLE_BOLD - Bold
STYLE_ITALIC - Italic
STYLE_FIXED_PITCH - Fixed-width
It is not a requirement that the screen implementation support
every combination of style; if no combinations are possible, it is
acceptable to simply use the first style in the sequence and ignore
the rest.
As per section 8.7.1.1 of the Z-Machine Standards Document, the
implementation need not provide bold or italic, and is free to
interpret them broadly.
"""
raise NotImplementedError()
def set_text_color(self, foreground_color, background_color):
"""Set current text foreground and background color. Each color
should correspond to one of the COLOR_* constants.
This method should only be implemented if the has_text_colors
feature is enabled.
"""
raise NotImplementedError()
# Standard output
def write(self, string):
"""Implementation of the ZOutputStream method. Prints the given
unicode string to the currently active window, using the current
text style settings."""
raise NotImplementedError()

View file

@ -0,0 +1,209 @@
#
# A class which manages both (1) the general purpose stack ("data
# stack") used by the story code to store temporary data, and (2) the
# interpreter-private stack of routines ("call stack") and their local
# variables.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from .zlogging import log
class ZStackError(Exception):
"General exception for stack or routine-related errors"
pass
class ZStackUnsupportedVersion(ZStackError):
"Unsupported version of Z-story file."
pass
class ZStackNoRoutine(ZStackError):
"No routine is being executed."
pass
class ZStackNoSuchVariable(ZStackError):
"Trying to access non-existent local variable."
pass
class ZStackPopError(ZStackError):
"Nothing to pop from stack!"
pass
# Helper class used by ZStackManager; a 'routine' object which
# includes its own private stack of data.
class ZRoutine:
def __init__(
self, start_addr, return_addr, zmem, args, local_vars=None, stack=None
):
"""Initialize a routine object beginning at START_ADDR in ZMEM,
with initial argument values in list ARGS. If LOCAL_VARS is None,
then parse them from START_ADDR."""
self.start_addr = start_addr
self.return_addr = return_addr
self.program_counter = 0 # used when execution interrupted
if stack is None:
self.stack = []
else:
self.stack = stack[:]
if local_vars is not None:
self.local_vars = local_vars[:]
else:
num_local_vars = zmem[self.start_addr]
if not (0 <= num_local_vars <= 15):
log(f"num local vars is {num_local_vars}")
raise ZStackError
self.start_addr += 1
# Initialize the local vars in the ZRoutine's dictionary. This is
# only needed on machines v1 through v4. In v5 machines, all local
# variables are preinitialized to zero.
self.local_vars = [0 for _ in range(15)]
if 1 <= zmem.version <= 4:
for i in range(num_local_vars):
self.local_vars[i] = zmem.read_word(self.start_addr)
self.start_addr += 2
elif zmem.version != 5:
raise ZStackUnsupportedVersion
# Place call arguments into local vars, if available
for i in range(0, len(args)):
self.local_vars[i] = args[i]
def pretty_print(self):
"Display a ZRoutine nicely, for debugging purposes."
log(f"ZRoutine: start address: {self.start_addr}")
log(f"ZRoutine: return value address: {self.return_addr}")
log(f"ZRoutine: program counter: {self.program_counter}")
log(f"ZRoutine: local variables: {self.local_vars}")
class ZStackBottom:
"""Sentinel object at the bottom of the call stack.
Includes stack and local_vars attributes so code that walks the call
stack can treat all frames uniformly without special-case checks for
the bottom sentinel.
"""
def __init__(self):
self.program_counter = 0 # used as a cache only
self.stack = []
self.local_vars = [0 for _ in range(15)]
class ZStackManager:
def __init__(self, zmem):
self._memory = zmem
self._stackbottom = ZStackBottom()
self._call_stack = [self._stackbottom]
def get_local_variable(self, varnum):
"""Return value of local variable VARNUM from currently-running
routine. VARNUM must be a value between 0 and 15, and must
exist."""
if self._call_stack[-1] == self._stackbottom:
raise ZStackNoRoutine
if not 0 <= varnum <= 15:
raise ZStackNoSuchVariable
current_routine = self._call_stack[-1]
return current_routine.local_vars[varnum]
def set_local_variable(self, varnum, value):
"""Set value of local variable VARNUM to VALUE in
currently-running routine. VARNUM must be a value between 0 and
15, and must exist."""
if self._call_stack[1] == self._stackbottom:
raise ZStackNoRoutine
if not 0 <= varnum <= 15:
raise ZStackNoSuchVariable
current_routine = self._call_stack[-1]
current_routine.local_vars[varnum] = value
def push_stack(self, value):
"Push VALUE onto the top of the current routine's data stack."
current_routine = self._call_stack[-1]
current_routine.stack.append(value)
def pop_stack(self):
"Remove and return value from the top of the data stack."
current_routine = self._call_stack[-1]
return current_routine.stack.pop()
def get_stack_frame_index(self):
"Return current stack frame number. For use by 'catch' opcode."
return len(self._call_stack) - 1
# Used by quetzal save-file parser to reconstruct stack-frames.
def push_routine(self, routine):
"""Blindly push a ZRoutine object to the call stack.
WARNING: do not use this unless you know what you're doing; you
probably want the more full-featured start_routine() belowe
instead."""
self._call_stack.append(routine)
# ZPU should call this whenever it decides to call a new routine.
def start_routine(self, routine_addr, return_addr, program_counter, args):
"""Save the state of the currenly running routine (by examining
the current value of the PROGRAM_COUNTER), and prepare for
execution of a new routine at ROUTINE_ADDR with list of initial
arguments ARGS."""
new_routine = ZRoutine(routine_addr, return_addr, self._memory, args)
current_routine = self._call_stack[-1]
current_routine.program_counter = program_counter
self._call_stack.append(new_routine)
return new_routine.start_addr
# ZPU should call this whenever it decides to return from current
# routine.
def finish_routine(self, return_value):
"""Toss the currently running routine from the call stack, and
toss any leftover values pushed to the data stack by said routine.
Return the previous routine's program counter address, so that
execution can resume where from it left off."""
exiting_routine = self._call_stack.pop()
current_routine = self._call_stack[-1]
# Depending on many things, return stuff.
if exiting_routine.return_addr is not None: # type: ignore[possibly-missing-attribute]
if exiting_routine.return_addr == 0: # type: ignore[possibly-missing-attribute]
# Push to stack
self.push_stack(return_value)
elif 0 < exiting_routine.return_addr < 0x10: # type: ignore[possibly-missing-attribute]
# Store in local var
self.set_local_variable(exiting_routine.return_addr - 1, return_value) # type: ignore[possibly-missing-attribute]
else:
# Store in global var
self._memory.write_global(exiting_routine.return_addr, return_value) # type: ignore[possibly-missing-attribute]
return current_routine.program_counter

View file

@ -0,0 +1,103 @@
#
# Template classes representing input/output streams of a z-machine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
class ZOutputStream:
"""Abstract class representing an output stream for a z-machine."""
def write(self, string):
"""Prints the given unicode string to the output stream."""
raise NotImplementedError()
class ZBufferableOutputStream(ZOutputStream):
"""Abstract class representing a buffered output stream for a
z-machine, which can be optionally configured at run-time to provide
'buffering', also known as word-wrap."""
def __init__(self):
# This is a public variable that determines whether buffering is
# enabled for this stream or not. Subclasses can make it a
# Python property if necessary.
self.buffer_mode = False
class ZInputStream:
"""Abstract class representing an input stream for a z-machine."""
def __init__(self):
"""Constructor for the input stream."""
# Subclasses must define real values for all the features they
# support (or don't support).
self.features = {
"has_timed_input": False,
}
def read_line(
self,
original_text=None,
max_length=0,
terminating_characters=None,
timed_input_routine=None,
timed_input_interval=0,
):
"""Reads from the input stream and returns a unicode string
representing the characters the end-user entered. The characters
are displayed to the screen as the user types them.
original_text, if provided, is pre-filled-in unicode text that the
end-user may delete or otherwise modify if they so choose.
max_length is the maximum length, in characters, of the text that
the end-user may enter. Any typing the end-user does after these
many characters have been entered is ignored. 0 means that there
is no practical limit to the number of characters the end-user can
enter.
terminating_characters is a string of unicode characters
representing the characters that can signify the end of a line of
input. If not provided, it defaults to a string containing a
carriage return character ('\r'). The terminating character is
not contained in the returned string.
timed_input_routine is a function that will be called every
time_input_interval milliseconds. This function should be of the
form:
def timed_input_routine(interval)
where interval is simply the value of timed_input_interval that
was passed in to read_line(). The function should also return
True if input should continue to be collected, or False if input
should stop being collected; if False is returned, then
read_line() will return a unicode string representing the
characters typed so far.
The timed input routine will be called from the same thread that
called read_line().
Note, however, that supplying a timed input routine is only useful
if the has_timed_input feature is supported by the input stream.
If it is unsupported, then the timed input routine will not be
called."""
raise NotImplementedError()
def read_char(self, timed_input_routine=None, timed_input_interval=0):
"""Reads a single character from the stream and returns it as a
unicode character.
timed_input_routine and timed_input_interval are the same as
described in the documentation for read_line().
TODO: Should the character be automatically printed to the screen?
The Z-Machine documentation for the read_char opcode, which this
function is meant to ultimately implement, doesn't specify."""
raise NotImplementedError()

View file

@ -0,0 +1,100 @@
#
# A class which represents the i/o streams of the Z-Machine and their
# current state of selection.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
# Constants for output streams. These are human-readable names for
# the stream ID numbers as described in sections 7.1.1 and 7.1.2
# of the Z-Machine Standards Document.
OUTPUT_SCREEN = 1 # spews text to the the screen
OUTPUT_TRANSCRIPT = 2 # contains everything player typed, plus our responses
OUTPUT_MEMORY = 3 # if the z-machine wants to write to memory
OUTPUT_PLAYER_INPUT = 4 # contains *only* the player's typed commands
# Constants for input streams. These are human-readable names for the
# stream ID numbers as described in section 10.2 of the Z-Machine
# Standards Document.
INPUT_KEYBOARD = 0
INPUT_FILE = 1
class ZOutputStreamManager:
"""Manages output streams for a Z-Machine."""
def __init__(self, zmem, zui):
# TODO: Actually set/create the streams as necessary.
self._selectedStreams = []
self._streams = {}
def select(self, stream):
"""Selects the given stream ID for output."""
if stream not in self._selectedStreams:
self._selectedStreams.append(stream)
def unselect(self, stream):
"""Unselects the given stream ID for output."""
if stream in self._selectedStreams:
self._selectedStreams.remove(stream)
def get(self, stream):
"""Retrieves the given stream ID."""
return self._streams[stream]
def write(self, string):
"""Writes the given unicode string to all currently selected output
streams."""
# TODO: Implement section 7.1.2.2 of the Z-Machine Standards
# Document, so that while stream 3 is selected, no text is
# sent to any other output streams which are selected. (However,
# they remain selected.).
# TODO: Implement section 7.1.2.2.1, so that newlines are written to
# output stream 3 as ZSCII 13.
# TODO: Implement section 7.1.2.3, so that whiles stream 4 is
# selected, the only text printed to it is that of the player's
# commands and keypresses (as read by read_char). This may not
# ultimately happen via this method.
for stream in self._selectedStreams:
self._streams[stream].write(string)
class ZInputStreamManager:
"""Manages input streams for a Z-Machine."""
def __init__(self, zui):
# TODO: Actually set/create the streams as necessary.
self._selectedStream = None
self._streams = {}
def select(self, stream):
"""Selects the given stream ID as the currently active input stream."""
# TODO: Ensure section 10.2.4, so that while stream 1 is selected,
# the only text printed to it is that of the player's commands and
# keypresses (as read by read_char). Not sure where this logic
# will ultimately go, however.
self._selectedStream = stream
def getSelected(self):
"""Returns the input stream object for the currently active input
stream."""
return self._streams[self._selectedStream]
class ZStreamManager:
def __init__(self, zmem, zui):
self.input = ZInputStreamManager(zui)
self.output = ZOutputStreamManager(zmem, zui)

View file

@ -0,0 +1,480 @@
#
# A ZString-to-Unicode Universal Translator.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
import itertools
from .zlogging import log
class ZStringEndOfString(Exception):
"""No more data left in string."""
class ZStringIllegalAbbrevInString(Exception):
"""String abbreviation encountered within a string in a context
where it is not allowed."""
class ZStringTranslator:
def __init__(self, zmem):
self._mem = zmem
def get(self, addr):
from .bitfield import BitField
pos = (addr, BitField(self._mem.read_word(addr)), 0)
s = []
try:
while True:
s.append(self._read_char(pos))
pos = self._next_pos(pos)
except ZStringEndOfString:
return s
def _read_char(self, pos):
offset = (2 - pos[2]) * 5
return pos[1][offset : offset + 5]
def _is_final(self, pos):
return pos[1][15] == 1
def _next_pos(self, pos):
from .bitfield import BitField
offset = pos[2] + 1
# Overflowing from current block?
if offset == 3:
# Was last block?
if self._is_final(pos):
# Kill processing.
raise ZStringEndOfString
# Get and return the next block.
return (pos[0] + 2, BitField(self._mem.read_word(pos[0] + 2)), 0)
# Just increment the intra-block counter.
return (pos[0], pos[1], offset)
class ZCharTranslator:
# The default alphabet tables for ZChar translation.
# As the codes 0-5 are special, alphabets start with code 0x6.
DEFAULT_A0 = [ord(x) for x in "abcdefghijklmnopqrstuvwxyz"]
DEFAULT_A1 = [ord(x) for x in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
# A2 also has 0x6 as special char, so they start at 0x7.
DEFAULT_A2 = [ord(x) for x in "\n0123456789.,!?_#'\"/\\-:()"]
DEFAULT_A2_V5 = [ord(x) for x in "\n0123456789.,!?_#'\"/\\-:()"]
ALPHA = (DEFAULT_A0, DEFAULT_A1, DEFAULT_A2)
ALPHA_V5 = (DEFAULT_A0, DEFAULT_A1, DEFAULT_A2_V5)
def __init__(self, zmem):
self._mem = zmem
# Initialize the alphabets
if self._mem.version == 5:
self._alphabet = self._load_custom_alphabet() or self.ALPHA_V5
else:
self._alphabet = self.ALPHA
# Initialize the special state handlers
self._load_specials()
# Initialize the abbreviations (if supported)
self._load_abbrev_tables()
def _load_custom_alphabet(self):
"""Check for the existence of a custom alphabet, and load it
if it does exist. Return the custom alphabet if it was found,
None otherwise."""
# The custom alphabet table address is at 0x34 in the memory.
if self._mem[0x34] == 0:
return None
alph_addr = self._mem.read_word(0x34)
alphabet = self._mem[alph_addr : alph_addr + 78]
return [alphabet[0:26], alphabet[26:52], alphabet[52:78]]
def _load_abbrev_tables(self):
self._abbrevs = {}
# If the ZM doesn't do abbrevs, just return an empty dict.
if self._mem.version == 1:
return
# Build ourselves a ZStringTranslator for the abbrevs.
xlator = ZStringTranslator(self._mem)
def _load_subtable(num, base):
for i, zoff in [(i, base + (num * 64) + (i * 2)) for i in range(0, 32)]:
zaddr = self._mem.read_word(zoff)
zstr = xlator.get(self._mem.word_address(zaddr))
zchr = self.get(zstr, allow_abbreviations=False)
self._abbrevs[(num, i)] = zchr
abbrev_base = self._mem.read_word(0x18)
_load_subtable(0, abbrev_base)
# Does this ZM support the extended abbrev tables?
if self._mem.version >= 3:
_load_subtable(1, abbrev_base)
_load_subtable(2, abbrev_base)
def _load_specials(self):
"""Load the special character code handlers for the current
machine version.
"""
# The following three functions define the three possible
# special character code handlers.
def newline(state):
"""Append ZSCII 13 (newline) to the output."""
state["zscii"].append(13)
def shift_alphabet(state, direction, lock):
"""Shift the current alphaber up or down. If lock is
False, the alphabet will revert to the previous alphabet
after outputting 1 character. Else, the alphabet will
remain unchanged until the next shift.
"""
state["curr_alpha"] = (state["curr_alpha"] + direction) % 3
if lock:
state["prev_alpha"] = state["curr_alpha"]
def abbreviation(state, abbrev):
"""Insert the given abbreviation from the given table into
the output stream.
This character was an abbreviation table number. The next
character will be the offset within that table of the
abbreviation. Set up a state handler to intercept the next
character and output the right abbreviation."""
def write_abbreviation(state, c, subtable):
state["zscii"] += self._abbrevs[(subtable, c)]
del state["state_handler"]
# If we're parsing an abbreviation, there should be no
# nested abbreviations. So this is just a sanity check for
# people feeding us bad stories.
if not state["allow_abbreviations"]:
raise ZStringIllegalAbbrevInString
state["state_handler"] = lambda s, c: write_abbreviation(s, c, abbrev)
# Register the specials handlers depending on machine version.
if self._mem.version == 1:
self._specials = {
1: lambda s: newline(s),
2: lambda s: shift_alphabet(s, +1, False),
3: lambda s: shift_alphabet(s, -1, False),
4: lambda s: shift_alphabet(s, +1, True),
5: lambda s: shift_alphabet(s, -1, True),
}
elif self._mem.version == 2:
self._specials = {
1: lambda s: abbreviation(s, 0),
2: lambda s: shift_alphabet(s, +1, False),
3: lambda s: shift_alphabet(s, -1, False),
4: lambda s: shift_alphabet(s, +1, True),
5: lambda s: shift_alphabet(s, -1, True),
}
else: # ZM v3-5
self._specials = {
1: lambda s: abbreviation(s, 0),
2: lambda s: abbreviation(s, 1),
3: lambda s: abbreviation(s, 2),
4: lambda s: shift_alphabet(s, +1, False),
5: lambda s: shift_alphabet(s, -1, False),
}
def _special_zscii(self, state, char):
if "zscii_char" not in list(state.keys()):
state["zscii_char"] = char
else:
zchar = (state["zscii_char"] << 5) + char
state["zscii"].append(zchar)
del state["zscii_char"]
del state["state_handler"]
def get(self, zstr, allow_abbreviations=True):
state = {
"curr_alpha": 0,
"prev_alpha": 0,
"zscii": [],
"allow_abbreviations": allow_abbreviations,
}
for c in zstr:
if "state_handler" in list(state.keys()):
# If a special handler has registered itself, then hand
# processing over to it.
state["state_handler"](state, c) # type: ignore[call-non-callable]
elif c in list(self._specials.keys()):
# Hand off per-ZM version special char handling.
self._specials[c](state)
elif state["curr_alpha"] == 2 and c == 6:
# Handle the strange A2/6 character
state["state_handler"] = self._special_zscii
else:
# Do the usual Thing: append a zscii code to the
# decoded sequence and revert to the "previous"
# alphabet (or not, if it hasn't recently changed or
# was locked)
if c == 0:
# Append a space.
z = 32
elif state["curr_alpha"] == 2:
# The symbol alphabet table only has 25 chars
# because of the A2/6 special char, so we need to
# adjust differently.
z = self._alphabet[state["curr_alpha"]][c - 7]
else:
z = self._alphabet[state["curr_alpha"]][c - 6]
state["zscii"].append(z)
state["curr_alpha"] = state["prev_alpha"]
return state["zscii"]
class ZsciiTranslator:
# The default Unicode Translation Table that maps to ZSCII codes
# 155-251. The codes are unicode codepoints for a host of strange
# characters.
DEFAULT_UTT = [
chr(x)
for x in (
0xE4,
0xF6,
0xFC,
0xC4,
0xD6,
0xDC,
0xDF,
0xBB,
0xAB,
0xEB,
0xEF,
0xFF,
0xCB,
0xCF,
0xE1,
0xE9,
0xED,
0xF3,
0xFA,
0xFD,
0xC1,
0xC9,
0xCD,
0xD3,
0xDA,
0xDD,
0xE0,
0xE8,
0xEC,
0xF2,
0xF9,
0xC0,
0xC8,
0xCC,
0xD2,
0xD9,
0xE2,
0xEA,
0xEE,
0xF4,
0xFB,
0xC2,
0xCA,
0xCE,
0xD4,
0xDB,
0xE5,
0xC5,
0xF8,
0xD8,
0xE3,
0xF1,
0xF5,
0xC3,
0xD1,
0xD5,
0xE6,
0xC6,
0xE7,
0xC7,
0xFE,
0xF0,
0xDE,
0xD0,
0xA3,
0x153,
0x152,
0xA1,
0xBF,
)
]
# And here is the offset at which the Unicode Translation Table
# starts.
UTT_OFFSET = 155
# This subclass just lists all the "special" character codes that
# are capturable from an input stream. They're just there so that
# the user of the virtual machine can give them a nice name.
class Input:
DELETE = 8
ESCAPE = 27
# The cursor pad
CUR_UP = 129
CUR_DOWN = 130
CUR_LEFT = 131
CUR_RIGHT = 132
# The Function keys
F1 = 133
F2 = 134
F3 = 135
F4 = 136
F5 = 137
F6 = 138
F7 = 139
F8 = 140
F9 = 141
F10 = 142
F11 = 143
F12 = 144
# The numpad (keypad) keys.
KP_0 = 145
KP_1 = 146
KP_2 = 147
KP_3 = 148
KP_4 = 149
KP_5 = 150
KP_6 = 151
KP_7 = 152
KP_8 = 153
KP_9 = 154
def __init__(self, zmem):
self._mem = zmem
self._output_table = {0: "", 10: "\n"}
self._input_table = {"\n": 10}
self._load_unicode_table()
# Populate the input and output tables with the ASCII and UTT
# characters.
for code, char in [(x, chr(x)) for x in range(32, 127)]:
self._output_table[code] = char
self._input_table[char] = code
# Populate the input table with the extra "special" input
# codes. The cool trick we use here, is that all these values
# are in fact numbers, so their key will be available in both
# dicts, and ztoa will provide the correct code if you pass it
# a special symbol instead of a character to translate!
#
# Oh and we also pull the items from the subclass into this
# instance, so as to make reference to these special codes
# easier.
for name, code in [
(c, v)
for c, v in list(self.Input.__dict__.items())
if not c.startswith("__")
]:
self._input_table[code] = code
setattr(self, name, code)
# The only special support required for ZSCII: ZM v5 defines
# an extra character code to represent a mouse click. If we're
# booting a v5 ZM, define this.
if self._mem.version == 5:
self.MOUSE_CLICK = 254
self._input_table[254] = 254
def _load_unicode_table(self):
if self._mem.version == 5:
# Read the header extension table address
ext_table_addr = self._mem.read_word(0x36)
# If:
# - The extension header's address is non-null
# - There are at least 3 words in the extension header
# (the unicode translation table is the third word)
# - The 3rd word (unicode translation table address) is
# non-null
#
# Then there is a unicode translation table other than the
# default that needs loading.
if (
ext_table_addr != 0
and self._mem.read_word(ext_table_addr) >= 3
and self._mem.read_word(ext_table_addr + 6) != 0
):
# Get the unicode translation table address
utt_addr = self._mem.read_word(ext_table_addr + 6)
# The first byte is the number of unicode characters
# in the table.
utt_len = self._mem[utt_addr]
# Build the range of addresses to load from, and build
# the unicode translation table as a list of unicode
# chars.
utt_range = range(utt_addr + 1, utt_addr + 1 + (utt_len * 2), 2)
utt = [chr(self._mem.read_word(i)) for i in utt_range]
else:
utt = self.DEFAULT_UTT
# One way or another, we have a unicode translation
# table. Add all the characters in it to the input and
# output translation tables.
for zscii, unichar in zip(itertools.count(155), utt):
self._output_table[zscii] = unichar
self._input_table[unichar] = zscii
def ztou(self, index):
"""Translate the given ZSCII code into the corresponding
output Unicode character and return it, or raise an exception if
the requested index has no translation."""
try:
return self._output_table[index]
except KeyError:
# Handle undefined ZSCII characters
# 0-31 (except 0, 10): control characters, return empty string
# 128-154, 252-254: undefined, return placeholder
# 155-251: extended characters, should have Unicode table but don't
if index < 32:
return ""
# For undefined or unmapped characters, return a placeholder
log(f"Warning: undefined ZSCII character code {index}, using '?'")
return "?"
def utoz(self, char):
"""Translate the given Unicode code into the corresponding
input ZSCII character and return it, or raise an exception if
the requested character has no translation."""
try:
return self._input_table[char]
except KeyError:
raise IndexError("No such input character") from None
def get(self, zscii):
return "".join([self.ztou(c) for c in zscii])
class ZStringFactory:
def __init__(self, zmem):
self._mem = zmem
self.zstr = ZStringTranslator(zmem)
self.zchr = ZCharTranslator(zmem)
self.zscii = ZsciiTranslator(zmem)
def get(self, addr):
zstr = self.zstr.get(addr)
zchr = self.zchr.get(zstr)
return self.zscii.get(zchr)

View file

@ -0,0 +1,31 @@
#
# A class representing the entire user interface of a Z-Machine.
#
# For the license of this file, please consult the LICENSE file in the
# root directory of this distribution.
#
from . import zaudio, zfilesystem, zscreen, zstream
class ZUI:
"""This class encapsulates the entire user interface of a
Z-Machine, providing access to all functionality that the end-user
directly experiences or interacts with."""
def __init__(self, audio, screen, keyboard_input, filesystem):
"""Initializes the ZUI with the given components."""
assert isinstance(audio, zaudio.ZAudio)
assert isinstance(screen, zscreen.ZScreen)
assert isinstance(keyboard_input, zstream.ZInputStream)
assert isinstance(filesystem, zfilesystem.ZFilesystem)
# The following are all public attributes of the instance, but
# should be considered read-only. In the future, we may want
# to make them Python properties.
self.audio = audio
self.screen = screen
self.keyboard_input = keyboard_input
self.filesystem = filesystem

View file

@ -0,0 +1,789 @@
"""
Unit tests for the Z-machine opcodes and object parser.
These tests verify the basic behavior of each opcode by mocking the
required dependencies (memory, stack, decoder, etc).
"""
from unittest import TestCase
from unittest.mock import Mock
from mudlib.zmachine.zcpu import ZCpu, ZCpuDivideByZero, ZCpuQuit, ZCpuRestart
class MockMemory:
"""Mock memory for testing."""
def __init__(self):
self.data = bytearray(65536)
self.version = 3
self.globals = {}
def __getitem__(self, addr):
return self.data[addr]
def __setitem__(self, addr, value):
self.data[addr] = value & 0xFF
def read_word(self, addr):
return (self.data[addr] << 8) | self.data[addr + 1]
def write_word(self, addr, value):
self.data[addr] = (value >> 8) & 0xFF
self.data[addr + 1] = value & 0xFF
def read_global(self, varnum):
return self.globals.get(varnum, 0)
def write_global(self, varnum, value):
self.globals[varnum] = value
def generate_checksum(self):
"""Generate checksum from 0x40 onwards, modulo 0x10000."""
total = sum(self.data[0x40:])
return total % 0x10000
class MockStackManager:
"""Mock stack manager for testing."""
def __init__(self):
self.stack = []
self.locals = [0] * 15
def push_stack(self, value):
self.stack.append(value)
def pop_stack(self):
return self.stack.pop()
def get_local_variable(self, index):
return self.locals[index]
def set_local_variable(self, index, value):
self.locals[index] = value
def finish_routine(self, return_value):
# Mock implementation - just return a PC value
return 0x1000
class MockOpDecoder:
"""Mock opcode decoder for testing."""
def __init__(self):
self.program_counter = 0x800
self.store_address = None
self.branch_condition = True
self.branch_offset = 2
def get_store_address(self):
return self.store_address
def get_branch_offset(self):
return (self.branch_condition, self.branch_offset)
class MockUI:
"""Mock UI for testing."""
def __init__(self):
self.screen = Mock()
self.screen.write = Mock()
self.keyboard_input = Mock()
self.keyboard_input.read_line = Mock()
class ZMachineOpcodeTests(TestCase):
"""Test suite for Z-machine opcodes."""
def setUp(self):
"""Create a minimal CPU for testing."""
self.memory = MockMemory()
self.stack = MockStackManager()
self.decoder = MockOpDecoder()
self.ui = MockUI()
# Create CPU with mocked dependencies
self.cpu = ZCpu(
self.memory,
self.decoder,
self.stack,
Mock(), # objects
Mock(), # string
Mock(), # stream manager
self.ui,
Mock(), # lexer
)
def test_op_nop(self):
"""Test NOP does nothing."""
# Should just return without error
self.cpu.op_nop()
def test_op_new_line(self):
"""Test new_line prints a newline."""
self.cpu.op_new_line()
self.ui.screen.write.assert_called_once_with("\n")
def test_op_ret_popped(self):
"""Test ret_popped pops stack and returns."""
self.stack.push_stack(42)
self.cpu.op_ret_popped()
# Should have popped the value and set PC
self.assertEqual(len(self.stack.stack), 0)
self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)
def test_op_pop(self):
"""Test pop discards top of stack."""
self.stack.push_stack(100)
self.stack.push_stack(200)
self.cpu.op_pop()
self.assertEqual(len(self.stack.stack), 1)
self.assertEqual(self.stack.stack[0], 100)
def test_op_quit(self):
"""Test quit raises exception."""
with self.assertRaises(ZCpuQuit):
self.cpu.op_quit()
def test_op_dec(self):
"""Test decrement variable."""
# Set local variable 1 to 10
self.stack.set_local_variable(0, 10)
# Decrement it (variable 1 = local 0)
self.cpu.op_dec(1)
# Should be 9 now
self.assertEqual(self.stack.get_local_variable(0), 9)
def test_op_dec_wrapping(self):
"""Test decrement wraps at zero."""
# Set local variable 1 to 0
self.stack.set_local_variable(0, 0)
# Decrement it
self.cpu.op_dec(1)
# Should wrap to 65535
self.assertEqual(self.stack.get_local_variable(0), 65535)
def test_op_not(self):
"""Test bitwise NOT."""
self.decoder.store_address = 0 # Store to stack
self.cpu.op_not(0x00FF)
result = self.stack.pop_stack()
self.assertEqual(result, 0xFF00)
def test_op_not_all_ones(self):
"""Test NOT of all ones gives zero."""
self.decoder.store_address = 0
self.cpu.op_not(0xFFFF)
result = self.stack.pop_stack()
self.assertEqual(result, 0)
def test_op_load(self):
"""Test load reads variable."""
# Set local variable 2 to 42
self.stack.set_local_variable(1, 42)
self.decoder.store_address = 0 # Store to stack
# Load variable 2
self.cpu.op_load(2)
result = self.stack.pop_stack()
self.assertEqual(result, 42)
def test_op_mod_positive(self):
"""Test modulo with positive numbers."""
self.decoder.store_address = 0
self.cpu.op_mod(17, 5)
result = self.stack.pop_stack()
self.assertEqual(result, 2)
def test_op_mod_negative_dividend(self):
"""Test modulo with negative dividend."""
self.decoder.store_address = 0
# -17 mod 5 = -2 (z-machine uses C-style truncation toward zero)
self.cpu.op_mod(self.cpu._unmake_signed(-17), 5)
result = self.cpu._make_signed(self.stack.pop_stack())
self.assertEqual(result, -2)
def test_op_mod_divide_by_zero(self):
"""Test modulo by zero raises exception."""
self.decoder.store_address = 0
with self.assertRaises(ZCpuDivideByZero):
self.cpu.op_mod(10, 0)
def test_op_storeb(self):
"""Test store byte to memory."""
self.cpu.op_storeb(0x1000, 5, 0x42)
self.assertEqual(self.memory[0x1005], 0x42)
def test_op_storeb_truncates(self):
"""Test store byte truncates to 8 bits."""
self.cpu.op_storeb(0x2000, 10, 0x1FF)
self.assertEqual(self.memory[0x200A], 0xFF)
def test_op_jg_true(self):
"""Test jump if greater (signed) - true case."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(10, 5)
# Should have branched (offset - 2)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 98)
def test_op_jg_false(self):
"""Test jump if greater (signed) - false case."""
self.decoder.branch_condition = True # Branch if true (but test is false)
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(5, 10)
# Should not have branched (test is false, condition is true)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_jg_signed(self):
"""Test jump if greater handles signed comparison."""
# -1 (as unsigned 65535) should NOT be greater than 1
self.decoder.branch_condition = False
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jg(65535, 1)
# Should not branch (false condition matches)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_pull(self):
"""Test pull from stack to variable."""
# Push value onto stack
self.stack.push_stack(123)
# Pull into local variable 1
self.cpu.op_pull(1)
# Should have stored in local variable 0 (variable 1)
self.assertEqual(self.stack.get_local_variable(0), 123)
# Stack should be empty
self.assertEqual(len(self.stack.stack), 0)
def test_op_print_addr(self):
"""Test print_addr decodes and prints text at byte address."""
# Configure mock string decoder to return a known string
self.cpu._string.get = Mock(return_value="Hello, world!")
# Print text at address 0x5000
self.cpu.op_print_addr(0x5000)
# Should have called string decoder with the address
self.cpu._string.get.assert_called_once_with(0x5000)
# Should have written the decoded text
self.ui.screen.write.assert_called_once_with("Hello, world!")
def test_op_print_num_positive(self):
"""Test print_num prints positive number."""
self.cpu.op_print_num(42)
self.ui.screen.write.assert_called_once_with("42")
def test_op_print_num_negative(self):
"""Test print_num prints negative number."""
# -1 as unsigned 16-bit is 65535
self.cpu.op_print_num(65535)
self.ui.screen.write.assert_called_once_with("-1")
def test_op_print_num_zero(self):
"""Test print_num prints zero."""
self.cpu.op_print_num(0)
self.ui.screen.write.assert_called_once_with("0")
def test_op_ret(self):
"""Test ret returns from routine with value."""
self.cpu.op_ret(42)
# Should have set PC to caller's address (0x1000 from mock)
self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)
def test_op_show_status(self):
"""Test show_status is a no-op (V3 only, not needed in MUD)."""
# Should just not raise an exception
self.cpu.op_show_status()
def test_op_test_all_flags_set(self):
"""Test op_test branches when all flags are set in bitmap."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 10
old_pc = self.cpu._opdecoder.program_counter
# bitmap 0b11010110, flags 0b10010100 - all flags present
self.cpu.op_test(0b11010110, 0b10010100)
# Should branch (offset - 2)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
def test_op_test_some_flags_missing(self):
"""Test op_test doesn't branch when some flags are missing."""
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
# bitmap 0b11010110, flags 0b10011100 - bit 3 missing
self.cpu.op_test(0b11010110, 0b10011100)
# Should not branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_test_zero_flags(self):
"""Test op_test with zero flags always branches (all 0 flags set)."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 15
old_pc = self.cpu._opdecoder.program_counter
# Any bitmap with flags=0 should pass (0 & 0 == 0)
self.cpu.op_test(0b11111111, 0)
# Should branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)
def test_op_test_identical(self):
"""Test op_test branches when bitmap and flags are identical."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 20
old_pc = self.cpu._opdecoder.program_counter
# Identical bitmap and flags
self.cpu.op_test(0b10101010, 0b10101010)
# Should branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)
def test_op_verify_matching_checksum(self):
"""Test op_verify branches when checksum matches."""
# Set expected checksum at 0x1C
self.memory.write_word(0x1C, 0x1234)
# Set data that produces matching checksum
# checksum = sum(data[0x40:]) % 0x10000
# For simplicity, set one byte to produce desired checksum
self.memory[0x40] = 0x34
self.memory[0x41] = 0x12
# Sum = 0x34 + 0x12 = 0x46, need 0x1234
# Set more bytes: 0x1234 - 0x46 = 0x11EE
for i in range(0x42, 0x42 + 0x11EE):
self.memory[i] = 1 if i < 0x42 + 0x11EE else 0
self.decoder.branch_condition = True
self.decoder.branch_offset = 25
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_verify()
# Should branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 23)
def test_op_verify_mismatched_checksum(self):
"""Test op_verify doesn't branch when checksum doesn't match."""
# Set expected checksum at 0x1C
self.memory.write_word(0x1C, 0x5678)
# Memory data will produce different checksum (mostly zeros)
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_verify()
# Should not branch (checksums don't match)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
class MockObjectParser:
"""Mock object parser for testing CPU opcodes."""
def __init__(self):
self.attributes = {}
self.parents = {}
self.siblings = {}
self.children = {}
self.shortnames = {}
self.property_data_addresses = {}
self.next_properties = {}
def get_attribute(self, objnum, attrnum):
return self.attributes.get((objnum, attrnum), 0)
def set_attribute(self, objnum, attrnum):
self.attributes[(objnum, attrnum)] = 1
def clear_attribute(self, objnum, attrnum):
self.attributes[(objnum, attrnum)] = 0
def get_parent(self, objnum):
return self.parents.get(objnum, 0)
def get_sibling(self, objnum):
return self.siblings.get(objnum, 0)
def get_child(self, objnum):
return self.children.get(objnum, 0)
def remove_object(self, objnum):
# Simple implementation - just clear parent
self.parents[objnum] = 0
def get_shortname(self, objnum):
return self.shortnames.get(objnum, "object")
def get_property_data_address(self, objnum, propnum):
return self.property_data_addresses.get((objnum, propnum), 0)
def get_next_property(self, objnum, propnum):
return self.next_properties.get((objnum, propnum), 0)
def get_property_length(self, data_address):
# Simple mock - return 2 for non-zero addresses
return 2 if data_address != 0 else 0
class ZMachineObjectOpcodeTests(TestCase):
"""Test suite for Z-machine object tree opcodes."""
def setUp(self):
"""Create a CPU with mocked object parser."""
self.memory = MockMemory()
self.stack = MockStackManager()
self.decoder = MockOpDecoder()
self.ui = MockUI()
self.objects = MockObjectParser()
self.string = Mock()
self.string.get = Mock(return_value="test object")
self.cpu = ZCpu(
self.memory,
self.decoder,
self.stack,
self.objects,
self.string,
Mock(), # stream manager
self.ui,
Mock(), # lexer
)
def test_op_get_sibling_with_sibling(self):
"""Test get_sibling stores sibling and branches if nonzero."""
self.objects.siblings[5] = 7
self.decoder.store_address = 0
self.decoder.branch_condition = True
self.decoder.branch_offset = 10
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_get_sibling(5)
# Should store 7
self.assertEqual(self.stack.pop_stack(), 7)
# Should branch (offset - 2)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
def test_op_get_sibling_no_sibling(self):
"""Test get_sibling with no sibling doesn't branch."""
self.objects.siblings[5] = 0
self.decoder.store_address = 0
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_get_sibling(5)
# Should store 0
self.assertEqual(self.stack.pop_stack(), 0)
# Should not branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_test_attr_true(self):
"""Test test_attr branches when attribute is set."""
self.objects.attributes[(10, 5)] = 1
self.decoder.branch_condition = True
self.decoder.branch_offset = 20
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_test_attr(10, 5)
# Should branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)
def test_op_test_attr_false(self):
"""Test test_attr doesn't branch when attribute is clear."""
self.objects.attributes[(10, 5)] = 0
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_test_attr(10, 5)
# Should not branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_set_attr(self):
"""Test set_attr sets attribute on object."""
self.cpu.op_set_attr(15, 3)
# Should have called set_attribute
self.assertEqual(self.objects.attributes.get((15, 3)), 1)
def test_op_clear_attr(self):
"""Test clear_attr clears attribute on object."""
self.objects.attributes[(15, 3)] = 1
self.cpu.op_clear_attr(15, 3)
# Should have called clear_attribute
self.assertEqual(self.objects.attributes.get((15, 3)), 0)
def test_op_jin_true(self):
"""Test jin branches when obj1 parent equals obj2."""
self.objects.parents[5] = 10
self.decoder.branch_condition = True
self.decoder.branch_offset = 15
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jin(5, 10)
# Should branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)
def test_op_jin_false(self):
"""Test jin doesn't branch when obj1 parent not equal to obj2."""
self.objects.parents[5] = 8
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_jin(5, 10)
# Should not branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_remove_obj(self):
"""Test remove_obj removes object from parent."""
self.objects.parents[7] = 3
self.cpu.op_remove_obj(7)
# Parent should be cleared
self.assertEqual(self.objects.parents.get(7), 0)
def test_op_print_obj(self):
"""Test print_obj prints object's short name."""
self.objects.shortnames[12] = "brass lantern"
self.cpu.op_print_obj(12)
self.ui.screen.write.assert_called_once_with("brass lantern")
def test_op_get_prop_addr_found(self):
"""Test get_prop_addr stores data address when property exists."""
self.objects.property_data_addresses[(20, 5)] = 0x5000
self.decoder.store_address = 0
self.cpu.op_get_prop_addr(20, 5)
# Should store the data address
self.assertEqual(self.stack.pop_stack(), 0x5000)
def test_op_get_prop_addr_not_found(self):
"""Test get_prop_addr stores 0 when property doesn't exist."""
self.decoder.store_address = 0
self.cpu.op_get_prop_addr(20, 99)
# Should store 0
self.assertEqual(self.stack.pop_stack(), 0)
def test_op_get_next_prop_first(self):
"""Test get_next_prop with propnum=0 returns first property."""
self.objects.next_properties[(25, 0)] = 15
self.decoder.store_address = 0
self.cpu.op_get_next_prop(25, 0)
# Should store first property number
self.assertEqual(self.stack.pop_stack(), 15)
def test_op_get_next_prop_next(self):
"""Test get_next_prop with propnum>0 returns next property."""
self.objects.next_properties[(25, 10)] = 8
self.decoder.store_address = 0
self.cpu.op_get_next_prop(25, 10)
# Should store next property number
self.assertEqual(self.stack.pop_stack(), 8)
def test_op_get_prop_len(self):
"""Test get_prop_len returns property data length."""
self.decoder.store_address = 0
self.cpu.op_get_prop_len(0x6000)
# Should store 2 (from mock)
self.assertEqual(self.stack.pop_stack(), 2)
def test_op_get_prop_len_zero_addr(self):
"""Test get_prop_len with address 0 returns 0."""
self.decoder.store_address = 0
self.cpu.op_get_prop_len(0)
# Should store 0
self.assertEqual(self.stack.pop_stack(), 0)
def test_op_get_child_with_child(self):
"""Test get_child stores child and branches if nonzero."""
self.objects.children[10] = 5
self.decoder.store_address = 0
self.decoder.branch_condition = True
self.decoder.branch_offset = 12
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_get_child(10)
# Should store 5
self.assertEqual(self.stack.pop_stack(), 5)
# Should branch (offset - 2)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 10)
def test_op_get_child_no_child(self):
"""Test get_child with no child doesn't branch."""
self.objects.children[10] = 0
self.decoder.store_address = 0
self.decoder.branch_condition = True
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_get_child(10)
# Should store 0
self.assertEqual(self.stack.pop_stack(), 0)
# Should not branch
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
class ZMachineComplexOpcodeTests(TestCase):
"""Test suite for complex Z-machine opcodes (input, save/restore, restart)."""
def setUp(self):
"""Create a CPU with all necessary mocks."""
self.memory = MockMemory()
self.stack = MockStackManager()
self.decoder = MockOpDecoder()
self.ui = MockUI()
# Create CPU with mocked dependencies
self.cpu = ZCpu(
self.memory,
self.decoder,
self.stack,
Mock(), # objects
Mock(), # string
Mock(), # stream manager
self.ui,
Mock(), # lexer
)
def test_op_sread_v3_basic_input(self):
"""Test sread (V3) reads text into buffer and null-terminates."""
# Setup: text buffer at 0x1000, max length 20
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20 # max length
# Mock keyboard input
self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")
# Call sread with text buffer only (no parse buffer)
self.cpu.op_sread(text_buffer_addr, 0)
# Verify text is stored lowercased starting at offset 1
expected = "hello world"
for i, ch in enumerate(expected):
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
# Verify null termination
self.assertEqual(self.memory[text_buffer_addr + 1 + len(expected)], 0)
def test_op_sread_v3_truncates_to_max_length(self):
"""Test sread respects max length in buffer."""
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 5 # max length of 5
self.ui.keyboard_input.read_line = Mock(return_value="hello world\n")
self.cpu.op_sread(text_buffer_addr, 0)
# Should only store first 5 characters
expected = "hello"
for i, ch in enumerate(expected):
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
self.assertEqual(self.memory[text_buffer_addr + 1 + 5], 0)
def test_op_sread_v3_lowercases_input(self):
"""Test sread converts input to lowercase."""
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard_input.read_line = Mock(return_value="HELLO World\n")
self.cpu.op_sread(text_buffer_addr, 0)
expected = "hello world"
for i, ch in enumerate(expected):
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
def test_op_sread_v3_strips_newlines(self):
"""Test sread strips newlines and carriage returns."""
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard_input.read_line = Mock(return_value="test\r\n")
self.cpu.op_sread(text_buffer_addr, 0)
expected = "test"
for i, ch in enumerate(expected):
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
self.assertEqual(self.memory[text_buffer_addr + 1 + 4], 0)
def test_op_sread_v3_calls_show_status(self):
"""Test sread calls op_show_status for V3."""
text_buffer_addr = 0x1000
self.memory[text_buffer_addr] = 20
self.ui.keyboard_input.read_line = Mock(return_value="test\n")
# Track if show_status was called
call_count = [0]
original = self.cpu.op_show_status
def counted_show_status(*args):
call_count[0] += 1
return original(*args)
self.cpu.op_show_status = counted_show_status # type: ignore
self.cpu.op_sread(text_buffer_addr, 0)
# Should have called show_status once
self.assertEqual(call_count[0], 1)
def test_op_save_v3_branches_false(self):
"""Test save (V3) branches false (QuetzalWriter not functional)."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_save()
# Should not have branched (test is false)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_restore_v3_branches_false(self):
"""Test restore (V3) branches false (no valid save files)."""
self.decoder.branch_condition = True
self.decoder.branch_offset = 100
old_pc = self.cpu._opdecoder.program_counter
self.cpu.op_restore()
# Should not have branched (test is false)
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
def test_op_input_stream_is_noop(self):
"""Test input_stream is a no-op stub."""
# Should not raise
self.cpu.op_input_stream(1)
def test_op_sound_effect_is_noop(self):
"""Test sound_effect is a no-op stub."""
# Should not raise
self.cpu.op_sound_effect(1, 2, 3)
def test_op_restart_raises_exception(self):
"""Test restart raises ZCpuRestart exception for run loop to handle."""
with self.assertRaises(ZCpuRestart):
self.cpu.op_restart()
# Note: ZObjectParser methods are tested through integration tests
# with real story files, not unit tests with mock memory, as the
# interaction with ZStringFactory makes mocking complex.