From 8a5ef7b1f67695f36eacd3eed2282a2dc5bd3d0c Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 10 Feb 2026 13:51:28 -0500 Subject: [PATCH] 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. --- src/mudlib/zmachine/zcpu.py | 209 ++++++++++++++++++++++++++++++------ 1 file changed, 179 insertions(+), 30 deletions(-) diff --git a/src/mudlib/zmachine/zcpu.py b/src/mudlib/zmachine/zcpu.py index 0c568e1..81e8a71 100644 --- a/src/mudlib/zmachine/zcpu.py +++ b/src/mudlib/zmachine/zcpu.py @@ -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