#!/usr/bin/env @PYCMD@
# -*- coding: utf-8 -*-
########################################################################
# simh/__init__.py - A wrapper class around pexpect for communicating
#   with an instance of the PiDP-8/I SIMH simulator running OS/8.
#
#   See ../doc/class-simh.md for a usage tutorial.
#
# Copyright © 2017 by Jonathan Trites, © 2017-2025 by William Cattey
# and Warren Young.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS LISTED ABOVE BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Except as contained in this notice, the names of the authors above
# shall not be used in advertising or otherwise to promote the sale,
# use or other dealings in this Software without prior written
# authorization from those authors.
########################################################################

import os
import pkg_resources
import subprocess
import time
import re
import sys
from typing import TYPE_CHECKING, Pattern, Union, Type, List, TextIO, cast
import pexpect

import pidp8i

if TYPE_CHECKING:
    from pexpect import spawn as Spawn

#### simh #############################################################
# Object to manage a running SIMH process programmatically with pexpect.
# Functions in this file are organized from lower level to higher
# level where possible.


#### Private functions ################################################

#### Abstract Definitions for Pexpect use #############################

InputRePattern = Union[
    str, bytes, Pattern[str], Pattern[bytes], Type[pexpect.EOF], Type[pexpect.TIMEOUT]
]

ExpectPattern = Union[InputRePattern, List[InputRePattern]]
#######################################################################
#  simh class


