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._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
def save_path(self) -> Path:
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
# restore confirmation.
return "restoring saved game...\r\nrestored."
output = self._strip_prompt(output)
return output
async def handle_input(self, text: str) -> IFResponse:
@ -130,6 +145,7 @@ class EmbeddedIFSession:
await loop.run_in_executor(None, wait_for_next_input)
output = self._screen.flush()
output = self._strip_prompt(output)
if self._done and self._error:
output = f"{output}\r\n{self._error}" if output else self._error
return IFResponse(output=output, done=self._done)
@ -149,7 +165,8 @@ class EmbeddedIFSession:
except Exception as e:
tb = traceback.format_exc()
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:
self._done = True
self._keyboard._waiting.set()

View file

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

View file

@ -107,7 +107,9 @@ class MudInputStream(zstream.ZInputStream):
text = self._input_queue.get()
if text:
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):
self._input_queue.put(text)

View file

@ -226,17 +226,27 @@ class ZCpu:
return table
def step_fast(self):
"""Execute a single instruction without tracing.
"""Execute a single instruction with lightweight tracing.
Returns True if execution should continue.
"""
current_pc = self._opdecoder.program_counter
(opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction()
entry = self._dispatch[opcode_class][opcode_number]
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
if not implemented:
return False
self._trace.append(
f" {current_pc:06x} {func.__name__}"
f"({', '.join(str(x) for x in operands)})"
)
try:
func(self, *operands)
except (ZCpuQuit, ZCpuRestart):

View file

@ -163,6 +163,8 @@ class ZObjectParser:
def get_attribute(self, objectnum, attrnum):
"""Return value (0 or 1) of attribute number ATTRNUM of object
number OBJECTNUM."""
if objectnum == 0:
return 0
object_addr = self._get_object_addr(objectnum)
@ -183,6 +185,8 @@ class ZObjectParser:
def set_attribute(self, objectnum, attrnum):
"""Set attribute number ATTRNUM of object number OBJECTNUM to 1."""
if objectnum == 0:
return
object_addr = self._get_object_addr(objectnum)
@ -206,6 +210,8 @@ class ZObjectParser:
def clear_attribute(self, objectnum, attrnum):
"""Clear attribute number ATTRNUM of object number OBJECTNUM to 0."""
if objectnum == 0:
return
object_addr = self._get_object_addr(objectnum)
@ -247,18 +253,24 @@ class ZObjectParser:
def get_parent(self, objectnum):
"""Return object number of parent of object number OBJECTNUM."""
if objectnum == 0:
return 0
[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."""
if objectnum == 0:
return 0
[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."""
if objectnum == 0:
return 0
[parent, sibling, child] = self._get_parent_sibling_child(objectnum)
return sibling
@ -298,6 +310,8 @@ class ZObjectParser:
def remove_object(self, objectnum):
"""Detach object OBJECTNUM from its parent (unlink from sibling chain)."""
if objectnum == 0:
return
parent = self.get_parent(objectnum)
if parent == 0:
@ -335,6 +349,8 @@ class ZObjectParser:
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
of parent_object."""
if parent_object == 0 or new_child == 0:
return
# Remove from old parent first (spec says "first removed")
self.remove_object(new_child)
@ -347,6 +363,8 @@ class ZObjectParser:
def get_shortname(self, objectnum):
"""Return 'short name' of object number OBJECTNUM as ascii string."""
if objectnum == 0:
return ""
addr = self._get_proptable_addr(objectnum)
return self._stringfactory.get(addr + 1)
@ -354,6 +372,8 @@ class ZObjectParser:
def get_prop(self, objectnum, propnum):
"""Return either a byte or word value of property PROPNUM of
object OBJECTNUM."""
if objectnum == 0:
return 0
(addr, size) = self.get_prop_addr_len(objectnum, propnum)
if size == 1:
return self._memory[addr]
@ -467,6 +487,8 @@ class ZObjectParser:
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."""
if objectnum == 0:
return 0
try:
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.
Otherwise, return the property number after PROPNUM in the property list.
Return 0 if there are no more properties."""
if objectnum == 0:
return 0
if propnum == 0:
# Return first property number