Compare commits
36 commits
47ef606e7f
...
8097bbcf55
| Author | SHA1 | Date | |
|---|---|---|---|
| 8097bbcf55 | |||
| ec4e53b2d4 | |||
| 1b08c36b85 | |||
| bbd70e8577 | |||
| 6e62ca203b | |||
| fa31f39a65 | |||
| f8f5ac7ad0 | |||
| 127ca5f56e | |||
| 5ffbe660fb | |||
| 2ce82e7d87 | |||
| 2cf303dd67 | |||
| 48727c4690 | |||
| 8ba1701c9f | |||
| 0f5fada29e | |||
| e6b63622fa | |||
| 9a0bacd581 | |||
| 1bcb83961a | |||
| 28a097997c | |||
| eeb65e0cc2 | |||
| e5329d6788 | |||
| 311a67e80a | |||
| 8d749fbb7e | |||
| 9116777603 | |||
| e1c6a92368 | |||
| 8eb2371ce1 | |||
| 61765fa6ba | |||
| e0e2e84dc2 | |||
| d218929f02 | |||
| fb8cbf7219 | |||
| 205e2716dd | |||
| 72dd047b7b | |||
| c76ee337d3 | |||
| 1b9d84f41a | |||
| 5ea030a0ac | |||
| dcc952d4c5 | |||
| 677ddac89f |
29 changed files with 7220 additions and 4 deletions
|
|
@ -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
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ data
|
|||
.worktrees
|
||||
.testmondata
|
||||
*.z*
|
||||
debug.log
|
||||
disasm.log
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-----------------
|
||||
|
||||
|
|
|
|||
344
docs/how/zmachine-garbled-output-investigation.rst
Normal file
344
docs/how/zmachine-garbled-output-investigation.rst
Normal 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
189
scripts/debug_zstrings.py
Executable 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
110
scripts/run_zork1.py
Executable 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
621
scripts/trace_opcodes.py
Normal 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()
|
||||
27
src/mudlib/zmachine/LICENSE
Normal file
27
src/mudlib/zmachine/LICENSE
Normal 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.
|
||||
8
src/mudlib/zmachine/__init__.py
Normal file
8
src/mudlib/zmachine/__init__.py
Normal 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"]
|
||||
62
src/mudlib/zmachine/bitfield.py
Normal file
62
src/mudlib/zmachine/bitfield.py
Normal 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
354
src/mudlib/zmachine/glk.py
Normal 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.")
|
||||
462
src/mudlib/zmachine/quetzal.py
Normal file
462
src/mudlib/zmachine/quetzal.py
Normal 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.")
|
||||
406
src/mudlib/zmachine/trivialzui.py
Normal file
406
src/mudlib/zmachine/trivialzui.py
Normal 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(" "),
|
||||
)
|
||||
76
src/mudlib/zmachine/zaudio.py
Normal file
76
src/mudlib/zmachine/zaudio.py
Normal 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
1040
src/mudlib/zmachine/zcpu.py
Normal file
File diff suppressed because it is too large
Load diff
63
src/mudlib/zmachine/zfilesystem.py
Normal file
63
src/mudlib/zmachine/zfilesystem.py
Normal 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()
|
||||
136
src/mudlib/zmachine/zlexer.py
Normal file
136
src/mudlib/zmachine/zlexer.py
Normal 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
|
||||
46
src/mudlib/zmachine/zlogging.py
Normal file
46
src/mudlib/zmachine/zlogging.py
Normal 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}")
|
||||
52
src/mudlib/zmachine/zmachine.py
Normal file
52
src/mudlib/zmachine/zmachine.py
Normal 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()
|
||||
338
src/mudlib/zmachine/zmemory.py
Normal file
338
src/mudlib/zmachine/zmemory.py
Normal 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
|
||||
591
src/mudlib/zmachine/zobjectparser.py
Normal file
591
src/mudlib/zmachine/zobjectparser.py
Normal 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()
|
||||
244
src/mudlib/zmachine/zopdecoder.py
Normal file
244
src/mudlib/zmachine/zopdecoder.py
Normal 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
|
||||
290
src/mudlib/zmachine/zscreen.py
Normal file
290
src/mudlib/zmachine/zscreen.py
Normal 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()
|
||||
209
src/mudlib/zmachine/zstackmanager.py
Normal file
209
src/mudlib/zmachine/zstackmanager.py
Normal 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
|
||||
103
src/mudlib/zmachine/zstream.py
Normal file
103
src/mudlib/zmachine/zstream.py
Normal 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()
|
||||
100
src/mudlib/zmachine/zstreammanager.py
Normal file
100
src/mudlib/zmachine/zstreammanager.py
Normal 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)
|
||||
480
src/mudlib/zmachine/zstring.py
Normal file
480
src/mudlib/zmachine/zstring.py
Normal 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)
|
||||
31
src/mudlib/zmachine/zui.py
Normal file
31
src/mudlib/zmachine/zui.py
Normal 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
|
||||
789
tests/test_zmachine_opcodes.py
Normal file
789
tests/test_zmachine_opcodes.py
Normal 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.
|
||||
Loading…
Reference in a new issue