class Simh:
    #### constants ######################################################
    # pexpect object instance, set by ctor

    # Constant used by os8_kbd_delay, assembled in stages:
    #
    # 1. PDP-8 RS-232 bits per character: 7-bit ASCII plus necessary
    #    start, stop, and parity bits.
    #
    # 2. The ratio of the instructions per second ratios of a PDP-8/I to
    #    that of the host hardware running the simulator.  The former is
    #    an approximate value; see lib/pidp8i/ips.py.in for the value and
    #    its defense.  The latter is either the IPS rate for:
    #
    #    a) a Raspberry Pi Model B+, that being the slowest host system
    #       we run this simulator on; or
    #
    #    b) the IPS rate of the actual host hardware if you have run the
    #       "bin/teco-pi-demo -b" benchmark on it.
    #
    # 2. The fact that real PDP-8s ran OS/8 reliably at 300 bps, and have
    #    been claimed to get flaky as early as 600 bps by some.  (Others
    #    claim to have run them up to 9,600 bps.)
    #
    # 3. The "safe BPS" value is the fastest bit per second speed actual
    #    PDP-8 hardware was known to run OS/8 terminal I/O at.  In this
    #    case, it is the high-speed tape reader.
    #
    #    TODO: We may be able to increase this.
    #
    #    We have one report that OS/8 was tested with terminals up to
    #    about ~600 bps before becoming unreliable.
    #
    #    We have another report that OS/8 could run under ETOS with
    #    9,600 bps terminals, but we don't know if that tells us anything
    #    about OS/8 running without the ETOS multitasking hardware.
    #
    # 4. Given above, calculate safe characters per second for host HW.
    #
    # 5. Invert to get seconds per character, that being the delay value.
    _bpc = 7 + 1 + 1 + 1  # [1]
    _ips_ratio = float(pidp8i.ips.current) / pidp8i.ips.pdp8i  # [2]
    _pdp8i_safe_bps = 300  # [3]
    _host_safe_cps = _pdp8i_safe_bps * _ips_ratio / _bpc  # [4]
    _os8_kbd_delay: float = 1 / _host_safe_cps  # [5]

    #### _simh_replies #################################################
    # Array of items that describe replies one might get from SIMH.
    # Each item is a 3 element tuple consisting of:
    #    [0] A help string that describes the reply.
    #        Match against this string when testing error values.
    #        This value may not be unique. When not unique
    #        the multiple values should represent the same error.
    #
    #    [1] A regular expression for pexpect to use to match on it.
    #        It is a good idea to terminate status messages with "\r"
    #        in order to confirm we've got the whole line,
    #        and prompts with "$" to terminate the search.
    #
    #        For speed and efficiency an array of compiled
    #        regular expressions, indexed to _simh_replies
    #        is created by __init__: simh_replies_rex
    #
    #   [2] A boolean that is ignored within simh,
    #       but included here to enable easy use of
    #       these replies along with the OS/8 replies.
    #       The replies interface holds both kinds.
    #       The context determines if we use the third element.

    _simh_replies: list[tuple[str, str, bool]] = [
        # Prompts:
        ("Prompt", r"sim> $", False),
        # Guard against case we used the simh table when we meant the OS/8 table.
        ("Monitor Prompt", r"\n\.$", False),
        ("Non-existent device", r"Non-existent device", False),
    ]

    ####_os8_replies ##################################################
    # Simiar to _simh_replies, but describing replies from OS/8
    # when it is running under SIMH.
    # Each item is a 3 element array consisting of:
    #    [0] A help string that describes the reply.
    #        Match against this string when testing error values.
    #        This value may not be unique. When not unique
    #        the multiple values should represent the same error.
    #
    #    [1] A regular expression for pexpect to use to match on it.
    #        It is a good idea to terminate status messages with "\r"
    #        in order to confirm we've got the whole line,
    #        and prompts with "$" to terminate the search.
    #
    #        For speed and efficiency an array of compiled
    #        regular expressions, indexed to _simh_replies
    #        is created by __init__: os8_replies_rex
    #
    #    [2] A True/False indicator if the command returned to the
    #        keyboard monitor, rather than continuing in the program.
    #        True for returned to the keyboard monitor.
    #        False for did not return to monitor, but intead stayed running.

    _os8_replies: list[tuple[str, str, bool]] = [
        # Prompts:
        ("Monitor Prompt", r"\n\.$", True),
        ("Command Decoder Prompt", r"\n\*$", False),
        ("PIP Continue", r"\^$", True),  # Newline NOT always present!
        # OS/8 Handbook 1974 page 1-43/81 Keyboard Monitor Error Messages:
        (
            "Directory I/O Error",
            r"MONITOR ERROR 2 AT \d+ \(DIRECTORY I/O ERROR\)",
            True,
        ),
        ("I/O Error on SYS", r"MONITOR ERROR 5 AT \d+ \(I/O ERROR ON SYS\)", True),
        (
            "Directory I/O Error",
            r"MONITOR ERROR 6 AT \d+ \(DIRECTORY I/O ERROR\)\r",
            True,
        ),
        ("Device not available", r"(\S+) NOT AVAILABLE", False),
        ("File not found", r"(\S+) NOT FOUND", False),
        #  OS/8 Handbook 1974 page 1-51/89 Command Decoder Error Messages
        ("Illegal Syntax", r"ILLEGAL SYNTAX", False),
        ("File does not exist", r"(\S+) DOES NOT EXIST", False),
        #  ("(\S+) NOT FOUND", False),                                 # See above
        ("Too many files", r"TOO MANY FILES", False),
        #  OS/8 Handbook 1974 page 1-75/113 CCL Error Messages
        ("Bad Device", r"BAD DEVICE", False),
        ("Bad Extension", r"BAD EXTENSION", False),
        # "",  OS/8 Handbook 1974 page 1-106/144 PIP Error Messages
        ("ARE YOU SURE?", r"ARE YOU SURE\?", False),
        ("BAD DIRECTORY ON DEVICE", r"BAD DIRECTORY ON DEVICE #\s?\d+", False),
        ("BAD SYSTEM HEAD", r"BAD SYSTEM HEAD", False),
        ("CAN'T OPEN OUTPUT FILE", r"CAN'T OPEN OUTPUT FILE", False),
        ("DEVICE NOT A DIRECTORY DEVICE", r"DEVICE #\d+ NOT A DIRECTORY DEVICE", False),
        ("DIRECTORY ERROR", r"DIRECTORY ERROR", False),
        ("ERROR DELETING FILE", r"ERROR DELETING FILE", False),
        ("ILLEGIAL BINARY INPUT, FILE", r"ILLEGIAL BINARY INPUT, FILE #\d+", False),
        ("INPUT ERROR", r"INPUT ERROR, FILE #\s?\d+", False),
        ("IO ERROR--CONTINUING", r"IO ERROR IN \(file name\) --CONTINUING", False),
        ("NO ROOM FOR OUTPUT FILE", r"NO ROOM FOR OUTPUT FILE", False),
        ("NO ROOM--CONTINUING", r"NO ROOM IN \(file name\) --CONTINUING", False),
        ("OUTPUT ERROR", r"OUTPUT ERROR", False),
        ("PREMATURE END OF FILE", r"PREMATURE END OF FILE, FILE #\s?\d+", False),
        ("ZERO SYS?", r"ZERO SYS?", False),
        # "",  OS/8 Handbook 1974 page 2-81/244: DIRECT Error Messages
        ("BAD INPUT DIRECTORY", r"BAD INPUT DIRECTORY", False),
        (
            "DEVICE DOES NOT HAVE A DIRECTORY",
            r"DEVICE DOES NOT HAVE A DIRECTORY",
            False,
        ),
        ("ERROR CLOSING FILE", r"ERROR CLOSING FILE", False),
        ("ERROR CLOSING FILE", r"ERROR CLOSING FILE", False),
        ("ERROR READING INPUT DIRECTORY", r"ERROR READING INPUT DIRECTORY", False),
        ("ILLEGAL *", r"ILLEGAL \*", False),
        # "",  OS/8 Handbook 1974 page: 2-109/272: FOTP Error Messages
        (
            "ERROR ON INPUT DEVICE, SKIPPING",
            r"ERROR ON INPUT DEVICE, SKIPPING \((\S+)\)",
            False,
        ),
        (
            "ERROR ON OUTPUT DEVICE, SKIPPING",
            r"ERROR ON OUTPUT DEVICE, SKIPPING \((\S+)\)",
            False,
        ),
        ("ERROR READING INPUT DIRECTORY", r"ERROR READING INPUT DIRECTORY", False),
        ("ERROR READING OUTPUT DIRECTORY", r"ERROR READING OUTPUT DIRECTORY", False),
        ("ILLEGAL ?", r"ILLEGAL \?", False),
        ("NO FILES OF THE FORM", r"NO FILES OF THE FORM: (\S+)", False),
        ("NO ROOM, SKIPPING", r"NO ROOM, SKIPPING \((\S+)\)", False),
        ("SYSTEM ERROR-CLOSING FILE", r"SYSTEM ERROR-CLOSING FILE", False),
        (
            "USE PIP FOR NON-FILE STRUCTURED DEVICE",
            r"USE PIP FOR NON-FILE STRUCTURED DEVICE",
            False,
        ),
        ("LINE TOO LONG IN FILE", r"LINE TOO LONG IN FILE#\d+", False),
    ]

    # Pattern to match a SIMH command.  The command verb ends up in
    # match().group(1), and anything after the verb in group(3).
    _simh_comm_re = re.compile(r"^\s*(\S+)(\s+(.*))?$")

    # Significant prefixes of SIMH command verbs that transition from SIMH
    # command context back into the simulation: BOOT, CONTINUE, and GO.
    # We need only the first letter in all cases, since these particular
    # commands are not ambiguous.  They're uppercase because the code that
    # uses this always uppercases the command before searching this list.
    _enters_os8_context = ["B", "C", "G"]

    #### ctor ############################################################
    # basedir is the parent of bin/{pdp8,pidp8i-sim}.
    #
    # Setting the skip_gpio flag to True causes us to use bin/pdp8 instead
    # of bin/pidp8i-sim, which runs the simulator with the PiDP-8/I GPIO
    # thread disabled.  You might do this because you don't need any front
    # panel interaction or because you know you're not running on an RPi
    # in the first place.

    def __init__(self, basedir: str, skip_gpio: bool = False):
        # Start the simulator instance
        sim = "pdp8" if skip_gpio else "pidp8i-sim"

        if TYPE_CHECKING:
            self._child: Spawn[str]  # for static type checking only

        self._child = pexpect.spawn(os.path.join(basedir, "bin", sim), encoding="utf-8")
        self._valid_pip_options = ["/A", "/B", "/I"]
        self._os8_file_re = re.compile(r"(\S+):(\S+)?")
        self._os8_error_match_strings = []
        self._os8_fatal_check = []
        self.verbose = False

        # We keep track of the command context and transition automatically.
        self._context = "simh"

        # Pre-compile our os8_replies regexps.
        self._os8_replies_rex: list[InputRePattern] = []
        for item in self._os8_replies:
            self._os8_replies_rex.append(re.compile(item[1]))

        # Pre-compile our simh_replies regexps.
        self._simh_replies_rex: list[InputRePattern] = []
        for item in self._simh_replies:
            self._simh_replies_rex.append(re.compile(item[1]))

        # Turn off pexpect's default inter-send() delay.  We add our own as
        # necessary.  The conditional tracks an API change between 3 and 4.
        # Type checking is ignored here, but we could pip install packaging and use a more modern construct.
        pev4 = pkg_resources.get_distribution("pexpect").parsed_version > pkg_resources.parse_version("4.0")  # type: ignore
        self._child.delaybeforesend = None if pev4 else 0

        # Wait for the simulator's startup message.
        if not self.try_wait(
            "PDP-8 simulator Open SIMH V.*git commit id: [0-9a-f]", 10
        ):
            raise RuntimeError("Simulator failed to start")

    #################### Accessors #######################################

    @property
    def simh_replies(self) -> list[tuple[str, str, bool]]:
        return self._simh_replies

    @property
    def os8_replies(self) -> list[tuple[str, str, bool]]:
        return self._os8_replies

    @property
    def child(self) -> "Spawn[str]":
        return self._child

    @property
    def context(self) -> str:
        return self._context

    @property
    def simh_replies_rex(self) -> list[InputRePattern]:
        return self._simh_replies_rex

    @property
    def os8_replies_rex(self) -> list[InputRePattern]:
        return self._os8_replies_rex

    #################### simh pexpect Abstraction layer ##################
    # Ideally these should be the public interface to pexpect
    # used with the simh object

    #### set_logfile #####################################################

    def set_logfile(self, lf: TextIO):
        self._child.logfile = lf

    #### send_line #######################################################
    # Sends the given line "blind", without before or after checks

    def send_line(self, line: str):
        self._child.sendline(line)

    #### read_tail #######################################################
    # Watch for a literal string, then get what follows on that line.

    def read_tail(self, head: str, timeout: int = -1) -> str:
        self._child.expect_exact([head], timeout)
        return self._child.readline()

    #### spin ############################################################
    # Let child run without asking anything more from it, with an optional
    # timeout value.  If no value is given, lets child run indefinitely.

    def spin(self, timeout: Union[int, None] = None):
        self._child.expect(pexpect.EOF, timeout=timeout)

    #### child_expect ###################################################
    # Higher level accessor to _child.expect that catches the timeout.

    def child_expect(self, replies: ExpectPattern, timeout: int = 10) -> int:
        try:
            reply = self._child.expect(replies, timeout=timeout)
        except pexpect.exceptions.TIMEOUT:
            print("pexpect timeout")
            self.dbg_before("\tmatch before:")
            return -1
        return reply

    #################### Abstract debug print routines for  pexpect ######

    def dbg_before(self, prefix: str):
        outstr: str = prefix + " {" + cast(str, self._child.before) + "}"
        print(outstr)

    def dbg_after(self, prefix: str):
        outstr: str = prefix + " {" + self.child_after() + "}"
        print(outstr)

    #### child_after #####################################################
    # Abstract accessor for the pexpect object's after contents.
    # Copes with the possibility that we timed out or hit end of file.
    # Carefully returns only a string.

    def child_after(self) -> str:
        obj = self._child.after
        if obj == pexpect.TIMEOUT:
            return "TIMEOUT"
        elif obj == pexpect.EOF:
            return "End Of File"
        return cast(str, self._child.after)

    def child_before(self) -> str:
        obj = self._child.after
        if obj == pexpect.TIMEOUT:
            return "TIMEOUT"
        elif obj == None:
            return "None type"
        elif obj == pexpect.EOF:
            return "End Of File"
        else:
            return cast(str, self._child.before)

    #### try_wait ########################################################
    # A wrapper around self._child.expect which catches exceptions and
    # returns false on pexpect timeout.  If you pass a list instead of a
    # string, it also returns true if the match wasn't for the first
    # element, so you can pass [success, failure1, failure2, etc.] to
    # check for a known-success case and one or more failure cases.

    def try_wait(self, matches: ExpectPattern, timeout: int = -1) -> bool:
        try:
            return self._child.expect(matches, timeout=timeout) == 0
        except pexpect.exceptions.TIMEOUT:
            sys.stderr.write(
                "Exceeded "
                + str(timeout)
                + " sec timeout "
                + "waiting for "
                + str(matches)
                + "\n"
            )
            return False
        except:
            sys.stderr.write(
                "Failed to match " + str(matches) + ": unknown exception.\n"
            )
            return False

    ######################## Utility Functions #############################

    #### test_result ######################################################
    # Given a result number, an expected result, and an array of result elements,
    # return True if the result matched expected value.
    # If caller is not the empty string, print a message about it.
    #
    # This routine does the repetitive work of testing results returned
    # from running a command, and optionally printing status.

    def test_result(
        self,
        reply: int,
        expected: str,
        results: Union[list[list[str]], list[tuple[str, str, bool]]],
        caller: str,
        debug: bool = False,
    ) -> bool:
        # Cover case of utter failure.
        if reply == -1:
            if debug or caller != "":
                print(caller + ": failure.")
            return False
        reply_ID = results[reply][0]
        if debug:
            print("test_result: Got reply: " + str(reply))
            self.dbg_before("\tmatch before:")
            self.dbg_after("\tmatch after:")
        if reply_ID.lower() == expected.lower():
            return True
        else:
            if caller != "":
                print(
                    caller
                    + ': Expected: "'
                    + expected
                    + '". Instead got: "'
                    + reply_ID
                    + '".'
                )
            return False

    ##################### Basic SIMH Interaction ###########################
    # Use these to work with simh under pexpect

    #### esc_to_simh  ######################################################
    # Unconditionally go to SIMH
    def esc_to_simh(self, debug: bool = False) -> int:
        self._child.sendcontrol("e")
        reply = self.child_expect(self.simh_replies_rex)  # Wait to get simh prompt
        self.simh_test_result(reply, "Prompt", "esc_to_simh")
        self._context = "simh"
        if debug:
            print(
                "esc_to_simh: reply: "
                + str(reply)
                + " -> "
                + self.simh_replies[reply][0]
            )
            self.dbg_before("\tmatch before:")
            self.dbg_after("\tmatch after:")
        return reply

    #### simh_test_result #################################################
    # Convenience wrapper for test_result that uses SIMH replies.

    def simh_test_result(
        self, reply: int, expected: str, caller: str, debug: bool = False
    ) -> int:
        return self.test_result(reply, expected, self.simh_replies, caller)

    #### simh_send_line ########################################################
    # Send a line to simh while managing context.
    # If we are not in the simh context call esc_to_simh which will
    #    send ^e, and set the context to simh.
    # If we issue a command that enters os8 context, set context "os8".
    # It is up to the caller to then do an expect to confirm success.

    def simh_send_line(self, cmd: str, debug: bool = False):
        if debug:
            print("Context: " + self._context)
        if self._context != "simh":
            self.esc_to_simh()
        if debug:
            print("simh_send_line: Sending: " + cmd)
        self._child.sendline(cmd)
        m = re.match(self._simh_comm_re, cmd)
        if m != None and m.group(1)[:1].upper() in self._enters_os8_context:
            self._context = "os8"

    #### simh_cmd ########################################################
    # If we are not in the simh context call esc_to_simh which will
    #    send ^e, and set the context to simh.
    # If we issue a command that enters os8 context, set context "os8".
    # Wait for a reply for error checking.
    # replies is an optional argument that defaults to simh_replies_rex

    def simh_cmd(
        self, cmd: str, replies: ExpectPattern = [], debug: bool = False
    ) -> int:
        if replies == []:
            replies = self.simh_replies_rex
        self.simh_send_line(cmd, debug=debug)
        reply = self.child_expect(replies)
        if reply == -1:
            print("simh_cmd: was sending:" + cmd)
        if debug:
            print("\tsimh_cmd: Got reply: " + str(reply))
            self.dbg_before("\tmatch before:")
            self.dbg_after("\tmatch after:")
        return reply

    #### quit ############################################################
    # Quits the simulator and waits for it to exit
    # By calling simh_send_line, we are careful to look to our context
    # and escape to SIMH if necessary.

    def quit(self):
        self.simh_send_line("quit")

    #### zero_core #######################################################
    # From SIMH context, zero the entire contents of core, which is
    # assumed to be 32 kWords.
    #
    # SIMH's PDP-8 simulator doesn't start with core zeroed, on purpose,
    # because the actual hardware did not do that.  SIMH does not attempt
    # to simulate the persistence of core memory by saving it to disk
    # between runs, but the SIMH developers are right to refuse to do this
    # by default: you cannot trust the prior state of a PDP-8's core
    # memory before initializing it yourself.
    #
    # See os8_zero_core () for a less heavy-handed alternative for use
    # when running under OS/8.

    def zero_core(self):
        reply = self.simh_cmd("de all 0")
        self.simh_test_result(reply, "Prompt", "simh_zero_core")

    ################# PDP-8 SIMH Interaction #############################
    # High level interfaces to learn about and change device configurations
    # for the PDP-8 under SIMH.

    #### describe_dev_config #############################################
    # We provide an interface to alter SIMH device configurations for
    # specific parameters and specific devices
    #
    # If an unsuppored dev name is given, return empty string.
    #
    # dev configs supported:  rx, tti, tape
    #
    # rx:     RX8E, RX28   RX8E is the simh name for RX01 support.
    #                      RX28 is the simh name for RX02 support.
    # tti:    KSR, 7b      7b is full keyboard support.
    #                      KSR forces upcase of lower case keys on input.
    # tape:   td, dt       td is the TD8E DECtape device
    #                      dt is the TC08 DECtape device

    def describe_dev_config(self, name: str) -> str:
        if name == "tape":
            lines = self.do_simh_show("dt")
            dev_status = self.parse_show_tape_dev(lines)

            if dev_status == "dt":
                return "dt"
            else:
                lines = self.do_simh_show("td")
                return self.parse_show_tape_dev(lines)
        elif name == "rx":
            lines = self.do_simh_show("rx")
            return self.parse_show_rx_dev(lines)
        elif name == "tti":
            lines = self.do_simh_show("tti")
            return self.parse_show_tti(lines)
        else:
            return ""

    #### do_simh_show  ###################################################
    #
    # Calls 'show' on the device name.
    # Returns array of lines from output.
    # This is focused on OS/8 devices.
    # If an unsuppored dev name is given, return empty list.

    def do_simh_show(self, name: str) -> list[str]:
        supported_shows = ["dt", "td", "tti", "rx"]
        if name not in supported_shows:
            return []

        ucname = name.upper()
        self.simh_send_line("show " + name)
        if self.child_expect([ucname + r"\s+(.+)\r"]) == -1:
            return []
        lines = self.child_after().split("\r")
        return lines

    #### parse_show_tape_dev  ############################################
    # Returns current state of DECtape support.
    # One of: disabled, td, dt, or empty string if parse fails.

    def parse_show_tape_dev(self, lines: list[str]) -> str:
        if lines == []:
            return ""
        is_enabled_re = re.compile(r"^(TD|DT)\s+(disabled|(devno=\S+,\s(\d)\s+units))$")
        m = re.match(is_enabled_re, lines[0])

        if m == None or m.group(1) == None or m.group(2) == None:
            return ""
        if m.group(2) == "disabled":
            return "disabled"
        elif m.group(1) == "TD":
            return "td"
        elif m.group(1) == "DT":
            return "dt"
        else:
            return ""

    #### parse_show_tape_attached  ########################################
    # Returns a dict that is an ordered list of files attached or
    # empty dict if disabled.
    def parse_show_tape_attached(self, lines: list[str]) -> dict[str, str]:
        attached: dict[str, str] = {}
        if lines == []:
            return attached
        if len(lines) < 2:
            return attached
        attachment_re = re.compile(
            r"^\s+(((DT|TD)(\d)(.+),\s+(not\s+attached|attached\s+to\s+(\S+)),(.+))|12b)$"
        )
        for line in lines[1:]:
            m = re.match(attachment_re, line)
            if m == None or m.group(1) == None or m.group(1) == "12b":
                continue
            filename = m.group(7)
            if filename == None:
                filename = ""
            attached[m.group(4)] = filename
        return attached

    #### do_print_lines ###################################################
    # Debugging aid. Prints what we parsed out of child_after.
    def do_print_lines(self, lines: list[str]):
        for line in lines:
            print(line)

    #### simh_configure routines #########################################
    # These routines affect the state of device configuration in SIMH.
    # They are intended as robust ways to toggle between incompatible
    # configurations of SIMH:
    # Choice of TD8E or TC08 DECtape (SIMH td and dt devices).
    # Choice of RX01 or RX02 Floppy emulation.
    # The SIMH rx device sets RX8E for RX01, and RX28 for RX02.
    # Choice of KSR or 7bit console configuration.
    #
    # When re-configuring dt, dt, and rx devices, any attached
    # images are detached before reconfiguration is attempted.
    # (SIMH errors out if you don't detach them.)
    #
    # The check to see if the change is unnecessary.
    # For now they return None if no change necessary.
    #
    # After re-configuring the device, the SIMH show command is used
    # to confirm the re-configuration was successful.
    #
    # In future, we should add exception handling for no change necessary.
    # For now, return True if the change was successful and False if not.

    def set_tape_config(self, to_tape: str) -> bool:
        if to_tape == "dt":
            from_tape = "td"
        elif to_tape == "td":
            from_tape = "dt"
        else:
            print("Cannot set_tape_config for " + to_tape)
            return False

        if self.verbose:
            print("Disable: " + from_tape + ", and enable: " + to_tape)

        lines = self.do_simh_show(from_tape)
        from_status = self.parse_show_tape_dev(lines)

        if from_status == "":
            print(
                "do_tape_change: Trouble parsing 'show "
                + from_tape
                + "' output from simh. Giving up on:"
            )
            self.do_print_lines(lines)
            return False

        if from_status == "disabled":
            print(from_tape + " already is disabled.")
        else:
            attached_from = self.parse_show_tape_attached(lines)
            if attached_from == {}:
                print(
                    "do_tape_change: Trouble parsing 'show "
                    + from_tape
                    + "' output from simh. Giving up on:"
                )
                self.do_print_lines(lines)
                return False
            else:
                for unit in attached_from.keys():
                    if attached_from[unit] != "":
                        det_comm = "det " + from_tape + unit
                        if self.verbose:
                            print(det_comm + "(Had: " + attached_from[unit] + ")")
                        reply = self.simh_cmd(det_comm)
                        self.simh_test_result(
                            reply, "Prompt", "set_tape_config: " + det_comm
                        )
                reply = self.simh_cmd("set " + from_tape + " disabled")
                self.simh_test_result(
                    reply, "Prompt", "set_tape_config disable " + from_tape
                )

        lines = self.do_simh_show(to_tape)
        to_status = self.parse_show_tape_dev(lines)

        if to_status == "":
            print(
                "do_tape_change: Trouble parsing 'show "
                + to_tape
                + "' output from simh. Giving up on:"
            )
            self.do_print_lines(lines)
            return False
        elif to_status != "disabled":
            print(to_tape + " already is enabled.")
        else:
            reply = self.simh_cmd("set " + to_tape + " enabled")
            self.simh_test_result(reply, "Prompt", "set_tape_config enable " + to_tape)

        # Test to confirm to_tape is now enabled.

        lines = self.do_simh_show(to_tape)
        to_status = self.parse_show_tape_dev(lines)

        if to_status == "":
            print(
                "Failed enable of "
                + to_tape
                + ". Parse fail on 'show "
                + to_tape
                + "'. Got:"
            )
            self.do_print_lines(lines)
            return False
        elif to_status == "disabled":
            print("Failed enable of " + to_tape + ". Device still disabled.")
            return False
        else:
            return True

    #### parse_show_rx_dev ###############################################
    # Show the rx device configuration.
    # Return empty string if parse failed or no rx devices.

    def parse_show_rx_dev(self, lines: list[str]) -> str:
        if lines == []:
            return ""
        is_enabled_re = re.compile(
            r"^\s*(RX)\s+(disabled|((RX8E|RX28),\s+devno=\S+,\s+(\d)\s+units))$"
        )
        m = re.match(is_enabled_re, lines[0])
        if m == None or m.group(2) == None:
            return ""
        if m.group(2) == "disabled":
            return "disabled"
        return m.group(4)

    #### parse_show_rx_attached ##########################################
    # Returns a dict containing an ordered list of files attached or empty dict if disabled.

    def parse_show_rx_attached(self, lines: list[str]) -> dict[str, str]:
        attached: dict[str, str] = {}
        if len(lines) < 2:
            return attached
        attachment_re = re.compile(
            r"^\s+(((RX)(\d)(.+),\s+(not\s+attached|attached\s+to\s+(\S+)),(.+))|autosize)$"
        )
        for line in lines[1:]:
            m = re.match(attachment_re, line)
            if m == None or m.group(1) == None or m.group(1) == "autosize":
                continue
            filename = m.group(7)
            if filename == None:
                filename = ""
            attached[m.group(4)] = filename
        return attached

    #### set_rx_config ####################################################

    def set_rx_config(self, to_rx: str) -> bool:
        to_rx = to_rx.lower()
        if to_rx == "rx8e":
            from_rx = "rx28"
        elif to_rx == "rx01":
            to_rx = "rx8e"
            from_rx = "rx28"
        elif to_rx == "rx28":
            from_rx = "rx8e"
        elif to_rx == "rx02":
            to_rx = "rx28"
            from_rx = "rx8e"
        else:
            print("Cannot set_rx_config for " + to_rx)
            return False

        if self.verbose:
            print("Switch rx driver: " + from_rx + ", to: " + to_rx)
        lines = self.do_simh_show("rx")

        rx_type = self.parse_show_rx_dev(lines)
        if rx_type == "":
            print(
                "do_rx_change: Trouble parsing 'show rx' output from simh.  Giving up on:"
            )
            self.do_print_lines(lines)
            return False
        elif rx_type == "disabled":
            if self.verbose:
                print("rx is disabled. Enabling...")
            reply = self.simh_cmd("set rx enabled")
            self.simh_test_result(reply, "Prompt", "set_rx_config enable  rx")
            # Retry getting rx info
            lines = self.do_simh_show("rx")
            rx_type = self.parse_show_rx_dev(lines)
            if rx_type == "":
                print(
                    "do_rx_change after re-enable: Trouble parsing 'show rx' output from simh. Giving up on:"
                )
                self.do_print_lines(lines)
                return False
            elif rx_type == "disabled":
                print("do_rx_change re-enable of rx failed. Giving up.")
                return False

        if rx_type.lower() == to_rx:
            print("rx device is already set to " + to_rx)
            return True

        attached_rx = self.parse_show_rx_attached(lines)
        if attached_rx == {}:
            print(
                "do_rx_change: Trouble parsing /'show rx' from simh to find rx attachments. Got:"
            )
            self.do_print_lines(lines)
        else:
            for unit in attached_rx.keys():
                if attached_rx[unit] != "":
                    det_comm = "det rx" + unit
                    if self.verbose:
                        print(det_comm + "(Had: " + attached_rx[unit] + ")")
                    reply = self.simh_cmd(det_comm)
                    self.simh_test_result(
                        reply, "Prompt", "set_rx_config detach: " + det_comm
                    )

        reply = self.simh_cmd("set rx " + to_rx)
        self.simh_test_result(reply, "Prompt", "set_rx_config set rx " + to_rx)

        # Test to confirm new setting of RX
        lines = self.do_simh_show("rx")
        rx_type = self.parse_show_rx_dev(lines)

        if rx_type == "":
            print("Failed change of rx to " + to_rx + ". Parse fail on 'show rx'.")
            return False
        elif rx_type.lower() != to_rx:
            print("Failed change of rx to " + to_rx + ". Instead got: " + rx_type)
            return False
        return True

    #### get_tti ##################################################
    # Returns a string with an ordered list of files attached
    # or the empty string if disabled.
    def parse_show_tti(self, lines: list[str]) -> str:
        if lines == []:
            return ""
        is_enabled_re = re.compile(r"^(KSR|7b)$")
        if len(lines) < 2:
            return ""

        # That second line of output contains embedded newlines.
        m = re.match(is_enabled_re, lines[1].strip())
        if m == None or m.group(1) == None:
            return ""
        return m.group(1)

    #### do_tti_change ###################################################

    def set_tti_config(self, to_tti: str) -> bool:
        if to_tti == "KSR":
            from_tti = "7b"
        elif to_tti == "7b":
            from_tti = "KSR"
        else:
            print("Cannot set_tti_config to " + to_tti)
            return False

        if self.verbose:
            print("Switch tti driver from: " + from_tti + ", to: " + to_tti)

        lines = self.do_simh_show("tti")
        tti_type = self.parse_show_tti(lines)
        if tti_type == "":
            print(
                "do_tti_change: Trouble parsing 'show tti' output from simh. Giving up on:"
            )
            self.do_print_lines(lines)
            return False
        elif tti_type == to_tti:
            print("tti device is already set to " + to_tti)
            return True

        reply = self.simh_cmd("set tti " + to_tti)
        self.simh_test_result(reply, "Prompt", "set_tti_config setting tti " + to_tti)

        # Test to confirm new setting of tti
        lines = self.do_simh_show("tti")
        tti_type = self.parse_show_tti(lines)

        if tti_type == "":
            print("Failed change of tti to " + to_tti + ". Parse fail on 'show tti'.")
            return False
        elif tti_type != to_tti:
            print("Failed change of tti to " + to_tti + ". Instead got: " + tti_type)
            return False
        else:
            return True

    ############### Basic OS/8 Interaction ###############################
    # Intereact with OS/8 under SIMH.

    #### os8_kbd_delay ###################################################
    # Artificially delay the media generation process to account for the
    # fact that OS/8 lacks a modern multi-character keyboard input buffer.
    # It is unsafe to send text faster than a contemporary terminal could,
    # though we can scale it based on how much faster this host is than a
    # real PDP-8.  See the constants above for the calculation.

    def os8_kbd_delay(self):
        time.sleep(self._os8_kbd_delay)

    #### os8_send_str ########################################################
    # Core of os8_send_line.  Also used by code that needs to send text
    # "blind" to OS/8, without expecting a prompt and without a CR, as
    # when driving TECO.

    def os8_send_str(self, send_str: str):
        for i in range(0, len(send_str)):
            self.os8_kbd_delay()
            self._child.send(send_str[i])

    #### os8_send_ctrl ###################################################
    # Send a control character to OS/8 corresponding to the ASCII letter
    # given.  We precede it with the OS/8 keyboard delay, since we're
    # probably following a call to os8_send_line or os8_cmd.

    def os8_send_ctrl(self, char: str):
        cc = char[0].lower()
        self.os8_kbd_delay()
        self._child.sendcontrol(cc)

        if cc == "e":
            self._context = "simh"

    #### os8_send_line ###################################################
    # Core of os8_cmd.  Also used by code that needs to send text
    # "blind" to OS/8, without expecting a prompt, as when driving EDIT.

    def os8_send_line(self, line: str):
        self.os8_send_str(line)
        self._child.send("\r")

    #### os8_test_result ##################################################
    # Convenience wrapper for test_result that uses OS/8 replies.

    def os8_test_result(
        self, reply: int, expected: str, caller: str, debug: bool = False
    ) -> bool:
        return self.test_result(reply, expected, self._os8_replies, caller, debug)

    #### mk_os8_name # ###################################################
    # Create an OS/8 filename: of the form XXXXXX.YY
    # From a POSIX path.

    def mk_os8_name(self, dev: str, path: str) -> str:
        bns = os.path.basename(path)
        bns = re.sub(r"-|:|\(|\)|!", "", bns)
        bns = bns.upper()
        if "." not in bns:
            return dev + bns[: min(6, len(bns))]
        else:
            dot = bns.index(".")
            return dev + bns[: min(6, dot, len(bns))] + "." + bns[dot + 1 : dot + 3]

    #### os8_cmd_ ###################################################
    # Send the given command to OS/8.
    # replies is an array of possible replies that command will get.
    # Returns the index into the replies array that pexpect got,
    # or -1 if the command could not be run.
    # replies is an optional argument which defaults to _os8_replies_rex

    def os8_cmd(
        self,
        cmd: str,
        replies: ExpectPattern = [],
        debug: bool = False,
        timeout: int = 60,
    ) -> int:
        if replies == []:
            replies = self.os8_replies_rex
        if self._context != "os8":
            print("OS/8 is not running. Cannot execute: " + cmd)
            return -1
        self.os8_send_line(cmd)

        reply = self.child_expect(replies, timeout=timeout)
        return reply

    #### os8_cfm_monitor ##################################################
    # Confirm return to OS/8 monitor.
    # This function is necessary so that we know our command has returned to OS/8.
    # Without this test, the next child_expect hits a monitor prompt instead of
    # what it will be looking for.
    # If caller is not empty, it will emit an error string if the prompt wasn't seen.
    # Returns True if we are where we expect.

    def os8_cfm_monitor(self, caller: str, debug: bool = False) -> bool:
        reply = self.child_expect(self.os8_replies_rex)
        return self.os8_test_result(reply, "Monitor Prompt", caller, debug=debug)

    #### os8_ctrl_c ##################################################
    # Return to OS/8 monitor using the ^C given escape character.
    # We will probably, but not always get an echo of ^C.
    # For example BUILD is known NOT to.
    # Our testing makes it optional.
    # We listen for an uparow and assume the C follows, and listen again
    # for the monitor prompt.
    # Optional caller argument enables a message if the ^C escape failed.
    # Note: OS/8 will respond to this escape IMMEDIATELY,
    # even if it has pending output.
    # You will need to make sure all pending output is in
    # a known state and the running program is quiescent
    # before calling this method. Otherwise pexpect may get lost.

    def os8_ctrl_c(self, caller: str = "", debug: bool = False) -> int:
        self.os8_send_ctrl("c")
        reply = self.child_expect(self.os8_replies_rex)
        uparrow = self.os8_test_result(reply, "PIP Continue", "")
        if uparrow:
            reply = self.child_expect(self.os8_replies_rex)
        return self.os8_test_result(reply, "Monitor Prompt", caller)

    #### os8_escape ##################################################
    # Return to OS/8 monitor using the escape (^]) character.
    # We need to listen for the $ echo or else cfm_monitor gets confused.
    # Confirm we got our monitor prompt.
    # Optional caller argument enables a message if escape failed.
    # Note: OS/8 will respond to this escape IMMEDIATELY,
    # even if it has pending output.
    # You will need to make sure all pending output is in
    # a known state and the running program is quiescent
    # before calling this method. Otherwise pexpect may get lost.

    def os8_escape(self, caller: str = "", debug: bool = False) -> bool:
        self.os8_send_ctrl("[")
        self.child_expect([r"\$"])
        return self.os8_cfm_monitor(caller)

    #### simh_restart_os8 #######################################################
    # Abstraction on returning to OS/8 monitor from within SIMH.
    # It is common practice to "load address 7600; start" at the console.

    def simh_restart_os8(self, caller: str = "", debug: bool = False):
        # Note we're calling simh with os8 replies because we will
        # be switching contexts.
        # simh_cmd manages the context, and checks for success
        # by expecting a command prompt.
        reply = self.simh_cmd("go 7600", self.os8_replies_rex, debug=debug)
        # We test our reply with os8_test result because we're in OS/8 now.
        self.os8_test_result(reply, "Monitor Prompt", caller)

    #### simh_resume_os8 ##########################################################
    # Continue execution of OS/8.
    # This has been extremely tricky to get right.
    # What we must do is give OS/8 a chance to wake up.
    # We do this by asking pexpect to show us our "cont\n" echo and then
    # we do an os8_kbd_delay()
    # We carefully use simh_send_line to send the command so that we
    # know to set the os8 context.
    # WARNING! Using simh_resume_os8 without having booted OS/8
    # has undefined reslts.

    def simh_resume_os8(self):
        self.simh_send_line("cont")
        # Give OS/8 a chance to wake up.
        self.child_expect("cont\r")
        self.os8_kbd_delay()

    #### os8_zero_core ###################################################
    # Starting from OS/8 context, bounce out to SIMH context and zero all
    # of core excepting:
    #
    # 0. zero page - many apps put temporary data here
    # 1. the top pages of fields 1 & 2 - OS/8 is resident here
    # 2. the top page of field 2 - OS/8's TD8E driver (if any) lives here
    #
    # We then restart OS/8, which means we absolutely need to do #1 and
    # may need to do #2.  We could probably get away with zeroing page 0.
    #
    # All of the above explains why we have this special OS/8 alternative
    # to the zero_core() method.

    def os8_zero_core(self):
        reply = self.simh_cmd("de 00200-07577 0")
        self.simh_test_result(reply, "Prompt", "os8_zero_core 00200-07577")
        reply = self.simh_cmd("de 10000-17577 0")
        self.simh_test_result(reply, "Prompt", "os8_zero_core de 10000-17577 0")
        reply = self.simh_cmd("de 20000-27577 0")
        self.simh_test_result(reply, "Prompt", "os8_zero_core de 20000-27577 0")
        reply = self.simh_cmd("de 30000-77777 0")
        self.simh_test_result(reply, "Prompt", "os8_zero_core de 30000-37577 0")
        self.simh_restart_os8()

    #### os8_squish ########################################################
    # Wraps the OS/8 SQUISH command for a given device.

    def os8_squish(self, device: str, caller: str = ""):
        reply = self.os8_cmd("SQUISH " + device + ":")
        self.os8_test_result(reply, "ARE YOU SURE?", caller)
        reply = self.os8_cmd("Y")
        self.os8_test_result(reply, "Monitor Prompt", caller)

    #### os8_pip_to ###################################################
    # Send a copy of a local file to OS/8 using PIP.
    #
    # The file is sent via the SIMH paper tape device through PIP
    # specifying a transfer option.  If no option is specified,
    # ASCII is assumed.
    #
    # In ASCII mode, we pre-process with txt2ptp which translates
    # POSIX ASCII conventions to OS/8 conventions.  In all other
    # modes, we do not do any translation.
    #
    # However, we should supply a sacrificial NULL as an additional character
    # because the OS/8 PTR driver throws the last character away. (NOT DONE YET)
    #
    # Entry context should be inside OS/8.  Exit context is inside OS/8.
    #

    def os8_pip_to(
        self,
        path: str,
        os8name: str,
        option: Union[None, str] = "",
        debug: bool = False,
    ):
        # If os8name is just a device, synthesize an upcased name from
        # the POSIX file basename.
        if not os.path.exists(path):
            print(path + " not found. Skipping.")
            return
        m = re.match(self._os8_file_re, os8name)
        if m != None and (m.group(2) == None or m.group(2) == ""):
            dest = self.mk_os8_name(os8name, path)
        else:
            dest = os8name

        if option == None:
            option = ""

        # Gross hack:
        # The command decoder prompt that comes when we continue a PTR/PTP
        # does not have a newline.  But everything else that matches on
        # a command decoder prompt NEEDS that newline. Otherwise we match
        # on asterisks in file specs.
        # Remember to decrement replies on this rex list if we use
        # _os8_replies

        pip_rex: list[InputRePattern] = [re.compile(r"\*$")]
        pip_rex.extend(self.os8_replies_rex)

        did_conversion = False
        if option == "" or option == "/A":
            # Convert text file to SIMH paper tape format in current dir of path.
            if self.verbose:
                print("Format converting " + path)
            bdir = pidp8i.dirs.build
            # Create uniquified temp path name.
            pt = path + "-" + str(os.getpid()) + ".pt_temp"
            tool = os.path.join(bdir, "bin", "txt2ptp")
            subprocess.call(tool + " < " + path + " > " + pt, shell=True)
            did_conversion = True
        elif option not in self._valid_pip_options:
            print("Invalid PIP option: " + option + ". Ignoring: " + path + " to OS/8.")
            return
        else:
            pt = path

        # TODO: Sacrificial extra character code goes here.

        # Paper tape created, so attach it read-only and copy it in.  We're
        # relying on txt2ptp to insert the Ctrl-Z EOF marker at the end of
        # the file.
        reply = self.simh_cmd("attach -r ptr " + pt, debug=debug)
        self.simh_test_result(reply, "Prompt", "os8_pip_to attaching ptr")
        # Enter OS/8.
        self.simh_restart_os8(caller="os8_pip_to")

        reply = self.os8_cmd("R PIP", debug=debug)
        # Has PIP Startup been successful?
        if (
            self.os8_test_result(
                reply, "Command Decoder Prompt", "os8_pip_to 0", debug=debug
            )
            == False
        ):
            return

        # Give file and test for ready to send.
        if debug:
            print("os8_pip_to: Sending: " + dest + "<PTR:" + option)
        reply = self.os8_cmd(dest + "<PTR:" + option, debug=debug)
        if (
            self.os8_test_result(reply, "PIP Continue", "os8_pip_to 1", debug=debug)
            == False
        ):
            # If we got "PIP Continue" we keep going. Otherwise return.
            # Send escape if reply indicates we didn't go back to the monitor.
            if self._os8_replies[reply][2] == False:
                self.os8_send_ctrl("[")
            return

        # Finish transfer
        # The test for success is implicit.
        self.os8_send_ctrl("[")
        reply = self.child_expect(pip_rex)
        if reply != 0:
            self.os8_test_result(
                reply - 1, "Command Decoder Prompt", "os8_pip_to 2", debug=debug
            )
        # If we did not return to monitor hit escape to exit PIP.
        if self._os8_replies[reply - 1][2] == False:
            self.os8_send_ctrl("[")  # Success or Non-fatal error.
            # Must exit PIP and wait for monitor prompt. Complain if we don't get it!
            reply = self.child_expect(self.os8_replies_rex)
            self.os8_test_result(reply, "Monitor Prompt", "os8_pip_to 3")
        # detach ptr
        # self.esc_to_simh ()
        # self.simh_cmd ('detach ptr ')
        # self.os8_restart ()

        # Remove the temp file if we created one.
        if did_conversion:
            os.remove(pt)

    #### os8_pip_from ###################################################
    # Fetch a file from OS/8 to a local path using PIP.
    #
    # The OS/8 source filename is synthesized from the basename of the path,
    # upcasing if necessary.
    #
    # The file is sent via the SIMH paper tape device through PIP
    # specifying a transfer option.  If no option is specified,
    # ASCII is assumed.
    #
    # In ASCII mode, we post-process with ptp2txt which translates
    # POSIX ASCII conventions to OS/8 conventions.  In all other
    # modes, we do not do any translation.
    #
    # Entry context should be inside OS/8.  Exit context is inside OS/8.

    def os8_pip_from(
        self, os8name: str, path: str, option: str = "", debug: bool = False
    ):
        # If path is not a file, use the name portion of os8name.
        if os.path.isdir(path):
            colon = os8name.find(":")
            if colon == -1:  # No dev, just a name.
                path = path + "/" + os8name
            else:
                path = path + "/" + os8name[colon + 1 :]

        if option != "" and option not in self._valid_pip_options:
            print(
                "Invalid PIP option: " + option + ". Ignoring os8_pip_from on: " + path
            )
            return

        # If the file already exists, the PTP device will append to it
        # So we must delete it!
        
        if os.path.exists (path):
            print ("Overwriting existing file: " + path)
            os.remove(path)

        reply = self.simh_cmd("attach ptp " + path, debug=debug)
        self.simh_test_result(reply, "Prompt", "os8_pip_from attachng ptp")
        # Enter OS/8.
        self.simh_restart_os8(caller="os8_pip_from 0")

        reply = self.os8_cmd("R PIP", debug=debug)
        # Has PIP Startup been successful?
        if (
            self.os8_test_result(reply, "Command Decoder Prompt", "os8_pip_from 0")
            == False
        ):
            return

        # Issue file transfer spec.
        reply = self.os8_cmd("PTP:<" + os8name + option, debug=debug)
        # Test for Success
        if (
            self.os8_test_result(reply, "Command Decoder Prompt", "os8_pip_from 1")
            == False
        ):
            # There is an empty PTP file we need to remove.
            os.remove(path)
            # Was this a fatal error?
            if self._os8_replies[reply][2] == False:
                self.os8_send_ctrl("[")  # Non-fatal error. Must exit PIP
            return

        self.os8_send_ctrl("[")  # exit PIP
        # Must wait for monitor prompt. Complain if we don't get it!
        reply = self.child_expect(self.os8_replies_rex)
        self.os8_test_result(reply, "Monitor Prompt", "os8_pip_from 2")

        reply = self.simh_cmd("detach ptp", debug=debug)  # Clean flush of buffers.
        self.simh_test_result(reply, "Prompt", "os8_pip_from detaching ptp")
        # Enter OS/8.
        self.simh_restart_os8(caller="os8_pip_from 3")

        if option == "" or option == "/A":
            if self.verbose:
                print("Format converting " + path)
            # Convert text file to SIMH paper tape format
            bdir = pidp8i.dirs.build
            # Create uniquified temp path name.
            pf = path + "-" + str(os.getpid()) + ".pf_temp"
            os.rename(path, pf)
            tool = os.path.join(bdir, "bin", "ptp2txt")
            subprocess.call(tool + " < " + pf + " > " + path, shell=True)
            os.remove(pf)
