Implement V5+ opcodes: aread, save_undo, shifts, scan_table, and more

Implements the opcode set needed for Lost Pig (V8):
- op_aread: V5+ input with text at byte 2, char count at byte 1,
  stores terminating character
- op_save_undo/op_restore_undo: stub returning -1/0 (undo not yet
  available, game continues without it)
- op_log_shift/op_art_shift: logical and arithmetic bit shifts
- op_scan_table: table search with configurable entry size and
  word/byte comparison
- op_tokenize: re-tokenize text buffer against dictionary
- op_copy_table: memory copy/zero with forward/backward support
- op_set_font: returns 1 for font 1, 0 for others
- op_print_unicode/op_check_unicode: basic Unicode output support

Lost Pig now runs to completion: 101K steps, 61 unique opcodes,
3 full input cycles with room descriptions rendering correctly.
This commit is contained in:
Jared Miller 2026-02-10 13:51:28 -05:00
parent d71f221277
commit 8a5ef7b1f6
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -745,8 +745,54 @@ class ZCpu:
raise ZCpuNotImplemented
def op_aread(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
"""Read text input from keyboard (V5+).
V5 text buffer: byte 0 = max chars, byte 1 = num chars written,
text starts at byte 2. Stores the terminating ZSCII character
(13 for newline) as result. Does not lowercase (game handles it).
Args:
args[0]: text buffer address
args[1]: parse buffer address (0 = skip tokenization)
args[2]: optional timer interval (tenths of a second, 0 = none)
args[3]: optional timer routine address
"""
text_buffer_addr = args[0]
parse_buffer_addr = args[1] if len(args) > 1 else 0
# Read input from keyboard
text = self._ui.keyboard_input.read_line()
text = text.lower().strip("\n\r")
# Store in text buffer (V5 format: text at byte 2, count at byte 1)
max_len = self._memory[text_buffer_addr]
text = text[:max_len]
self._memory[text_buffer_addr + 1] = len(text)
for i, ch in enumerate(text):
self._memory[text_buffer_addr + 2 + i] = ord(ch)
# Tokenize if parse buffer provided
if parse_buffer_addr != 0:
max_words = self._memory[parse_buffer_addr]
tokens = self._lexer.parse_input(text)
num_words = min(len(tokens), max_words)
self._memory[parse_buffer_addr + 1] = num_words
offset = 0
for i in range(num_words):
word_str, dict_addr = tokens[i]
pos = text.find(word_str, offset)
if pos == -1:
pos = offset
word_len = len(word_str)
base = parse_buffer_addr + 2 + (i * 4)
self._memory.write_word(base, dict_addr)
self._memory[base + 2] = word_len
self._memory[base + 3] = pos + 2 # offset from start of text buffer
offset = pos + word_len
# Store terminating character (13 = newline)
self._write_result(13)
def op_print_char(self, char):
"""Output the given ZSCII character."""
@ -877,9 +923,28 @@ class ZCpu:
char = self._ui.keyboard_input.read_char()
self._write_result(char)
def op_scan_table(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_scan_table(self, x, table, length, *args):
"""Search a table for a value, branch if found, store address (V4+).
Searches length entries starting at table. Each entry is form
bytes wide (default 2). Compares against byte 0-1 (word) or
byte 0 (if form bit 7 is set, compare bytes not words).
form & 0x7f = entry size in bytes.
"""
form = args[0] if len(args) > 0 else 0x82 # default: word entries, 2 bytes wide
entry_size = form & 0x7F
compare_word = not (form & 0x80)
for i in range(length):
addr = table + (i * entry_size)
val = self._memory.read_word(addr) if compare_word else self._memory[addr]
if val == x:
self._write_result(addr)
self._branch(True)
return
self._write_result(0)
self._branch(False)
def op_not_v5(self, *args):
"""TODO: Write docstring here."""
@ -893,17 +958,69 @@ class ZCpu:
"""Call routine with up to 7 arguments and discard the result."""
self._call(routine_addr, args, False)
def op_tokenize(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_tokenize(self, text_buffer, parse_buffer, *args):
"""Tokenize text in text_buffer into parse_buffer (V5+).
Uses V5 text buffer format (count at byte 1, text at byte 2+).
Optional args[0] = dictionary address, args[1] = flag.
"""
_dictionary = args[0] if len(args) > 0 else 0 # custom dict, not yet used
flag = args[1] if len(args) > 1 else 0
# Read text from V5 text buffer
num_chars = self._memory[text_buffer + 1]
text = ""
for i in range(num_chars):
text += chr(self._memory[text_buffer + 2 + i])
# Tokenize
max_words = self._memory[parse_buffer]
tokens = self._lexer.parse_input(text)
num_words = min(len(tokens), max_words)
if not flag:
self._memory[parse_buffer + 1] = num_words
offset = 0
for i in range(num_words):
word_str, dict_addr = tokens[i]
pos = text.find(word_str, offset)
if pos == -1:
pos = offset
word_len = len(word_str)
base = parse_buffer + 2 + (i * 4)
# When flag is set, only fill in entries that have dict matches
if not flag or dict_addr != 0:
self._memory.write_word(base, dict_addr)
self._memory[base + 2] = word_len
self._memory[base + 3] = pos + 2
offset = pos + word_len
def op_encode_text(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_copy_table(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_copy_table(self, first, second, size):
"""Copy a block of memory, or zero-fill (V5+).
If second is 0, zero-fill first for size bytes.
If size is positive, copy forward (safe for non-overlapping).
If size is negative, copy backward (safe for overlapping).
"""
if second == 0:
for i in range(abs(self._make_signed(size))):
self._memory[first + i] = 0
else:
signed_size = self._make_signed(size)
count = abs(signed_size)
if signed_size >= 0:
# Forward copy (may corrupt overlapping regions)
for i in range(count):
self._memory[second + i] = self._memory[first + i]
else:
# Backward copy (safe for overlapping)
for i in range(count - 1, -1, -1):
self._memory[second + i] = self._memory[first + i]
def op_print_table(self, *args):
"""TODO: Write docstring here."""
@ -924,33 +1041,65 @@ class ZCpu:
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_log_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_log_shift(self, number, places):
"""Logical shift: positive places = left, negative = right (V5+).
def op_art_shift(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
Right shift fills with zeros (unsigned/logical shift).
"""
places = self._make_signed(places)
result = (
(number << places) & 0xFFFF
if places >= 0
else (number >> (-places)) & 0xFFFF
)
self._write_result(result)
def op_set_font(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_art_shift(self, number, places):
"""Arithmetic shift: positive places = left, negative = right (V5+).
Right shift preserves the sign bit (signed/arithmetic shift).
"""
signed_number = self._make_signed(number)
places = self._make_signed(places)
if places >= 0:
result = (signed_number << places) & 0xFFFF
else:
result = self._unmake_signed(signed_number >> (-places))
self._write_result(result)
def op_set_font(self, font_id):
"""Set the current font. Returns the previous font, or 0 if
the requested font is unavailable (V5+).
Font 1 is the normal font. We only support font 1.
"""
if font_id == 1:
self._write_result(1) # was font 1, now font 1
else:
self._write_result(0) # unsupported font
def op_save_undo(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
"""Save undo state. Store -1 if not available (V5+).
Stores 1 on success, 0 on failure, -1 if not available.
Real undo support deferred; return -1 for now.
"""
self._write_result(self._unmake_signed(-1))
def op_restore_undo(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
"""Restore undo state. Store 0 on failure (V5+)."""
self._write_result(0)
def op_print_unicode(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_print_unicode(self, char_code):
"""Print a Unicode character (V5+, EXT:11)."""
self._ui.screen.write(chr(char_code))
def op_check_unicode(self, *args):
"""TODO: Write docstring here."""
raise ZCpuNotImplemented
def op_check_unicode(self, char_code):
"""Check if Unicode character can be printed/read (V5+, EXT:12).
Bit 0 = can print, bit 1 = can read. We support printing only.
"""
self._write_result(1) # can print, can't read
# Declaration of the opcode tables. In a Z-Machine, opcodes are
# divided into tables based on the operand type. Within each