Compare commits

..

6 commits

Author SHA1 Message Date
74538756d5
Handle object 0 (nothing) gracefully in object parser
Per the Z-machine spec, object 0 means "nothing" and operations on it
should be safe no-ops: get_child/get_sibling/get_parent return 0,
test_attr returns false, set/clear_attr are no-ops, etc. Previously
these threw ZObjectIllegalObjectNumber, crashing on games like Curses
that pass object 0 to get_child during room transitions.
2026-02-10 18:32:36 -05:00
5a98adb6ee
Add instruction tracing to step_fast and improve error messages
step_fast() never recorded trace entries, so crash dumps always showed
an empty trace. Now records PC + opcode info in the same deque as
step(). Also includes exception type in player-facing error messages
when the exception string is empty.
2026-02-10 18:29:27 -05:00
c8d9bdfae9
Map empty Enter to ZSCII 13 in read_char
MudInputStream.read_char() returned 0 for empty input, which no game
recognizes as a valid keypress. Now returns 13 (Enter/Return) so
"press any key" prompts like Curses' intro work from a MUD client.
2026-02-10 18:26:41 -05:00
fd977b91a2
Guard bare > stripping with has_prompt check 2026-02-10 17:53:11 -05:00
b81bc3edc8
Add consistent > prompt for IF mode in server loop 2026-02-10 17:50:35 -05:00
ac1d16095e
Strip trailing > prompt from embedded z-machine output 2026-02-10 17:50:06 -05:00
5 changed files with 58 additions and 6 deletions

View file

@ -36,6 +36,20 @@ class EmbeddedIFSession:
self._zmachine = ZMachine(story_bytes, self._ui) self._zmachine = ZMachine(story_bytes, self._ui)
self._filesystem = self._ui.filesystem self._filesystem = self._ui.filesystem
def _strip_prompt(self, output: str) -> str:
"""Strip trailing > prompt from game output (matches dfrotz behavior)."""
has_prompt = (
output.endswith("> ") or output.endswith(">\n") or output.endswith(">\r\n")
)
text = output.rstrip()
if text.endswith("\n>"):
return text[:-2].rstrip()
if text == ">":
return ""
if has_prompt and text.endswith(">"):
return text[:-1].rstrip()
return output
@property @property
def save_path(self) -> Path: def save_path(self) -> Path:
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name) safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name)
@ -100,6 +114,7 @@ class EmbeddedIFSession:
# buffer), producing unwanted output. Suppress it and only show the # buffer), producing unwanted output. Suppress it and only show the
# restore confirmation. # restore confirmation.
return "restoring saved game...\r\nrestored." return "restoring saved game...\r\nrestored."
output = self._strip_prompt(output)
return output return output
async def handle_input(self, text: str) -> IFResponse: async def handle_input(self, text: str) -> IFResponse:
@ -130,6 +145,7 @@ class EmbeddedIFSession:
await loop.run_in_executor(None, wait_for_next_input) await loop.run_in_executor(None, wait_for_next_input)
output = self._screen.flush() output = self._screen.flush()
output = self._strip_prompt(output)
if self._done and self._error: if self._done and self._error:
output = f"{output}\r\n{self._error}" if output else self._error output = f"{output}\r\n{self._error}" if output else self._error
return IFResponse(output=output, done=self._done) return IFResponse(output=output, done=self._done)
@ -149,7 +165,8 @@ class EmbeddedIFSession:
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
logger.error(f"Interpreter crashed:\n{tb}") logger.error(f"Interpreter crashed:\n{tb}")
self._error = f"interpreter error: {e}" msg = str(e) or type(e).__name__
self._error = f"interpreter error: {msg}"
finally: finally:
self._done = True self._done = True
self._keyboard._waiting.set() self._keyboard._waiting.set()

View file

@ -315,8 +315,7 @@ async def shell(
if player.mode == "editor" and player.editor: if player.mode == "editor" and player.editor:
_writer.write(f" {player.editor.cursor + 1}> ") _writer.write(f" {player.editor.cursor + 1}> ")
elif player.mode == "if" and player.if_session: elif player.mode == "if" and player.if_session:
# IF mode: game writes its own prompt, don't add another _writer.write("> ")
pass
else: else:
_writer.write("mud> ") _writer.write("mud> ")
await _writer.drain() await _writer.drain()

View file

@ -107,7 +107,9 @@ class MudInputStream(zstream.ZInputStream):
text = self._input_queue.get() text = self._input_queue.get()
if text: if text:
return ord(text[0]) return ord(text[0])
return 0 # Player hit Enter with no text — return 13 (ZSCII Enter/Return)
# so "press any key" prompts work in a line-oriented MUD client.
return 13
def feed(self, text: str): def feed(self, text: str):
self._input_queue.put(text) self._input_queue.put(text)

View file

@ -226,17 +226,27 @@ class ZCpu:
return table return table
def step_fast(self): def step_fast(self):
"""Execute a single instruction without tracing. """Execute a single instruction with lightweight tracing.
Returns True if execution should continue. Returns True if execution should continue.
""" """
current_pc = self._opdecoder.program_counter
(opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction() (opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction()
entry = self._dispatch[opcode_class][opcode_number] entry = self._dispatch[opcode_class][opcode_number]
if entry is None: if entry is None:
raise ZCpuIllegalInstruction self._trace.append(f" {current_pc:06x} ILLEGAL")
self._dump_trace()
raise ZCpuIllegalInstruction(
f"illegal opcode class={opcode_class} num={opcode_number}"
f" at PC={current_pc:#x}"
)
implemented, func = entry implemented, func = entry
if not implemented: if not implemented:
return False return False
self._trace.append(
f" {current_pc:06x} {func.__name__}"
f"({', '.join(str(x) for x in operands)})"
)
try: try:
func(self, *operands) func(self, *operands)
except (ZCpuQuit, ZCpuRestart): except (ZCpuQuit, ZCpuRestart):

View file

@ -163,6 +163,8 @@ class ZObjectParser:
def get_attribute(self, objectnum, attrnum): def get_attribute(self, objectnum, attrnum):
"""Return value (0 or 1) of attribute number ATTRNUM of object """Return value (0 or 1) of attribute number ATTRNUM of object
number OBJECTNUM.""" number OBJECTNUM."""
if objectnum == 0:
return 0
object_addr = self._get_object_addr(objectnum) object_addr = self._get_object_addr(objectnum)
@ -183,6 +185,8 @@ class ZObjectParser:
def set_attribute(self, objectnum, attrnum): def set_attribute(self, objectnum, attrnum):
"""Set attribute number ATTRNUM of object number OBJECTNUM to 1.""" """Set attribute number ATTRNUM of object number OBJECTNUM to 1."""
if objectnum == 0:
return
object_addr = self._get_object_addr(objectnum) object_addr = self._get_object_addr(objectnum)
@ -206,6 +210,8 @@ class ZObjectParser:
def clear_attribute(self, objectnum, attrnum): def clear_attribute(self, objectnum, attrnum):
"""Clear attribute number ATTRNUM of object number OBJECTNUM to 0.""" """Clear attribute number ATTRNUM of object number OBJECTNUM to 0."""
if objectnum == 0:
return
object_addr = self._get_object_addr(objectnum) object_addr = self._get_object_addr(objectnum)
@ -247,18 +253,24 @@ class ZObjectParser:
def get_parent(self, objectnum): def get_parent(self, objectnum):
"""Return object number of parent of object number OBJECTNUM.""" """Return object number of parent of object number OBJECTNUM."""
if objectnum == 0:
return 0
[parent, sibling, child] = self._get_parent_sibling_child(objectnum) [parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return parent return parent
def get_child(self, objectnum): def get_child(self, objectnum):
"""Return object number of child of object number OBJECTNUM.""" """Return object number of child of object number OBJECTNUM."""
if objectnum == 0:
return 0
[parent, sibling, child] = self._get_parent_sibling_child(objectnum) [parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return child return child
def get_sibling(self, objectnum): def get_sibling(self, objectnum):
"""Return object number of sibling of object number OBJECTNUM.""" """Return object number of sibling of object number OBJECTNUM."""
if objectnum == 0:
return 0
[parent, sibling, child] = self._get_parent_sibling_child(objectnum) [parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return sibling return sibling
@ -298,6 +310,8 @@ class ZObjectParser:
def remove_object(self, objectnum): def remove_object(self, objectnum):
"""Detach object OBJECTNUM from its parent (unlink from sibling chain).""" """Detach object OBJECTNUM from its parent (unlink from sibling chain)."""
if objectnum == 0:
return
parent = self.get_parent(objectnum) parent = self.get_parent(objectnum)
if parent == 0: if parent == 0:
@ -335,6 +349,8 @@ class ZObjectParser:
Per the Z-spec: if new_child already has a parent, it is first Per the Z-spec: if new_child already has a parent, it is first
removed from that parent's child list, then made the first child removed from that parent's child list, then made the first child
of parent_object.""" of parent_object."""
if parent_object == 0 or new_child == 0:
return
# Remove from old parent first (spec says "first removed") # Remove from old parent first (spec says "first removed")
self.remove_object(new_child) self.remove_object(new_child)
@ -347,6 +363,8 @@ class ZObjectParser:
def get_shortname(self, objectnum): def get_shortname(self, objectnum):
"""Return 'short name' of object number OBJECTNUM as ascii string.""" """Return 'short name' of object number OBJECTNUM as ascii string."""
if objectnum == 0:
return ""
addr = self._get_proptable_addr(objectnum) addr = self._get_proptable_addr(objectnum)
return self._stringfactory.get(addr + 1) return self._stringfactory.get(addr + 1)
@ -354,6 +372,8 @@ class ZObjectParser:
def get_prop(self, objectnum, propnum): def get_prop(self, objectnum, propnum):
"""Return either a byte or word value of property PROPNUM of """Return either a byte or word value of property PROPNUM of
object OBJECTNUM.""" object OBJECTNUM."""
if objectnum == 0:
return 0
(addr, size) = self.get_prop_addr_len(objectnum, propnum) (addr, size) = self.get_prop_addr_len(objectnum, propnum)
if size == 1: if size == 1:
return self._memory[addr] return self._memory[addr]
@ -467,6 +487,8 @@ class ZObjectParser:
def get_property_data_address(self, objectnum, propnum): def get_property_data_address(self, objectnum, propnum):
"""Return the address of property PROPNUM's data bytes for object """Return the address of property PROPNUM's data bytes for object
OBJECTNUM. Return 0 if the object doesn't have that property.""" OBJECTNUM. Return 0 if the object doesn't have that property."""
if objectnum == 0:
return 0
try: try:
addr, size = self.get_prop_addr_len(objectnum, propnum) addr, size = self.get_prop_addr_len(objectnum, propnum)
@ -484,6 +506,8 @@ class ZObjectParser:
"""If PROPNUM is 0, return the first property number of object OBJECTNUM. """If PROPNUM is 0, return the first property number of object OBJECTNUM.
Otherwise, return the property number after PROPNUM in the property list. Otherwise, return the property number after PROPNUM in the property list.
Return 0 if there are no more properties.""" Return 0 if there are no more properties."""
if objectnum == 0:
return 0
if propnum == 0: if propnum == 0:
# Return first property number # Return first property number