# Copyright (C) 2011-2024 A S Lewis
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU
# General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
# even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program. If not,
# see <http://www.gnu.org/licenses/>.
#
#
# Games::Axmud::Session
# The code that handles a single session (each tab in the 'main' window has its own session,
#   representing a single connection to a world - even if we're not currently connected to the
#   world)

{ package Games::Axmud::Session;

    use strict;
    use warnings;
#   use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Called by GA::Client->startSession (only)
        #
        # Expected arguments
        #   $number         - A unique number for the session (matches a key in
        #                       GA::Client->sessionHash)
        #   $world          - The world's name (matches a world profile name)
        #
        # Optional arguments
        #   $host           - The world's host address (if 'undef', default host address used)
        #   $port           - The world's port (if 'undef', default host port used)
        #   $char           - A character name (matches a character profile name (if 'undef', no
        #                       character profile used)
        #   $pass           - The corresponding password (if 'undef', the world profile is consulted
        #                       to provide the password, if possible)
        #   $account        - The character's associated account name, for worlds that use both
        #                       (if 'undef', no account name used)
        #   $protocol       - If set to 'telnet', 'ssh' or 'ssl', that protocol is used; if 'undef'
        #                       or an unrecognised value, the world profile's ->protocol is used
        #   $loginMode      - Set when called by $self->connectBlind, when a new world profile is to
        #                       be created, and the user has specified what type of ->loginMode this
        #                       world uses; otherwise set to 'undef'
        #   $offlineFlag    - If TRUE, the session doesn't actually connect to the world, but still
        #                       loads all data and makes some client commands available. If FALSE
        #                       (or 'undef'), the session tries to connect to the world
        #   $tempFlag       - If set to TRUE, the world profile is a temporary world profile,
        #                       created because the user didn't specify a world name. File saving
        #                       in the session will be disabled. Otherwise set to FALSE (or
        #                       'undef')
        #
        # Return values
        #   'undef' on improper arguments
        #   Blessed reference to the newly-created object on success

        my (
            $class, $number, $world, $host, $port, $char, $pass, $account, $protocol, $loginMode,
            $offlineFlag, $tempFlag, $check,
        ) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $number || ! defined $world || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        # Setup
        my $self = {
            _objName                    => 'session',
            _objClass                   => $class,
            _parentFile                 => undef,       # No parent file object
            _parentWorld                => undef,       # No parent file object
            _privFlag                   => TRUE,        # All IVs are private

            # Perl object components
            # ----------------------

            # A copy of $self, for methods inherited by many objects that need a ->session IV
            session                     => undef,       # Set below
            # A unique number for the session (matches a key in GA::Client->sessionHash)
            number                      => $number,

            # Blessed reference to this session's 'main' window, a GA::Win::Internal object whose
            #   ->winType is 'main'
            # If GA::CLIENT->shareMainWinFlag = TRUE, there is a single 'main' window is shared by
            #   all sessions. If GA::CLIENT->shareMainWinFlag = FALSE, each session has its own
            #   'main' window
            mainWin                     => undef,
            # In this session's 'main' window, there is a default pane object (GA::Table::Pane)
            #   which contains a default tab object (GA::Obj::Tab) which in turn contains a default
            #   textview object (GA::Obj::TextView) for this session
            # This session uses its default tab to display most text received from the world. In
            #   addition, when the default tab is closed manually by the user, the session
            #   terminates
            # The default tab object for this session (once set, it does not change)
            defaultTabObj               => undef,
            # The tab object currently being used to display text received from the world (for
            #   example, MXP may want multiple panes, and to switch between them frequently)
            currentTabObj               => undef,

            # Blessed reference to the Automapper window (GA::Win::Map) for this session (only one
            #   can be opened per session, set to 'undef' when it's not open)
            mapWin                      => undef,       # Set by $self->set_mapWin
            # Blessed reference to the automapper object (GA::Obj::Map) for this session (always one
            #   per session)
            mapObj                      => undef,
            # Blessed reference to the world model object (GA::Obj::WorldModel) for the current
            #   world profile (always one per session)
            worldModelObj               => undef,       # Saved in file [worldmodel]

            # The Session Console window (only one can be open per session)
            consoleWin                  => undef,       # Set by $self->set_consoleWin
            # Blessed reference to the data viewer window (GA::OtherWin::Viewer) for this session
            #   (only one can be opened per session, set to 'undef' when it's not open)
            viewerWin                   => undef,       # Set by $self->set_viewerWin
            # Blessed reference to any 'wiz' window (inherited from GA::Generic::WizWin) opened by
            #   this session (only one 'wiz' window can be opened per session, set to 'undef' when
            #   no 'wiz' window is open)
            wizWin                      => undef,       # Set by $self->set_wizWin

            # IVs for this session
            # --------------------

            # Flag set to TRUE once $self->start has finished...
            startCompleteFlag           => FALSE,
            # ...any text received from the world is stored here, until that moment
            initialTextBuffer           => '',
            # The GA::Obj::ConnectHistory object for this connection (if one was created, otherwise
            #   it remains 'undef')
            connectHistoryObj           => undef,
            # When the object exists, its IVs are updated once a second. The next time (matches
            #   $self->sessionTime) to update it
            historyCheckTIme            => undef,

            # The character set to use for this session. $self->spinIncomingLoop encodes text
            #   received from the world using this charset
            # If the world profile's ->worldCharSet is defined, use that; otherwise use
            #   GA::Client->charSet. If the latter isn't available for some reason, this IV remains
            #   set to 'null', and no encoding of text received from the world takes place
            sessionCharSet              => 'null',

            # When system messages are written but it's not possible to display them in the 'main'
            #   window, they are stored here. Then, the next time the Session Console window for
            #   this session is open, they are displayed there
            # (If the Session Console window is already open, the system message is displayed there
            #   immediately and is not added to this list)
            # List in groups of 2, in the form (type, message), where 'type' is the type of system
            #   message: 'system', 'error', 'warning', 'debug' or 'improper'
            systemMsgList               => [],
            # The 'main' window uses a button to show which kinds of system message are waiting to
            #   be displayed in the Session Console window; this IV sets which icon to use on the
            #   button:
            #       'empty'     - no system messages are waiting to be displayed in the Session
            #                       Console window
            #       'system'    - at least one system message, and no system error/warning/debug/
            #                       improper arguments messages
            #       'debug'     - at least one system debug message, and no system error/warning/
            #                       improper arguments messages
            #       'error'     - at least one system error/warning/improper arguments message
            systemMsgMode               => 'empty',
            # When a system error/warning/debug/improper argument message is added to the list of
            #   messages waiting to be displayed, the icon's button flashes
            # What kind of system message is the most recent one, which determines the colour of the
            #   flashing button ('system', 'debug' or 'error', matching values of
            #   $self->systemMsgMode)
            systemMsgTempMode           => 'empty',
            # The length of time (in seconds) to flash
            systemMsgWaitTime           => 2,
            # The time (matches $self->sessionTime) at which to stop flashing
            systemMsgCheckTime          => undef,

            # Automatic logins
            # ----------------

            # Flag set to TRUE once the user has logged into the world (or until they use the client
            #  command ';login'). Set back to FALSE temporarily during an MXP crosslinking operation
            loginFlag                   => FALSE,
            # For login modes 'lp', 'tiny', 'world_cmd', 'telnet' and 'mission', this IV is set to
            #   the mode; otherwise set to 'none' ($self->spinMaintainLoop watches for prompts -
            #   text received from the world without a newline character - and responds to them)
            loginPromptsMode            => 'none',
            # For login modes 'lp', 'tiny', 'world_cmd' and 'telnet', once a response to the
            #   prompt(s) has been sent, a list of login success patterns to watch out for (imported
            #   from GA::Profile::World->loginSuccessPatternList). When the list is empty, we're not
            #   looking out for these patterns
            loginSuccessPatternList     => [],
            # For login mode 'telnet', the regexes used on prompts. As each regex matches a prompt,
            #   it is removed from the list. After sending the username / password, the list is
            #   empty
            loginPromptPatternList      => [
                # Used in a m/.../i expression
                # Use regexes similar to those used by GA::Obj::Telnet, but abbreviate 'username'
                #   to 'name', allow it to end with a question mark as well as a colon, and also
                #   allow it to end with 'wish', so that 'telnet' mode logins works on the default
                #   Dead Souls mudlib
                '(login|name|wish)[:? ]*$',
                'password[: ]*$',
            ],
            # For login mode 'tiny' and 'world_cmd', a list of patterns matching lines which confirm
            #   the world is ready to receive the login (set to
            #   GA::Profile::World->loginConnectPatternList)
            loginConnectPatternList     => [],
            # For login mode 'tiny', when ->processLineSegment spots a line matching one of the
            #   patterns in $self->loginConnectPatternList (contains the pattern 'connect' by
            #   default), sets this flag (so that $self->processLineSegment can call ->doLogin)
            # For login mode 'world_cmd', the same applies if ->loginConnectPatternList contains one
            #   or more patterns. If ->loginConnectPatternList is empty, we wait for the first
            #   prompt, instead
            loginConnectFoundFlag       => FALSE,
            # For login mode 'mission', a local copy of GA::Profile::World->loginSpecialList. If
            #   set, the session checks every incoming line until it finds one matching both a
            #   character name (insensitively) and one of these patterns; when it finds one, it
            #   sends the corresponding world commands, and then stops checking received lines, by
            #   emptying this local copy of the list (and does not call ->doLogin - that's the
            #   responsibility of the login mission)
            loginSpecialList            => [],
            # For all login modes (even 'none'), the time (matches $self->sessionTime) at which to
            #   display a reminder to use the ';login' command (default is 60 seconds). Set to
            #   'undef' after the warning is displayed, after a disconnection or after the character
            #   is marked as logged in
            loginWarningTime            => undef,
            # When $self->doLogin completes an automatic login, the confirmation message is not
            #   displayed immediately (which might interrupt a block of text received from the
            #   world), but stored here temporarily, and displayed at the end of the call to
            #   $self->spinIncomingLoop
            loginConfirmText            => undef,

            # When Axmud decides that a prompt has been received, $self->processPrompt is called to
            #   handle it. Once that happens, the prompt becomes a 'command prompt', in the
            #   expectation that the world is now waiting for us to send a world command
            # Usually it's because the world has sent a Go Ahead (GA) or End Of Record (EOR)
            #   sequence, or a line ending in text which matches a pattern in
            #   GA::Profile::World->cmdPromptPatternList
            # Otherwise, when we receive a line of text that doesn't end with a newline character,
            #   we wait a short time (GA::Client->promptWaitTime seconds) in the expectation that
            #   the world is sending more text; if that time expires and no more text is received,
            #   $self->processPrompt is called anyway
            #
            # The number of command prompts received during this session and processed by
            #   $self->processPrompt (the total won't include prompts that were processed by
            #   ->dispatchCmd, if the user types a world command before ->processPrompt can act)
            promptCount                 => 0,
            # A flag set to TRUE when $self->processPrompt is called; set back to FALSE when more
            #   text (or a new line) is handled
            promptFlag                  => FALSE,
            # A flag set to TRUE when $self->processPrompt is called by $self->processLinePortion,
            #   after it displays a line matching a recognised command prompt pattern. The flag's
            #   TRUE value instructs $self->processIncomingData to insert an artificial newline
            #   after the prompt if one has not already been inserted, and if the next packet of
            #   text received from the world doesn't begin with one
            promptInsertFlag            => FALSE,
            # When waiting to see if the most recently-received text is a command prompt, or not,
            #   the time (matches $self->sessionTime) at which we should stop waiting and process
            #   the incomplete line as a command prompt
            promptCheckTime             => undef,

            # Value set whenever $self->tokeniseIncomingData extracts a token, recording the type
            #   of token that was extracted
            # (Required by MSP, to make sure MSP sound triggers are at the start of a line. The
            #   initial value is 'nl' because, at the start of a session, the first text received
            #   follows an imaginary newline character)
            lastTokenType               => 'nl',
            # Flag set to TRUE when $self->dispatchPassword sends a password to the world which has
            #   turned oFf ECHO (i.e. $self->echoMode is 'client_agree'). The world is supposed to
            #   supply its own newline character after the password, but many worlds don't, meaning
            #   that the next line is joined to the previous one
            # When TRUE, ->processIncomingData inserts an extra newline character at the start of
            #   the next packet of received text (but only if it doesn't already begin with a
            #   newline character), and then resets the flag back to FALSE
            nlEchoFlag                  => FALSE,
            # IV used to cope with <CR><LF>, or <LF><CR>, or even (occasionally, e.g. at TorilMUD)
            #   <CR><escape_sequence><LR>
            # <CR> and <LF> are processed as separate tokens, setting this IV to 'cr' or 'lf'. Any
            #   text token sets the IV to an empty string. Other non-printing tokens (such as valid
            #   escape sequences) don't affect the setting of the IV
            # If this IV is set to 'cr' and a line feed token (\n) is processed, or if this IV is
            #   set to 'lf' and a carriage return (\r) is encountered, the IV is set back to an
            #   empty string, regarding the two tokens (whether sequential or not) as a single
            #   newline token
            crlfMode                    => '',
            # Hash of Axmud colour tags to use for each type of token, when raw received text is
            #   display in the RawToken task window
            constRawHash                => {
                'nl'                    => 'BLUE',
                'ga'                    => 'blue',
                'esc'                   => 'YELLOW',
                'inv'                   => 'YELLOW',
                'ctrl'                  => 'cyan',
                'seq'                   => 'CYAN',
                'bsp'                   => 'red',
                'msp'                   => 'RED',
                'mxp'                   => 'GREEN',
                'ent'                   => 'MAGENTA',
                'pueblo'                => 'GREEN',
                'mcp'                   => 'GREEN',
                'text'                  => 'white',
            },
            # When $self->processIncomingData receives incoming text, it calls
            #   $self->tokeniseIncomingData to tokenise it. The list of tokens is stored in this IV.
            #   Tokens are then removed from the list, one by one, to be processed. Any part of the
            #   code can artificially insert a token at the beginning of the list, if it needs to
            # List in groups of 2, in the form
            #   (type, argument, type, argument...)
            #   - 'type' is one of the strings:
            #           - 'nl' (newline token)
            #           - 'ga' (IAC GA 'go ahed' token)
            #           - 'esc' (token containing single escape character)
            #           - 'inv' (token containing invalid escape sequence)
            #           - 'ctrl' (token containing valid escape sequence; any undisplayed text
            #               must be displayed, before processing this sequence)
            #           - 'seq' (token containing valid escape sequence; any undisplayed text
            #               doesn't need to be displayed yet)
            #           - 'bsp' (a backspace token, in the form "\b \b" or just "\b"
            #           - 'msp' (msp token), 'mxp' (MXP element token)
            #           - 'ent' (MXP entity token)
            #           - 'pueblo' (Pueblo element token)
            #           - 'mcp' (MCP out-of-band line
            #           - 'nomcp' (token containing #$", the start of an MCP in-band line)
            #           - 'text' (ordinary text token)
            #           - 'go' (artificially-inserted newline character)
            #   - (NB 'part' is used in calls to $self->respondIncomingData to show that an
            #       incomplete line is to be shown, but it's never added to this IV)
            #   - 'argument' is usually the token itself. For type 'ctrl' and 'seq', it's a list
            #       reference and for 'go', the argument (if defined) is ignored
            currentTokenList            => [],
            # The RawText/RawToken tasks won't start until an automatic login is completed, meaning
            #   that they won't display text/tokens from the beginning of the session
            # When $self->loginFlag is still FALSE, $self->displayRawIncomingData stores the
            #   incoming raw text/tokens in these buffer IVs (at least for a while; if no login has
            #   been processed before $self->loginWarningTime, it empties these IVs and gives up)
            # Raw text buffer. List in groups of 2, in the form (packet_number, raw_text_string)
            rawTextBufferList           => [],
            # Raw token buffer. List in groups of 2, in the form
            #   (packet_number, reference_to_list_of_tokens)
            rawTokenBufferList          => [],

            # File objects
            # ------------

            # Most file objects are stored in GA::Client's registry
            # This GA::Session's registry contains file objects of the type 'worldprof',
            #   'otherprof' and 'worldmodel'. Only those files relating to the current world
            #   profile have their file objects stored here
            # The 'worldprof' file object will appear both here, and in the GA::Client registry
            #   File type    Standard directory                               Unique name    Stored
            #   'worldprof'  <SCRIPT_DIR>/data/worlds/<WORLD>/worldprof.axm   WORLD          Both
            #   'otherprof'  <SCRIPT_DIR>/data/worlds/<WOPLD>/otherprof.axm   otherprof      Session
            #   'worldmodel' <SCRIPT_DIR>/data/worlds/<WORLD>/worldmodel.axm  worldmodel     Session
            #
            # Registry hash of file objects, in the form
            #   $sessionFileObjHash{unique_name} = blessed_reference_to_file_object
            sessionFileObjHash          => {},
            # Flag set to TRUE by ';qquit', ';xxit' (etc) - stops $self->reactDisconnect from
            #   saving any files (including the 'config' file) as the connection closes. Otherwise
            #   set to FALSE
            disconnectNoSaveFlag        => FALSE,
            #
            # When auto-saves are turned on (i.e. GA::Client->autoSaveFlag is TRUE and actually
            #   connected to a world, not in 'offline' mode), the time (matches $self->sessionTime)
            #   at which the next auto-save should take place. When auto-saves are turned off, set
            #   to 0
            autoSaveCheckTime           => 0,
            # The last time (matches $self->sessionTime) at which an auto-save took place. Set
            #   to 0 if autosaves are turned off, or if no autosaves have been performed yet
            autoSaveLastTime            => 0,

            # Profiles
            # --------

            # Customisable registry list of profile priorities (which can include the standard
            #   categories of profile - 'char', 'race', 'guild' and 'world' - as well as any custom
            #   categories created by the user)
            profPriorityList            => [],      # [otherprof] Set below
            # (Profiles are explained in the comments for GA::Client->new. Each profile has a unique
            #   name, max 16 characters)
            # Registry hash of all profile templates (one entry for each new kind of custom
            #   profile). Hash in the form
            #   $templateHash{unique_string_name} = blessed_reference_to_profile_template_object
            templateHash                => {},      # [otherprof]
            #
            # Registry hash of profile objects. Includes the profile for the current world, as well
            #   as all profiles associated with that world. (The current world profile is also
            #   stored in GA::Client->worldProfHash). Hash in the form
            #   $profHash{unique_string_name} = blessed_reference_to_profile_object
            profHash                    => {},      # [otherprof]
            # Registry hash of current profile objects - a subset of $self->profHash containing only
            #   those profiles which are 'current' - no more than one for each category of profile.
            #   Categories include 'world', 'guild', 'race', 'char' and any other kind of category
            #   created by the user. Hash in the form
            #   $currentProfHash{category} = blessed_reference_to_profile_object
            currentProfHash             => {},
            # Four shortcuts to the current profile objects for the four 'standard' categories. Set
            #   to 'undef' when there isn't a current profile for that category
            currentWorld                => undef,
            currentGuild                => undef,
            currentRace                 => undef,
            currentChar                 => undef,
            # When one of the current profiles for this session changes, this flag is set to TRUE
            currentProfChangeFlag       => FALSE,
            #
            # The calling function supplies the world name, host IP address, port, character name,
            #   password and flags. They are stored here until required
            initWorld                   => $world,
            initHost                    => $host,
            initPort                    => $port,
            initChar                    => $char,
            initPass                    => $pass,
            initAccount                 => $account,
            initProtocol                => $protocol,
            initLoginMode               => $loginMode,
            initOfflineFlag             => $offlineFlag,
            initTempFlag                => $tempFlag,

            # Cages
            # -----

            # Cages are discussed in the comments for GA::Client->new. Each cage has a unique name
            #   in the form type_profileCategory_profileName, e.g 'alias_world_deathmud', so the
            #   built-in maximum length is 42 characters (8_16_16 - type has maximum length of 8
            #   characters, profiles, 16 characters)
            # Registry hash of all cages associated with this world and its profiles. Hash in the
            #   form
            #   $cageHash{unique_name} = blessed_reference_to_cage_object
            cageHash                    => {},      # [otherprof]
            # Registry hash of all cages associated with current profiles - a subset of
            #   $self->cageHash
            currentCageHash             => {},
            # Registry connecting each cage in ->cageHash to its inferior cage. Hash in the form
            #   $inferiorCageHash{cage_name} = blessed_reference_to_inferior_cage
            #   $inferiorCageHash{cage_name} = undef (if there is no inferior cage)
            inferiorCageHash            => {},

            # Dictionaries
            # ------------

            # Blessed reference of the current dictionary
            currentDict                 => undef,

            # Interfaces
            # ----------

            # Interfaces are triggers, aliases, macros, timers and hooks
            # 'Active' interfaces are those that are in operation right now (although, if their
            #   ->enabledFlag is FALSE, they won't do anything). Active interfaces are
            #   GA::Interface::Active objects that inherit most of their values from a parent
            #   GA::Interface::Trigger (etc) object
            # (Interface names max 32 chars)
            #
            # Registry hash of active interface objects, in the form
            #   $interfaceHash{unique_name} = blessed_reference_to_active_interface_object
            interfaceHash               => {},
            # Parallel registry hash of active interface objects, containing the same list of
            #   objects, in the form
            #       $interfaceNumHash{number} = blessed_reference_to_active_interface_object
            interfaceNumHash            => {},
            # How many interfaces have been created during this session
            interfaceCount              => 0,
            # Whenever a dependent interface fires, some part of the Axmud code is called; that
            #   code may want to delete the interface. However, it's not a good idea to start
            #   tampering with the interface registries while some other part of the code is
            #   halfway through checking them
            # This list contains a list of interfaces that have been marked for deletion by
            #   $self->deleteInterface. The list is checked whenever the task loop spins, and the
            #   interfaces are deleted.
            deleteInterfaceList         => [],

            # Registry hash of active trigger interface objects, in the form
            #   $triggerHash{number} = undef
            triggerHash                 => {},
            # Registry hash of active trigger interface objects which are in a cooldown period
            #   (contains a subset of $self->triggerHash). Hash in the form
            #   $triggerCooldownHash{number} = cooldown expiry time (matches $self->sessionTime)
            triggerCooldownHash         => {},
            # Registry list of active trigger interface numbers. Contains all the keys in
            #   ->triggerHash, but in order of creation, e.g. (2, 3, 5, 10, 19)
            # This makes sure that triggers fire in a predictable order
            triggerOrderList            => [],
            # Registry hash of active alias interface objects, in the form
            #   $aliasHash{number} = undef
            aliasHash                   => {},
            # Registry hash of active alias interface objects which are in a cooldown period
            #   (contains a subset of $self->aliasHash). Hash in the form
            #   $aliasCooldownHash{number} = cooldown expiry time (matches $self->sessionTime)
            aliasCooldownHash           => {},
            # Registry list of active alias interface numbers. Contains all the keys in
            #   ->aliasHash, but in order of creation, e.g. (2, 3, 5, 10, 19)
            # This makes sure that aliases fire in a predictable order
            aliasOrderList              => [],
            # Registry hash of active macro interface objects, in the form
            #   $macroHash{number} = keycode
            # ...where 'keycode' is an Axmud standard keycode (or keycode string)
            macroHash                   => {},
            # Registry hash of active macro interface objects which are in a cooldown period
            #   (contains a subset of $self->triggerHash). Hash in the form
            #   $macroCooldownHash{number} = cooldown expiry time (matches $self->sessionTime)
            macroCooldownHash           => {},
            # Registry list of active macro interface numbers. Contains all the keys in
            #   ->macroHash, but in order of creation, e.g. (2, 3, 5, 10, 19)
            # This makes sure that macros fire in a predictable order
            macroOrderList              => [],
            # Registry hash of active timer interface objects which have a numeric stimulus, e.g.
            #   60 for a timer that fires every 60 seconds
            # Hash in the form
            #   $timerNumHash{number} = next_fire_time
            # ...where next_fire_time matches $self->sessionTime
            timerNumHash                => {},
            # Registry hash of active timer interface objects which have a clock time stimulus in
            #   the form HH::MM (timers that fire once a day) or 99:MM (timers that fire at the same
            #   time every hour)
            # Hash in the form
            #   $timerClockHash{number} = clock_time
            timerClockHash              => {},
            # Timers with clock time stimuluses are checked once a minute. This IV records the time
            #   of the last check (and is set to a value in the form HH:MM)
            timerLastClock              => undef,
            # Registry list of active timer interface numbers. Contains all the keys in
            #   ->timerNumHash and ->timerClockHash, but in order of creation, e.g. (2, 3, 10, 19)
            # This makes sure that timers fire in a predictable order
            timerOrderList              => [],
            # Registry hash of active hook interface objects, in the form
            #   $hookHash{number} = hook_event
            hookHash                    => {},
            # Registry hash of active hook interface objects which are in a cooldown period
            #   (contains a subset of $self->hookHash). Hash in the form
            #   $hookCooldownHash{number} = cooldown expiry time (matches $self->sessionTime)
            hookCooldownHash            => {},
            # Registry list of active hook interface numbers. Contains all the keys in
            #   ->hookHash, but in order of creation, e.g. (2, 3, 5, 10, 19)
            # This makes sure that hooks fire in a predictable order
            hookOrderList               => [],

            # Interface responses that are instructions (world commands, forced world commands
            #   beginning with ',,', client commands beginning ';', echo commands beginning '"',
            #   Perl commands beginning '/', script commands beginning '&' and multi commands
            #   beginning ':') may need to access information about the event that caused the
            #   interface to fire
            # When an interface fires, the information is stored in this hash. When the response
            #   has been completed, the information is removed
            # The keys of the hash are a variable available to the programmes executed by Perl
            #   commands (those starting with '/'); e.g. the value of the key '_hookVar' is
            #   available to those programes as the variable $_hookVar
            perlCmdDataHash             => {
                # All interfaces
                '_interface'            => undef,
                # Trigger interfaces
                '_line'                 => undef,
                '_stripLine'            => undef,
                '_modLine'              => undef,
                # Alias interfaces
                '_originalCmd'          => undef,
                # Macro response
                '_keycode'              => undef,
                # Timer response
                '_timerExpect'          => undef,
                '_timerTime'            => undef,
                # Hook response
                '_hookEvent'            => undef,
                '_hookVar'              => undef,
                '_hookVal'              => undef,
            },
            # Parallel hash used by trigger interfaces which stores escape sequences (converted into
            #   Axmud colour/style tags) and their positions in a line of received text (see the
            #   comments in $self->checkAliases)
            perlCmdTagHash              => {},
            # Parallel list used by trigger interfaces which stores the portions of a line of
            #   received text which match the group substrings of the regex used as the trigger's
            #   ->stimulus (see the comments in $self->checkAliases)
            perlCmdGrpStringList        => [],

            # Tasks
            # -----

            # Tasks are mini-scripts which interact with the world, and with the data Axmud stores
            #   about the world. Axmud comes with several built-in tasks - some of which are more
            #   useful than others - and provides the possibility of the user writing new ones. (It
            #   is usually quicker to write scripts in Axmud's own scripting language, Axbasic;
            #   writing your own tasks allows you to customise Axmud's behaviour, but it's not
            #   possible to modify them which Axmud is running.)
            #
            # Actually, there are two categories of task - 'process' tasks and 'activity' tasks
            # 'Process' tasks are usually called once per task loop. They are good for performing
            #   actions in small chunks. called 'stages'. A process task might have the following
            #   chunks: (1) Go e;e;e;s;e to the bank (2) Wait for the character to arrive (3)
            #   Withdraw money (4) Check the inventory, to see if your character has enough (5) Go
            #   w;n;w;w;w to the town centre (6) Wait for the arrival (7) End the task
            # 'Activity' tasks are event-driven: after initial setup, nothing happens until they
            #   are prompted, usually as the result of a trigger, alias, macro, timer or hook
            # All tasks - both activities and processes - should inherit from GA::Generic::Task
            # Axmud maintains several tasklists. The most important one is the 'current tasklist',
            #   containing all the tasks that are running now.
            # There is also a 'global initial tasklist'. containing tasks which start as soon as the
            #   user connects to any world. Each profile also has its own initial tasklist,
            #   containing tasks which start when the user connects to a particular world, or with a
            #   particular character, and so on
            # Finally, there is also a 'custom tasklist', which wait in the background until the
            #   user starts them. These tasks are usually customised by the user to perform a
            #   particular function.
            #
            # Some tasks are 'jealous' - only one copy of them can be run at any time.
            # In any case, when a new task is created, it's given a unique name (the task object's
            #   name followed by a number)
            #
            # Task name IVs have size limits. If they are breached, the task can't initialise:
            #   ->name          16  e.g. 'status_task'
            #   ->prettyName    32  e.g. 'Status Task'
            #   ->uniqueName    24  e.g. 'status_task_15'
            #   ->stage         5   e.g. '1'
            #
            # Registry hash of tasks - the 'current tasklist'
            #   ->currentTaskHash{unique_task_name} = blessed_reference_to_task_object
            #       e.g. ->currentTaskHash{'status_task_57'}
            currentTaskHash             => {},
            # The same hash, but with multiple copies of the same task eliminated. Each key is
            #   associated with the most recently created task of that type (e.g. if there are two
            #   TaskList tasks running, only the second is included)
            #   ->currentTaskNameHash{task_name} = blessed_reference_to_task_object
            #       e.g. ->currentTaskNameHash{'status_task'}
            currentTaskNameHash         => {},
            # Any tasks paused with the ';pausetask' command have their blessed references stored in
            #   this array; then, the ';resumetask' command can un-pause all of these tasks, without
            #   affecting tasks that were paused for some other reason
            pausedTaskList              => [],
            #
            # Most of Axmud's built-in tasks are 'jealous' (only one copy can be run at a time). The
            #   Script task is an exception, and can have any number of instances running
            #   simultaneously
            # Convenient shortcuts for the built-in 'jealous' tasks (set to 'undef' when the task
            #   isn't running)
            advanceTask                 => undef,
            attackTask                  => undef,
            channelsTask                => undef,
            chatTask                    => undef,   # Only stored the 'lead' Chat task
            compassTask                 => undef,
            conditionTask               => undef,
            connectionsTask             => undef,
            divertTask                  => undef,
            inventoryTask               => undef,
            launchTask                  => undef,
            locatorTask                 => undef,
            notepadTask                 => undef,
            rawTextTask                 => undef,
            rawTokenTask                => undef,
            statusTask                  => undef,
            systemTask                  => undef,
            taskListTask                => undef,
            watchTask                   => undef,

            # Axbasic scripts
            # ---------------

            # A list of Language::Axbasic::Script objects which are paused, and ready to resume
            #   (which happens at the beginning of every task loop). The list can contain multiple
            #   copies of the same LA::Script object, since the task loop checks that the script is
            #   paused, before unpausing it
            scriptResumeList            => [],

            # Recordings, missions, routes and quests
            # ---------------------------------------

            # Recordings - the user can record all commands sent to the world for a period; these
            #   recordings can then be saved as missions or routes. Only one recording at a time is
            #   possible (per session)
            # Flag is set to TRUE if a recording is currently in progress, FALSE if not
            recordingFlag               => FALSE,
            # Flag is set to TRUE if a recording is currently in progress, but is paused (FALSE if
            #   not)
            recordingPausedFlag         => FALSE,
            # The current recording, a list of strings starting with an initial character which
            #   identifies the type of command, optionally followed by whitespace, followed by the
            #   command itself (empty when no recording in progress)
            # Valid initial characters are:
            #   '>' identifies the string as a world command
            #   ';' identifies the string as a client command
            #   '#' identifies the string as a comment to be displayed in the 'main' window
            #   '@' identifies the string as a break (anything following the '@' is ignored)
            # There are three special kinds of break (see the help for ';startmission'):
            #   't' identifies a trigger break; it is followed by a string used as the stimulus
            #       in a trigger. When the trigger fires, the mission continues
            #   'p' identifies a pause break; it is followed by a number, the time (in seconds) to
            #       wait
            #   'l' identifies a Locator break; it waits for the Locator task to decide it's not
            #       expecting any more room descriptions (anything following the 'l' is ignored)
            recordingList               => [],
            # The user can specify that, while making a recording, new commands can be inserted at
            #   an arbitrary position (rather than added to the end, as happens by default).
            # Set to the line number at which the next command should be inserted, or 'undef' if
            #   new commands should just be added to the end of ->recordingList
            recordingPosn               => undef,

            # Missions are stored in the world profile. Only one mission can be carried out at a
            #   time. When the user starts a mission with ';startmission', the GA::Obj::Mission
            #   stored in the world profile is cloned, and the cloned object is stored here
            currentMission              => undef,

            # Logging
            # -------

            # Axmud can log to several different logfiles, all of which are in the /logs
            #   directory and its sub-directories (in the Axmud data directory, $DATA_DIR)
            # The 'sleep', 'passout' and 'dead' logfiles are written by the Status task, which
            #   attempts to write lines of text received by the world before and after the event
            # After one of these events, the line until which these logfiles should be written, if
            #   allowed (matches $self->displayBufferCount). Set to 'undef' when those logfiles
            #   aren't being written because the event hasn't happened recently)
            logAsleepUntilLine          => undef,
            logPassedOutUntilLine       => undef,
            logDeadUntilLine            => undef,

            # System loops
            # ------------

            # The session time (used by various parts of the code that need a time that stays
            #   consistent for some measured period, but which is updated frequently). Set by
            #   $self->spinTimerLoop, whenever the session loop spins
            # (See also the corresponding client IV, GA::Client->clientTime)
            sessionTime                 => 0,
            # For the benefit of GA::Client->spinClientLoop, an IV set to $self->sessionTime rounded
            #   down to the nearest second, every time $self->getTimeLabelText() is called
            # (The connection info strip object, GA::Strip::ConnectInfo, displays three time counts,
            #   and it's very distracting if they don't update simultaneously)
            connectInfoCheckTime        => undef,
            # A string describing the date and time at which the connection was actually established
            #   (used by GA::Strip::ConnectInfo to show a tooltip). Initially set by
            #   $self->connectionComplete. Set to 'undef' whenever $self->status is not 'connected'
            connectedTimeString         => undef,

            # Session loop
            # ------------

            # The GA::Obj::Loop which handles the session loop
            # NB The maintenance, timer, incoming data, task and replay loops do not have their
            #   own GA::Obj::Loop object. Instead, there is a single loop object for the session
            #   loop
            # The session loop, every time it spins, checks whether it's time to call the
            #   subservient loops' ->spin functions, based on the IVs below
            sessionLoopObj              => undef,
            # Flag set to TRUE (by GA::Obj::Loop->spinLoop) when the session loop spins, set back to
            #   FALSE when the spin is complete. The TRUE setting prevents one session loop spin
            #   from taking place if another is still being processed
            sessionLoopSpinFlag         => FALSE,
            # Flag set to TRUE (by $self->spinTimerLoop, etc) when any of the session loop's
            #   subservient loops spin, set back to FALSE when the spin is complete. The TRUE
            #   setting prevents one type of subservient loop from spinning if another type of loop
            #   spin is already being processed
            childLoopSpinFlag           => FALSE,
            # The session loop default delay, in seconds (never changes once set; absolute minimum
            #   value is 0.01)
            sessionLoopDelay            => 0.05,

            # GA::Obj::TextView->scrollToTop and ->scrollToBottom request scrolling through
            #   Gt3k::TextView->scroll_to_mark, which scrolls smoothly (a nice visual effect) but
            #   fails when many lines are display in the textview
            # As a result, we let Gtk do its smooth scrolling (which preserves the nice visual
            #   effect), then a little later (on every spin of the maintain loop, and after every
            #   call to GA::Obj::Desktop->updateWidgets) we force the textview to the top/bottom
            #   (which fixes any failures to scroll)
            # Hash of Gtk3::TextView objects (not GA::Obj::TextView objects) which must be forcibly
            #   scrolled to the top on the next session loop (via a call to
            #   $self->forceScrollTextViews). Hash in the form
            #       $textViewScrollUpHash{textview_object} = textview_object
            textViewScrollUpHash        => {},
            # Hash of Gtk3::TextView objects (not GA::Obj::TextView objects) which must be forcibly
            #   scrolled to the bottom on the next session loop. Hash in the form
            #       $textViewScrollDownHash{textview_object} = textview_object
            textViewScrollDownHash      => {},

            # Maintenance loop
            # ----------------

            # The maintenance loop delay, in seconds (can be changed while the session is running,
            #   but should not be set lower than $self->sessionLoopDelay)
            maintainLoopDelay           => 0.1,
            # The time at which the maintenance loop should next spin (i.e. the time at which
            #   $self->spinSessionLoop should call $self->spinMaintainLoop)
            # When set to 'undef', the loop is not running at all; the first time it spins, set to 0
            maintainLoopCheckTime       => undef,

            # If the world profile defines slowwalking settings (a maximum number of world commands
            #   to send per time period), excess world commands are stored here until it's time to
            #   send them to the world
            # This IV is updated after the time interval specified by
            #   GA::Profile::World->excessCmdDelay, and any excess stored commands can be sent. Set
            #   by $self->spinMaintainLoop to the current value of $self->sessionTime
            lastExcessCmdTime           => 0,
            # How many world commands have been sent during that second
            excessCmdCount              => 0,
            # A list of stored commands that can't be sent yet; a list in the form
            #   (command, time, command, time)
            # ...where time is the $self->sessionTime after which it's safe to send the command
            excessCmdList               => [],

            # Crawl mode is a temporary slowwalking mode. Once enabled, the maximum number of
            #   world commands to send per time period depends on the IVs just below, not on the
            #   world profile IVs (as they usually do)
            # Once enabled, crawl mode waits up to two minutes for an excess command to be stored.
            #   If none are stored within that time, crawl mode disables itself. If at least one
            #   excesss command is stored, crawl mode will apply until the excess command list is
            #   emptied; at that point it disables itself (immediately)
            # Flag set to TRUE when crawl mode is enabled, FALSE when disabled
            crawlModeFlag               => FALSE,
            # When crawl mode is enabled, the maximum number of commands to send per second (not
            #   per time period, as with the world profile IVs)
            crawlModeCmdLimit           => undef,
            # How long to wait for the first excess command to be stored, before crawl mode disables
            #   itself (in seconds)
            crawlModeWaitTime           => 120,
            # The time at which crawl mode, if no excess commands have been stored, disables itself
            #   (set back to 'undef' as soon as the first excess command is stored; matches
            #   $self->sessionTime)
            crawlModeCheckTime          => undef,

            # v1.0.287 - it's apparently possible for $self->worldCmd to be called a second time,
            #   while the function is still processing commands from an earlier call. As a result,
            #   world commands can be processed in the wrong order.
            # To resolve this issue, the second call to $self->worldCmd temporarily stores the
            #   world commands in this list, and the function returns immediately. When the first
            #   call to ->worldCmd has completed, if there are any world commands in this emergency
            #   list, then they are processed
            emergencyCmdList            => [],
            # This flag is set to TRUE at the start of a call to ->worldCmd, and FALSE at the end
            #   of it
            worldCmdProcessFlag         => FALSE,
            # When $self->worldCmd is called with a string of world commands (e.g.
            #   'north;east;north'), we don't want to redraw the automapper's ghost room once for
            #   each movement command (of which there might be hundreds)
            # Instead, the function temporarily stores the ghost room (if one exists at the time
            #   the function is called) here, to guarantee that it gets redrawn
            worldCmdGhostRoom           => undef,

            # Delayed quit. If this IV is set, it is the moment in the future (matches
            #   $self->sessionTime) at which some kind of 'quit' or 'exit' client command must be
            #   performed
            delayedQuitTime             => undef,
            # When it's time to perform the delayed quit, the actual client command to use - will
            #   be one of 'quit', 'qquit', 'exit', 'xxit' (the ';' sigil is not required)
            # NB The client commands ';quitall' / ';exitall' set these IVs in every session
            delayedQuitCmd              => undef,
            # The disconnection time (a real clock time), set upon disconnection, and used to
            #   update the 'main' window's connection info host label. Set to 'undef' until
            #   $self->status changes from 'connected' to 'disconnected'
            disconnectTime              => undef,

            # Timer loop
            # ----------

            # The timer loop delay, in seconds (can be changed while the session is running, but
            #   should not be set lower than $self->sessionLoopDelay)
            timerLoopDelay              => 0.1,
            # The time at which the timer loop should next spin (i.e. the time at which
            #   $self->spinSessionLoop should call $self->spinTimerLoop)
            # When set to 'undef', the loop is not running at all; the first time it spins, set to 0
            timerLoopCheckTime          => undef,

            # Incoming data loop
            # ------------------

            # The incoming data loop delay, in seconds (can be changed while the session is running,
            #   but should not be set lower than $self->sessionLoopDelay)
            incomingLoopDelay           => 0.1,
            # The time at which the incoming data loop should next spin (i.e. the time at which
            #   $self->spinSessionLoop should call $self->spinIncomingLoop)
            # When set to 'undef', the loop is not running at all; the first time it spins, set to 0
            incomingLoopCheckTime       => undef,

            # An emergency buffer used for invalid escape sequences which are probably the result of
            #   a valid escape sequence split over two packets, the second of which hasn't been
            #   received yet. Set and reset by $self->processIncomingData
            emergencyBuffer             => undef,

            # The world's host address and port (the ones actually used, not the ones supplied by
            #   the calling function - just in case they are different)
            host                        => undef,
            port                        => undef,
            # Which connection protocol this session is using: 'telnet', 'ssh' or 'ssl' ('undef'
            #   when not connected)
            protocol                    => undef,
            # The GA::Obj::Telnet handling the connection ('undef' when not connected)
            connectObj                  => undef,
            # For SSH connections, the Net::OpenSSH and Perl pty (an IO::Tty filehandle) objects
            #   ('undef' for telnet/SSL connections and when not connected)
            sshObj                      => undef,
            ptyObj                      => undef,
            # For SSL connections, the IO::Socket::SSL object ('undef' for telnet/SSH connections
            #   and when not connected)
            sslObj                      => undef,
            # The number of packets received (i.e. the number of times $self->processIncomingData
            #   has been called) during this session
            packetCount                 => 0,
            # The current connection status:
            #   'waiting'       - The first connection hasn't been attempted yet
            #   'connecting'    - Attempting to connect
            #   'connected'     - Connected to the remote host
            #   'offline'       - Session opened in 'connect offline' mode
            #   'disconnected'  - Disconnected from the remote host (or connection failed, or an
            #                       'offline' mode session has finished, or an MXP crosslinking
            #                       operation is in progress)
            status                      => 'waiting',
            # On disconnection, $self->reactDisconnect might be called before $self->doDisconnect
            #   has finished (such as during blind mode, when the 'Disconnected' message is still
            #   being read aloud)
            # On the call to ->doDisconnect, this flag is set to TRUE. When the call finishes, it is
            #   set back to FALSE. ->reactDisconnect (if called) won't do anything if this flag ist
            #   TRUE
            doDisconnectFlag            => FALSE,
            # On disconnection, $self->reactDisconnect is called from several places in the session
            #   code. In rare circumstances (such as the GA::Obj::Telnet object returning TRUE to
            #   an ->eof() call), it might be called more than once
            # On the first call, this flag is set to TRUE. On any subsequent calls, nothing happens
            #   if this flag is TRUE
            reactDisconnectFlag         => FALSE,

            # $self->processIncomingData tokenises the incoming data, and then processes the tokens,
            #   one at a time. When it's time to display a complete or partial line, it calls
            #   $sef->respondIncomingData
            # However, some tokens (particularly MXP tokens) can't be fully processed while there
            #   are tokens waiting to be displayed (e.g. when switching between MXP frames)
            # To cope with that, these IVs are set every time ->processIncomingData processes a
            #   token, and are reset every time $self->respondIncomingData is called
            # In that way, when the MXP tag <FRAME> is processed with a call to
            #   $self->processMxpFrameElement, that function can force all undisplayed tokens to be
            #   displayed (using a call to $self->respondIncomingData), before processing the
            #   token containing the <FRAME> tag
            # The complete or partial line received from the world, before any non-text tokens are
            #   removed
            processOrigLine             => '',
            # The same complete or partial line with all non-text tokens removed
            processStripLine            => '',
            # A hash of the stripped Axmud colour/style tags, in the form
            #   $processTagHash{line_offset} = reference_to_list_of_tags
            # NB To keep the code simple, the hash always contains an entry corresponding to the
            #   start of the string in $self->processStripLine
            processTagHash              => {
                0                       => [],
            },
            # When $self->processIncomingData processes tokens, it calls a function to handle each
            #   type of token. If one of the called functions needs to display a partial line,
            #   it calls $self->respondIncomingData early
            # All calls to $self->respondIncomingData set this flag to TRUE, so that the loop in
            #   $self->processIncomingData knows to update, not replace, the IVs ->processOrigLine,
            #   ->processStripLine and ->processTagHash
            processRetainFlag           => FALSE,
            # This IV is generally the same as $self->processStripLine, but with a description of
            #   each image drawn. It's set whenever a text token or image is processed, and only
            #   reset when $self->processStripLine is reset
            # The value of the IV is used for writing logs
            processImageLine            => '',

            # When explicit line numbers and explicit colour/style tags are displayed on a line, in
            #   between actual portions of text displayed by the world, clickable links appear in
            #   the wrong place (because the link doesn't know about the added text)
            # This IV is set and reset by $self->processLineSegment and ->displayLinePieces to the
            #   length of all explicit line numbers/colour tags displayed on the line so far
            explicitTextLength          => 0,
            # During VT100 emulation, the world can switch between 'Set cursor key to application'
            #   mode and 'Set cursor key to cursor' mode. The current mode, set to 'cursor'
            #   (default) or 'application'
            ctrlCursorMode              => 'cursor',
            # During VT100 emulation, the world can switch between 'Set numeric keypad' mode and
            #   'set alternative keypad' mode. The current mode, set to 'numeric' (default) or
            #   'alternate'
            ctrlKeypadMode              => 'numeric',

            # Task loop
            # ---------

            # The task loop delay, in seconds (can be changed while the session is running, but
            #   should not be set lower than $self->sessionLoopDelay)
            taskLoopDelay               => 0.1,
            # The time at which the task loop should next spin (i.e. the time at which
            #   $self->spinSessionLoop should call $self->spinTaskLoop)
            # When set to 'undef', the loop is not running at all; the first time it spins, set to 0
            taskLoopCheckTime           => undef,

            # Flag set to TRUE if the task loop should be frozen - nothing happens when
            #   $self->taskLoop is called
            # NB A frozen task loop doesn't prevent other code from calling a task's method
            freezeTaskLoopFlag          => FALSE,
            # Set to TRUE when the first task loop completes (required by $self->startTask)
            firstTaskLoopCompleteFlag   => FALSE,
            # The time (matches $self->sessionTime) at which each task should next be called.
            #   ->taskCallHash{task_unique_name} = next_call_time
            # Set to '0' if the task should be called on the next task loop, if it should be called
            #   every task loop, or if the task is an activity task
            taskCallHash                => {},

            # Replay loop
            # -----------

            # The replay loop delay, in seconds (can be changed while the session is running, but
            #   should not be set lower than $self->sessionLoopDelay)
            replayLoopDelay             => 0.05,     # Same as $self->sessionLoopDelay, please
            # The time at which the replay loop should next spin (i.e. the time at which
            #   $self->spinSessionLoop should call $self->spinReplayLoop)
            # When set to 'undef', the loop is not running at all; the first time it spins, set to 0
            replayLoopCheckTime         => undef,

            # The time at which the replay loop first spun (system time, in seconds)
            replayLoopStartTime         => undef,
            # The time at which the replay loop last spun (seconds after ->replayLoopStartTime)
            replayLoopTime              => undef,
            # The time at which the replay loop is due to halt (matches ->sessionTime)
            replayLoopStopTime          => undef,

            # Flag set to TRUE if the text buffer should be replayed, FALSE (or 'undef') if not
            replayLoopTextFlag          => undef,
            # Flag set to TRUE if the command buffer should be replayed, FALSE (or 'undef') if not
            replayLoopCmdFlag           => undef,
            # The line number of the next buffer line that can be 'replayed'
            replayLoopNextText          => undef,
            replayLoopNextCmd           => undef,

            # The replay text buffer hash (modelled on the display buffer hash)
            replayDisplayBufferHash     => {},
            replayDisplayBufferCount    => 0,
            replayDisplayBufferFirst    => undef,
            replayDisplayBufferLast     => undef,

            # The replay command buffer hash (modelled on the command buffer hash)
            replayCmdBufferHash         => {},
            replayCmdBufferCount        => 0,
            replayCmdBufferFirst        => undef,
            replayCmdBufferLast         => undef,

            # Buffers
            # -------

            # The display buffer for this session. Every line of text received from the world and
            #   display in the default tab object is assigned its own GA::Buffer:Display object in
            #   this session's registry (there is no equivalent registry in GA::Client)
            # Text which is redirected to another tab (for example, because MXP has created internal
            #   frames) is not stored in this hash
            # Text which is inserted in the Gtk3::TextBuffer at an insertion point other than the
            #   one at the end of the buffer is added to the end of this hash, on a new line.
            #   Earlier lines in the hash are not modified)
            # When the buffer is full, adding a line to the buffer also deletes the oldest existing
            #   one
            # The hash might not be the same size as the default tab's Gtk3::TextBuffer (although
            #   they are, by default); so it could happen that text visible in the textview is no
            #   longer stored in this hash, or that text still stored in this hash is no longer
            #   visible in the textview
            # Hash in the form
            #   $displayBufferHash{number} = blessed_reference_to_buffer_object
            displayBufferHash           => {},
            # How many lines have been added to the buffer altogether (used to give every
            #   GA::Buffer::Display object a unique number)
            displayBufferCount          => 0,
            # The ->number of the earliest surviving GA::Buffer::Display object in the buffer,
            #   deleted when the buffer is full and a new object is added
            displayBufferFirst          => undef,
            # The ->number of the most recently-created GA::Buffer::Display object in the buffer
            #   (when set, always one less than $self->displayBufferCount)
            displayBufferLast           => undef,

            # The instruction buffer for this session. Every instruction processed by this session
            #   is assigned its own GA::Buffer::Instruct object in this session's registry, as well
            #   as a separate GA::Buffer:Instruct object in the Axmud client's registry
            # Instructions includes client commands like ';setworld deathmud', Perl commands, echo
            #   commands as well as world commands
            # In addition, if the user types 'north;kill troll;eat corpse', that chain of world
            #   commands is stored as a single GA::Buffer::Instruct (and also as three separate
            #   GA::Buffer::Cmd objects in the world command buffer)
            # When the buffer is full, adding an instruction to the buffer also deletes the oldest
            #   existing one
            # Hash in the form
            #   $instructBufferHash{number} = blessed_reference_to_buffer_object
            instructBufferHash          => {},
            # How many instructions have been processed altogether (used to give every
            #   GA::Buffer::Instruct object a unique number)
            instructBufferCount         => 0,
            # The ->number of the earliest surviving GA::Buffer::Instruct object in the buffer,
            #   deleted when the buffer is full and a new object is added
            instructBufferFirst         => undef,
            # The ->number of the most recently-created GA::Buffer::Instruct object in the buffer
            #   (when set, always one less than $self->instructBufferCount)
            instructBufferLast          => undef,
            # When the user navigates through instructions in an 'internal' window's command entry
            #   box by pressing the up/down arrow keys, the number of the buffer object whose
            #   instruction is currently used (matches a key in ->instructBufferHash)
            # Set on the first such key press when this is the current session. Reset back to
            #   'undef' when another session becomes the current session, or an instruction is
            #   processed (even if it's a $self->pseudoCmd call)
            instructBufferPosn          => undef,

            # The world command buffer for this session. Every world command processed by this
            #   session is assigned its own GA::Buffer::Cmd object in this session's registry, as
            #   well as a separate GA::Buffer:Cmd object in the Axmud client's registry
            # If the user types 'north;kill troll;eat corpse', three world commands are processed
            #   and three GA::Buffer:Cmd objects are added
            # When the buffer is full, adding an instruction to the buffer also deletes the oldest
            #   existing one
            # Hash in the form
            #   $cmdBufferHash{number} = blessed_reference_to_buffer_object
            cmdBufferHash               => {},
            # How many world commands have been processed altogether (used to give every
            #   GA::Buffer::Cmd object a unique number)
            cmdBufferCount              => 0,
            # The ->number of the earliest surviving GA::Buffer::Cmd object in the buffer, deleted
            #   when the buffer is full and a new object is added
            cmdBufferFirst              => undef,
            # The ->number of the most recently-created GA::Buffer::Cmd object in the buffer (when
            #   set, always one less than $self->cmdBufferCount)
            cmdBufferLast               => undef,
            # When the user navigates through world commands in an 'internal' window's command entry
            #   box by pressing the up/down arrow keys, the number of the buffer object whose
            #   world command is currently used (matches a key in ->cmdBufferHash)
            # Set on the first such key press when this is the current session. Reset back to
            #   'undef' when another session becomes the current session, or an instruction is
            #   processed (even if it's a $self->pseudoCmd call)
            cmdBufferPosn               => undef,

            # When the Locator task is running and a new command is sent to the world,
            #   GA::Buffer::Cmd->interpretCmd is called to decide whethere it's a movement command
            #   or not (unless it's already been processed as a redirect mode command or an assisted
            #   move)
            # This IV is usually set to 'unknown', which tells ->interpretCmd that it can decide for
            #   itself whether the sent command is a movement command, or not
            # $self->moveCmd briefly sets the IV to 'is_move', meaning that it is definitely a
            #   movement command
            # $self->relayCmd briefly sets the IV to 'not_move', meaning that it is definitely NOT a
            #   movement command
            moveMode                    => 'unknown',
            # Flag set temporarily to TRUE by $self->checkAssistedMove, when it cancels a movement
            #   command in protected moves mode (when the world models' ->superProtectedMovesFlag
            #   is also TRUE).
            # $self->worldCmd and ->spinMaintainLoop both process world commands by calling
            #   $self->processWorldCmd. After the call is returned, they both check the value of
            #   this flag and, if it's TRUE, they both stop processing world commands;
            #   ->spinMaintainLoop deletes all excess commands that might be stored. Then they
            #   restore this flag's value to FALSE
            overruleMoveFlag            => FALSE,

            # Four IVs set so any code can work out how long the user has been idle (in this
            #   session). To work out the user's idle time, subtract these values from
            #   $self->sessionTime
            # The time at which text was last received from the world and displayed in the default
            #   textview object ('undef' if no text has been received yet)
            #  Text received and displayed in a different
            #   textview object does not count. Matches $self->sessionTime, set by
            #   $self->updateDisplayBuffer
            lastDisplayTime             => undef,
            # The time at which the last instruction was processed ('undef' if no instructions have
            #   been processed yet)
            # Includes client commands, world commands, Perl commands and echo commands. Matches
            #   $self->sessionTime, set by $self->updateInstructBuffer
            lastInstructTime            => undef,
            # The time at which the last world command was processed ('undef' if no world commands
            #   have been processed yet)
            # Matches $self->sessionTime, set by $self->updateCmdBuffer
            lastCmdTime                 => undef,
            # The time at which the last out-of-bounds communication was processed ('undef' if no
            #   communications have been processed yet)
            lastOutBoundsTime           => undef,
            # How many seconds should we wait before the 'user_idle' and 'world_idle' hook events
            #   take place (i.e. how many seconds after $self->lastCmdTime or
            #   $self->lastDisplayTime)
            # NB Setting the literal value to 0 would cause these hook events to never happen
            constHookIdleTime           => 60,
            # Flags set to TRUE when one of these hook events happens; set back to FALSE the next
            #   time $self->lastCmdTime or $self->lastDisplayTime are updated
            disableUserIdleFlag         => FALSE,
            disableWorldIdleFlag        => FALSE,

            # For text-to-speech, we try to process the whole packet of received text, before
            #   converting it to speech. Processed line segments are added to the existing text
            #   in this IV by $self->processLineSegment; the IV is then reset by
            #   $self->incomingDataLoop
            ttsBuffer                   => '',
            # What type of message was last converted to speech by a call from this session to
            #   GA::Client->tts: 'receive' for text received from the world, 'system', 'error' for
            #   system messages, 'command' or 'cmd' for a world command, 'dialogue' for a 'dialogue'
            #   window or 'task' for a task window message or 'other' for something else (used for
            #   multi-line system messages, so the TTS engine doesn't have to read out 'system
            #   message' at the beginning of every consecutive line)
            ttsLastType                 => undef,
            # Flag that can be set to TRUE by anything, that wants to temporarily disable TTS for
            #   system messages (but not system error messages). Used mainly by $self->start, so
            #   that all the copyright (etc) messages don't get read out (so that the first thing
            #   the user hears is 'Connecting to...' )
            ttsTempDisableFlag          => FALSE,

            # Tabs and textviews
            # ------------------

            # When there are no file objects in $self->sessionFileObjHash whose ->modifyFlag is set
            #   to TRUE (meaning that none of them need to be saved), this flag is set to FALSE
            # When the first file object has its ->modifyFlag set to TRUE, $self->setModifyFlag
            #   calls GA::Table::Pane->setSessionTabLabel for each session using the same world
            #   profile as this one
            # That function displays an asterisk in the label for the session's default tab. In
            #   addition, this flag gets set to TRUE
            # The flag is set back to FALSE by $self->checkTabLabels when all file objects are saved
            showModFlag                 => FALSE,
            # Flag set to TRUE whenever text is received from the world while this session isn't the
            #   current session. Set back to FALSE when this session becomes the current session
            showNewTextFlag             => FALSE,
            # Which colour to use in the default tab's label
            #   'normal'        - Show the normal colour
            #   'active'        - Show red (meaning that it's not the current session, and that some
            #                       text has been received by the world, which hasn't yet been seen
            #                       by the user)

            #   'disconnected'  - Show blue (the session is disconnected)
            #   'offline'       - Show magenta (the session is running in 'connect offline' mode)
            showTabColourMode           => 'normal',
            # The xterm title sent by the world (even if it isn't displayed in the tab label);
            #   'undef' if no xterm title has been received
            xTermTitle                  => undef,
            # Flag set to TRUE when an xterm title is received (but only when
            #   GA::Client->xTermTitleFlag is TRUE), set back to FALSE by $self->checkTabLabels once
            #   the tab label has been updated
            showXTermTitleFlag          => FALSE,
            # Some MUDs might use the OSC colour palette to adjust the colour of the basic 16 ANSI
            #   colours. If an escape sequence 'ESC]Pxxxxxxx' is received (where x is a hexadecimal
            #   character in the range 0-9, A-F), this hash is updated which causes
            #   $self->processEscSequence to use the adjusted colour instead of the Axmud standard
            #   colour tag. Hash in the form
            #       $oscColourHash{standard_colour_tag} = rgb_colour_tag
            # If the MUD hasn't tried to adjust the basic 16 ANSI colours, then this hash will be
            #   empty
            oscColourHash               => {},

            # Blinkers
            # --------

            # 'Internal' windows (and especially 'main' windows) can include a strip object
            #   (GA::Strip::ConnectInfo) that displays blinkers - little blobs of colour which are
            #   lit up (briefly) when data is sent to and forth from the world
            # This version of Axmud implements the following blinker numbers:
            #   0   - blinker turned on when data is received from the world
            #   1   - blinker turned on when telnet option/protocol data (invisible to users) is
            #           received from the world
            #   2   - blinker turned on when a world command is sent
            # In each window, only the window object's ->visibleSession can light up blinkers; when
            #   the visible session changes, all blinkers are reset
            # Hash of blinkers and their current status, for when this session is the visible
            #   session. Hash in the form
            #       $blinkerStatusHash{blinker_number} = state
            # ...where 'state' represents the blinker's state: when the blinker is turned off, set
            #   to 'undef', when the blinker is turned on, set to a time (matches
            #   GA::Client->clientTime) when the blinker should be turned off again
            blinkerStateHash            => {
                    0                   => undef,
                    1                   => undef,
                    2                   => undef,
            },

            # Telnet options
            # --------------

            # ECHO mode:
            #   'no_invite' - The server has not suggested ECHO yet
            #   'client_agree' - The server has suggested that the client stop ECHOing, and the
            #       client has agreed
            #   'client_refuse' - The server has suggested that the client stop ECHOing, and the
            #       client has refused
            #   'server_stop' - The server has suggested that the client resume ECHOing, and the
            #       client has agreed
            echoMode                    => 'no_invite',
            # Special echo mode (not a telnet option)
            # Some worlds (MUD1, MUD2, GreaterMUD etc) turn on echo mode permanently, which leaves
            #   the 'main' window's command entry box permanently obscured
            # When $self->doLogin is called to process an automatic login, if $self->echoMode is
            #   still 'client_agree', the value of this IV is set to 'waiting'
            # Then, when the first text token is received from the world, if $self->echoMode is
            #   still 'client_agree', the value of this IV is set to 'enabled'
            # When 'enabled', the command entry box is artificially unobscured
            # In addition, if direct keys are enabled in this session, the GA::Strip::Entry object
            #   (which handles the command entry box) goes into a special operating mode, in which
            #   all characters typed by the user are sent to the world immediately, one character at
            #   a time. Non-world commands, such as client commands, are not affected. Forced world
            #   commands are also not affected
            # Furthermore, the ESCAPE, TAB, BACKSPACE, DELETE and ENTER keys are picked up by a
            #   ->signal_connect in GA::Win::Internal->setKeyPressEvent, and are also sent directly
            #   to the world
            # If direct keys are enabled/disabled during the session, this IV's value switches
            #   between 'enabled' and 'disabled' too; the entry strip object and internal window
            #   only act on individual keys when the value is 'enabled'
            specialEchoMode             => 'normal',

            # SGA mode:
            #   'no_invite' - The server has not suggested SGA yet
            #   'client_agree' - The server has suggested that the client SGA, and the client has
            #       agreed
            #   'client_refuse' - The server has suggested that the client SGA, and the client has
            #       refused
            #   'server_stop' - The server has suggested that the client stopped SGA, and the client
            #       has agreed
            sgaMode                     => 'no_invite',
            # List of TTYPE terminal types to send (items are removed from the front of the list,
            #   one by one, until one item remains, which should be 'unknown', and then this item
            #   is sent again, if another server request is received)
            sendTTypeList               => [],
            # The first terminal type sent is supposed to be the client's preferred one. It is
            #   stored here, in case anything needs it later
            specifiedTType              => undef,
            # EOR mode:
            #   'no_invite' - The server has not suggested EOR yet, but the client is willing
            #   'client_agree' - The server has suggested EOR, and the client has agreed
            #   'client_refuse' - The client refuses to use EOR
            eorMode                     => 'no_invite',
            # Flag set to TRUE if a single IAC EOR or IAC GA sequence (indicating a prompt) has been
            #   received from the world, in which case $self->processLinePortion doesn't need to
            #   check that lines that don't end in a newline do end with text matching a pattern in
            #   the world profile's ->cmdPromptPatternList
            eorgaFlag                   => FALSE,
            # NAWS mode:
            #   'no_invite' - The server has not suggested NAWS yet, but the client is willing
            #   'client_agree' - The server has suggested NAWS, and the client has agreed
            #   'client_refuse' - The client refuses to use NAWS
            nawsMode                    => 'no_invite',
            # NEW-ENVIRON mode:
            #   'no_invite' - The server has not suggested NEW-ENVIRON yet, but the client is
            #                   willing
            #   'client_agree' - The server has suggested NEW-ENVIRON, and the client has agreed
            #   'client_refuse' - The client refuses to use NEW-ENVIRON
            newEnvironMode              => 'no_invite',
            # CHARSET mode:
            #   'no_invite' - The server has not suggested CHARSET yet, but the client is willing
            #   'client_agree' - The server has suggested CHARSET, and the client has agreed
            #   'client_refuse' - The client refuses to use CHARSET
            charSetMode                 => 'no_invite',

            # IVs for MUD protocols
            # ---------------------

            # MSDP mode:
            #   'no_invite' - The server has not suggested MSDP yet, but the client is willing
            #   'client_agree' - The server has suggested MSDP, and the client has agreed
            #   'client_refuse' - The client refuses to use MSDP
            #   'client_ignore' - Axmud ignores incoming MSDP data, because earlier data is invalid
            msdpMode                    => 'no_invite',
            # Hash of generic MSDP commands supported by the server (returned after LIST COMMANDS)
            msdpGenericCmdHash          => {},          # Set below
            # Hash of custom MSDP commands supported by the server (in the same form as above, but
            #   only supported commands are included, so for all key-value pairs, the value is TRUE)
            msdpCustomCmdHash           => {},
            # Hash of generic MSDP lists supported by the server (returned after LIST LISTS)
            msdpGenericListHash         => {},          # Set below
            # Hash of custom MSDP lists supported by the server (in the same form as above, but only
            #   supported lists are included, so for all key-value pairs, the value is TRUE)
            msdpCustomListHash          => {},
            # During the initial negotiation, Axmud asks the server to send its supported lists,
            #   waiting for a response before sending the next one. This IV keeps track of where we
            #   are in the process.
            #   'none_sent' - No LIST command sent yet
            #   'sent_commands' - Sent LIST COMMANDS, waiting response
            #   'sent_lists' - Sent LIST LISTS, waiting response
            #   'sent_other' - Sent LIST <something else>, waiting for a response
            #   'ready' - All responses received (the client might repeat any of them at any
            #       point in the future, but ->mdspInitMode remains set to 4)
            msdpInitMode                => 'none_sent',
            # During ->msdpInitMode 'sent_other', a list of supported lists to request. As each
            #   request is sent, the first item in this list is removed until it is empty
            msdpRequestList             => [],
            # Hash of generic MSDP configurable variables supported by the server (returned after
            #   LIST CONFIGURABLE_VARIABLES
            msdpGenericConfigFlagHash   => {},          # Set below
            # Hash of custom MSDP configurable variables (in the same form as above, but only
            #   supported configurable variables are included, so for all key-value pairs, the value
            #   is TRUE)
            msdpCustomConfigFlagHash    => {},
            # Hash of generic MSDP configurable variables whose values have been configured (sent to
            #   the server), in the form
            #       $hash{variable} = value
            # ...where 'variable' is one of the keys in $self->msdpGenericConfigFlagHash and 'value'
            #   is its corresponding value
            msdpGenericConfigValHash    => {},
            # Hash of custom MSDP configurable variables whose values have been configured (in the
            #   same form)
            msdpCustomConfigValHash     => {},
            # Hash of generic MSDP reportable variables supported by the server (returned after
            #   LIST REPORTABLE_VARIABLES
            msdpGenericReportableFlagHash
                                        => {},          # Set below
            # Hash of custom MSDP reportable variables (in the same form as above, but only
            #   supported reportable variables are included, so for all key-value pairs, the value
            #   is TRUE)
            msdpCustomReportableFlagHash
                                        => {},
            # Hash of generic MSDP reported variables supported by the server (returned after
            #   LIST REPORTED_VARIABLES; but is also updated after a call to
            #   $self->optSendMsdpReport or ->optSendMsdpUnreport)
            msdpGenericReportedFlagHash => {},          # Set below
            # Hash of custom MSDP reported variables (in the same form as above; key-value pairs can
            #   have a TRUE or FALSE value) (updated after LIST REPORTED VARIABLES; but is also
            #   updated after a call to $self->optSendMsdpReport or ->optSendMsdpUnreport)
            msdpCustomReportedFlagHash  => {},
            # Hash of generic MSDP sendable variables supported by the server (returned after
            #   LIST SENDABLE_VARIABLES)
            msdpGenericSendableFlagHash => {},          # Set below
            # Hash of custom MSDP sendable variables (in the same form as above, but only
            #   supported sendable variables are included, so for all key-value pairs, the value
            #   is TRUE)
            msdpCustomSendableFlagHash  => {},
            # Hash of generic MSDP reportable variables whose values have been reported (sent by
            #   the server), in the form
            #       $hash{variable} = value
            # ...where 'variable' is one of the keys in $self->msdpGenericReportableFlagHash and
            #   'value' is its corresponding value
            msdpGenericValueHash        => {},
            # Hash of custom MSDP reportable variables whose values have been reported (in the same
            #   form)
            msdpCustomValueHash         => {},
            # If an MSDP sanity check fails, this IV is set with a 3-digit error code representing
            #   the point at which the failure occured
            msdpSanityNum               => undef,

            # MSSP mode:
            #   'no_invite' - The server has not suggested MSSP yet, but the client is willing
            #   'client_agree' - The server has suggested MSSP, and the client has agreed
            #   'client_refuse - The client refuses to use MSSP
            msspMode                    => 'no_invite',

            # MCCP mode (both MCCP1 and MCCP2 are implemented):
            #   'no_invite' - The server has not suggested MCCP yet, but the client is willing
            #   'client_agree' - The server has suggested MCCP, and the client has agreed
            #   'client_refuse' - The client refuses to use MCCP
            #   'compress_start' - The server has suggested MCCP, the client has agreed, and the
            #       server has signalled that it has started compression
            #   'compress_error' - During mode 'compress_start' there was a compression error, so
            #       the client has signalled the server to stop using MCCP
            #   'compress_stop' - During mode 'compress_start' the client sent Z_FINISH, terminating
            #       the compression stream
            mccpMode                    => 'no_invite',

            # MSP mode:
            #   'no_invite' - The server has not suggested MSP yet, but the client is willing
            #   'client_agree' - The server has suggested MSP, and the client has agreed
            #   'client_refuse' - The client refuses to use MSP
            #   'client_simulate' - The server did not negotiate MSP, but Axmud is responding to MSP
            #       sound/music triggers
            mspMode                     => 'no_invite',
            # A registry of GA::Obj::Sound objects. A new one is created for every sound played by
            #   GA::Client->playSoundFile (usually initiated by MSP), but not Axmud sound effects
            #   played by GA::Client->playSound (which are independent of MSP)
            # (Only sounds played by this session are stored in this session's hash)
            # Hash in the form
            #   $soundHarnessHash{unique_number} = $sound_object
            soundHarnessHash            => {},
            # The number of GA::Obj::Sound objects created (used to give each object a unique number
            soundHarnessCount           => 0,
            # The default URL to use for MSP downloads (if allowed)
            mspDefaultURL               => undef,

            # MXP mode:
            #   'no_invite' - The server has not suggested MXP yet, but the client is willing
            #   'client_agree' - The server has suggested MXP, and the client has agreed
            #   'client_refuse - The client refuses to use MXP
            mxpMode                     => 'no_invite',
            # MXP line mode, set by $self->extractEscapeSequences as each MXP escape sequence is
            #   processed, and reset by the end of each line
            #   0   - Open line
            #           'Only MXP commands in the "open" category are allowed. When a newline is
            #           received from the MUD, the mode reverts back to the Default mode.  OPEN MODE
            #           starts as the Default mode until changes with one of the "lock mode" tags
            #           listed below.'
            #   1   - Secure line
            #           'All tags and commands in MXP are allowed within the line. When a newline is
            #           received from the MUD, the mode reverts back to the Default mode.'
            #   2   - Locked line
            #           'No MXP or HTML commands are allowed in the line. The line is not parsed for
            #           any tags at all. This is useful for "verbatim" text output from the MUD.
            #           When a newline is received from the MUD, the mode reverts back to the
            #           Default mode.'
            # NB Set to 'undef' when $self->mxpMode is not 'client_agree', or when it is
            #   'client_agree' but no MXP escape sequences have been processed yet. Immediately
            #   after that, the default value for ->mxpLineMode is 0 (Open line)
            mxpLineMode                 => undef,
            # MXP default line mode, also set by $self->extractEscapeSequences as MXP escape
            #   sequences are processed
            #   0   - No lock mode
            #           ->mxpLineMode resets to the default line mode, 0, after every line
            #   5   - Lock open mode
            #           'Set open mode. Mode remains in effect until changed. OPEN mode becomes the
            #           new default mode.'
            #   6   - Lock secure mode
            #           'Set secure mode. Mode remains in effect until changed. Secure mode becomes
            #           the new default mode.'
            #   7   - Lock locked mode
            #           'Set locked mode. Mode remains in effect until changed. Locked mode becomes
            #           the new default mode.'
            # NB Set to 'undef' when $self->mxpMode is not 'client_agree', or when it is
            #   'client_agree' but no MXP escape sequences have been processed yet. Immediately
            #   after that, the default value for ->mxpDefaultMode is 0 (No lock mode)
            mxpDefaultMode              => undef,
            # MXP temporary secure mode. When '<ESC>[4z', the escape sequence for 'temp secure mode'
            #   is received, this IV is set to the value of $self->mxpLineMode. The escape sequence
            #   must be followed by an MXP tag <...>; as soon as it's been processed, the value of
            #   ->mxpMode is restored, and the value of this IV is set back to 'undef'
            mxpTempMode                 => undef,
            # When the link object (GA::Obj::Link) is created by the <A> and <SEND> tags, it can't
            #   be applied to the current textview immediately, because there may be some
            #   displayable text before the link which hasn't been displayed yet
            # If so, $self->processMxpLinkElement and ->processMxpSendElement store the link object
            #   in this list, temporarily, so that ->processIncomingData can apply the link(s) to
            #   the textview as soo as it's ready
            mxpTempLinkList             => [],
            # A hash of current user-defined MXP elements, each stored in a GA::Mxp::Element
            #   object. Since element names are case insensitive, Axmud stores user-defined elements
            #   with lower-case names. Hash in the form
            #       $mxpElementHash{name} = blessed_reference_of_mxp_element_object
            mxpElementHash              => {},
            # A hash of current user-defined MXP entities, each stored in a GA::Mxp::Entity
            #   object. Entity names are case-sensitive. Hash in the form
            #       $mxpEntityHash{name} = blessed_reference_of_mxp_entity_object
            # NB There is a constant hash of entity names in GA::Client->constMxpEntityHash. If the
            #   world chooses to redefine one of these standard entity names, a GA::Mxp::Entity
            #   object is created for it and added to $self->mxpEntityHash; but standard entity
            #   names that haven't been redefined don't have their own GA::Mxp::Entity object
            # NB Axmud also recognises entities in the form &#nnn; - but they don't exist in
            #   GA::Client->constMxpEntityHash and are never added to this hash
            mxpEntityHash               => {},
            # A hash of file filters specified by the world, each stored in a GA::Mxp::Filter
            #   object. Hash in the form
            #       $mxpFilterHash{source_extension} = blessed_reference_of_mxp_filter_object
            #   ...where 'source_extension' is the file extension of the world's own image/sound
            #       format, e.g. 'gff' (which the specified plugin will convert to 'gif'
            mxpFilterHash               => {},
            # A hash of current MXP frames, each stored in a a GA::Mxp::Frame object (the MXP spec
            #   doesn't say if the frame name is case-sensitive or not, so Axmud will treat them as
            #   case-sensitive). Used for both external and internal frames
            # The first frame added always has the name '_top'. The name '_previous' refers to
            #   one of the frames in this hash, so there is never a frame object called '_previous'
            # Hash in the form
            #   $mxpFrameHash{frame_name} = blessed_reference_to_frame_object
            mxpFrameHash                => {},
            # The name of the frame currently being used to display text received from the world,
            #   and the name of the frame previously used to display text received from the world
            mxpCurrentFrame             => undef,
            mxpPrevFrame                => undef,
            # For internal frames, the size of the default tab's pane object on the 60x60 grid when
            #   the first frame (besides the default '_top' frame) is added
            mxpFrameWidth               => undef,
            mxpFrameHeight              => undef,
            # For internal frames, the size of new frames is a factor of the original size of the
            #   default tab's pane object
            mxpFrameXFactor             => 0.33,        # Width is 33% of default pane
            mxpFrameYFactor             => 0.25,        # Height is 25% of default pane
            # When a <DEST>...</DEST> construction is being processed, the opening <DEST> tag causes
            #   the creation on a GA::Mxp::Dest object. When the matching </DEST> tag is processed,
            #   the text received between the tags is sent to the specified frame. Meanwhile, any
            #   text tokens encountered are added to the object's ->text IV
            mxpCurrentDest              => undef,
            # When a <V>...</V> construction is being processed, the opening <V> tag causes the
            #   creation of a GA::Mxp::Var object. When the matching </V> tag is processed, the
            #   object's properties are transferred to the corresponding entity. Meanwhile, any
            #   text tokens encountered are added to the object's ->value IV
            # The current GA::Mxp::Var object, if we're between matching <V> and </V> tags
            mxpCurrentVar               => undef,
            # When an <A>...</A> construction is being processed, the opening <A> tag causes the
            #   creation of a temporary GA::Obj::Link object whose ->number is set to -1. When the
            #   matching </A> tag is processed, the object is added to the current textview and
            #   given a unique ->number within that textview
            # The temporary (unfinished) GA::Obj::Link object, if we're between matching <A> and
            #   </A> tags
            mxpCurrentLink              => undef,
            # When a <SEND>...</SEND> construction is being processed, the opening <SEND> tag causes
            #   the creation of a GA::Obj::Link object whose ->number is set to -1. When the
            #   matching </SEND> tag is processed, the object is added to the current textview and
            #   given a unique ->number within that textview
            # The temporary (unfinished) GA::Obj::Link object, if we're between matching <SEND>
            #   and </SEND> tags
            mxpCurrentSend              => undef,
            # Hash used to store the text between two matching custom elements, when those elements
            #   have a FLAG=... argument
            # e.g. from the MXP spec, <!ELEMENT RName FLAG="RoomName"> ... <RName>Temple</RName>
            # When the closing tag is received, any text between the matching tags is stored in the
            #   display buffer object for the line containing the closing tag
            # If there were newline characters between matching tags, they are ignored (and a space
            #   character is inserted, if necessary); so the stored text is always a single string
            #   without newline characters
            # The MXP spec defineds six standard tag properties. 'RoomName', 'RoomDesc', 'RoomExit'
            #   and 'RoomNum' are handled by the Locator task. 'Prompt' is handled by
            #   $self->processLineSegment. 'Set xxx' is handled by $self->popMxpStack. Non-standard
            #   tag properties are simply stored in the display buffer, as described above
            # Hash in the form
            #   $mxpFlagTextHash{tag_property} = string
            mxpFlagTextHash             => {},
            # As soon as the closing tag is received, a key value pair is removed from
            #   ->mxpFlagTextHash and added to this hash, which is used by $self->processLineSegment
            #   to update the display at the next available opportunity
            # Hash in the form
            #   $mxpFlagTextStoreHash{tag_property} = string
            mxpFlagTextStoreHash        => {},
            # MXP can send a style sheet number, indicating "the current version of the optional
            #   style sheet" which seems to be something to do with the subset of elements and
            #   entities currently in use. The MXP spec only requires us to store this number, and
            #   to return it in response to a VERSION tag
            mxpStyleSheetNum            => undef,
            # Two flags used to handle the MXP tags <NOBR>, <P> and </P>, and set/reset by
            #   $self->processMxpSpacingTag
            mxpIgnoreNewLineFlag        => FALSE,
            mxpParagraphFlag            => FALSE,
            # Equvalent flag used to handle the MXP tags <H1>...</H1> to <H6>...</H6>
            mxpHeadingFlag              => FALSE,
            # If MXP has created a 'main' window gauge box, GA::Strip::GaugeBox object which handles
            #   gauges in that 'main' window
            mxpGaugeStripObj            => undef,
            # If MXP has created a 'main' window gauge box, the unique number of the
            #   GA::Obj::GaugeLevel object used to draw MXP gauges
            mxpGaugeLevel               => undef,
            # A hash of GA::Obj::Gauge objects, one for each MXP gauge drawn. Hash in the form
            #   $mxpGaugeHash{entity_name} = gauge_object
            # ...where 'entity_name' matches a key in $self->mxpEntityHash
            mxpGaugeHash                => {},
            # Whenever an entity is created or updated, it is added to this hash so that
            #   $self->updateMxpGauges (called by $self->spinMaintainLoop) knows to update the
            #   corresponding gauges. Hash in the form
            #       $mxpGaugeUpdateHash{entity_name} = undef
            mxpGaugeUpdateHash          => {},
            # MXP server crosslinking mode, used in response to a <RELOCATE>...</RELOCATE> sequence
            #   'none' - No crosslinking operation is in progress, or a crosslinking operation has
            #       been completed (and the character has been marked 'logged in')
            #   'wait_start' - The <RELOCATE> tag has been received, and the crosslinking operation
            #       is waiting to start
            #   'started' - The crosslinking operation has started, and the old connection has been
            #       terminated
            #   'wait_login' - The new connection has been initiated, and we're waiting to log in
            #       (manually or automatically)
            mxpRelocateMode             => 'none',
            # The hostname and port specified by the <RELOCATE> tag
            mxpRelocateHost             => undef,
            mxpRelocatePort             => undef,
            # Flag set to TRUE when the <QUIET> tag is received, set back to FALSE when the
            #   </RELOCATE> tag is received
            mxpRelocateQuietFlag        => FALSE,
            # Flag set to TRUE when an MXP escape sequence (<esc>!19z) is received, which is
            #   equivalent to a <QUIET> tag for a single line
            mxpRelocateQuietLineFlag    => FALSE,
            # Flag set to TRUE when an external frame (implemented as a Frame task window) is
            #   close by the user; when TRUE, frames are disable for the rest of the session
            mxpDisableFrameFlag         => FALSE,
            # MXP login mode, used to react when the world sends the <USER> and <PASSWORD> tags
            #   umprompted (i.e., not during a server crosslinking operation). In case either tag is
            #   ever sent twice during a session, any part of the code can reset this IV back to 0,
            #   ready to receive the next <USER> tag
            #   'no_tag' - Neither tag has been received
            #   'user_tag' - The <USER> tag has been received,
            #   'pwd_tag' - The <PASSWORD> tag has been received (after <USER> was received)
            mxpLoginMode                => 'no_tag',

            # Pueblo mode:
            #   'no_invite' - The server has not suggested Pueblo
            #   'client_agree' - The server has suggested Pueblo, and the client has agreed
            #   'client_refuse - The server has suggested Pueblo, and the client has refused
            # (NB In line with other major MUD clients, Axmud offers only partial Pueblo support)
            puebloMode                  => 'no_invite',
            # The Pueblo version detected (e.g. 1.10). Axmud responds with 'PUEBLOCLIENT 2.01', just
            #   as zMud does
            puebloVersion               => undef,
            # When the </XCH_MUDTEXT> tag is received, Axmud begins interpreting text received from
            #   the world as HTML. and this flag is set to TRUE. When the corresponding
            #   <XCH_MUDTEXT> tag is received, Axmud resumes interpreting text as normal, and this
            #   flag is set to FALSE
            puebloActiveFlag            => FALSE,
            # The default base font size (in the range 1-7, default 3), used by <FONT> tags and set
            #   by <BASEFONT>
            puebloBaseFontSize          => 3,
            # A list of GA::Pueblo::List objects currently being processed, each one representing
            #   an unordered list (<UL>...</UL>) or and ordered list <OL>...</OL>)
            # If the list contains more than one object, the lists are embedded within each other.
            #   The outermost object is the first one in the list, and the currently processed
            #   object is the last one in the list
            puebloStackList             => [],
            # For list items (<LI>), the number of space characterss per list. A simple list will
            #   have three spaces before each list item. If there are two lists, one embedded within
            #   the other, each list item will have six spaces. If there are three lists, nine
            #   spaces. (This roughly matches the format of help files)
            puebloColumnSize            => 3,
            # Flag set to TRUE inside a <P>...</P> construction, and FALSE outside it
            puebloParagraphFlag         => FALSE,
            # Flag set to TRUE inside a <CODE>...</CODE> construction, and FALSE outside it
            # (Axmud currently implements <PRE>...</PRE> in the same way, so that construction also
            #   sets the flag to TRUE)
            puebloLiteralFlag           => FALSE,
            # Flag set to TRUE inside a <SAMP>...</SAMP> construction, and FALSE outside it
            #   (Axmud implements <SAMP> in the same wasy <CODE> except that whitespace is not
            #   reduced inside <SAMP>...</SAMP> constructions)
            puebloLiteralSampFlag       => FALSE,
            # After a tag like </CENTER>, the Axmud style tag 'justify_default' must be applied to
            #   the beginning of the next line (or, at least, the beginning of the next line that
            #   might contain characters. Various functions set this IV to these values:
            #       'normal' - normal operations
            #       'wait_newline' - </CENTER> tag processed, now waiting for newline character
            #       'wait_loop' - newline character processed, now waiting for next loop inside
            #               $self->processIncomingData
            puebloJustifyMode           => 'normal',

            # ZMP mode:
            #   'no_invite' - The server has not suggested ZMP yet, but the client is willing
            #   'client_agree' - The server has suggested ZMP, and the client has agreed
            #   'client_refuse' - The client refuses to use ZMP
            zmpMode                     => 'no_invite',
            # The UTC timestamp received with a 'zmp.time' command. 'undef' if that ZMP command has
            #   never been received; otherwise a string in the form 'YYYY-MM-SS HH:MM:SS'
            zmpTimeStamp                => undef,
            # Flag set to TRUE if the client has sent a 'zmp.ident' command to the server (this
            #   command must only be sent once)
            zmpSendIdentFlag            => FALSE,
            # The server's name, version and description received with a 'zmp.ident' command; any or
            #   all of which might be 'undef' if the server didn't include the correct number of
            #   parameters
            zmpServerName               => undef,
            zmpServerVersion            => undef,
            zmpServerDescrip            => undef,

            # AARD102 (Aardwolf 102 channel) mode:
            #   'no_invite' - The server has not suggested AARD102 yet, but the client is willing
            #   'client_agree' - The server has suggested AARD102, and the client has agreed
            #   'client_refuse' - The client refuses to use AARD102
            aard102Mode                 => 'no_invite',
            # AARD102 sends sequences in the form of two bytes; the first byte is 100 or 101
            # For 100, this IV is updated, depending on the value of the second byte:
            #   100, 1  - 'at_login_screen'   'At login screen, no player yet'
            #   100, 2  - 'at_motd'           'Player at MOTD or other login sequence'
            #   100, 3  - 'player_active'     'Player fully active and able to receive MUD commands'
            #   100, 4  - 'player_afk'        'Player AFK'
            #   100, 5  - 'note_mode'         'Player in note mode'
            #   100, 6  - 'edit_mode'         'Player in Building/Edit mode'
            #   100, 7  - 'page_prompt'       'Player at paged output prompt'
            #   100, 8  - 'player_rest'       (proposed - possibly never implemented)
            #   100, 9  - 'code_nine'         (used by Aardwolf, not sure what it represents)
            #   100, n  - 'unknown_n', where n is any other value (range 1-254)
            aard102Status               => undef,
            # For 101, the second byte is always 1, meaning 'the MUD just ticked'. This IV records
            #   the time (matches $self->sessionTime) at which the last tick took place
            aard102TickTime             => undef,

            # ATCP mode:
            #   'no_invite' - The server has not suggested ATCP yet, but the client is willing
            #   'client_agree' - The server has suggested ATCP, and the client has agreed
            #   'client_refuse' - The client refuses to use ATCP
            atcpMode                    => 'no_invite',
            # Hash of ATCP data received. Keys are strings representing
            #   'Package[.SubPackage][.Message]'; the corresponding values are GA::Obj::Atcp objects
            # NB ATCP package names are case-insensitive; Axmud always converts them to lower case
            #   before storing them in this hash
            atcpDataHash                => {},

            # GMCP mode:
            #   'no_invite' - The server has not suggested GMCP yet, but the client is willing
            #   'client_agree' - The server has suggested GMCP, and the client has agreed
            #   'client_refuse' - The client refuses to use GMCP
            gmcpMode                    => 'no_invite',
            # Hash of GMCP data received. Keys are strings representing
            #   'Package[.SubPackage][.Message]'; the corresponding values are GA::Obj::Gmcp objects
            # NB GMCP package names are case-insensitive; Axmud always converts them to lower case
            #   before storing them in this hash
            gmcpDataHash                => {},

            # MCP mode:
            #   'no_invite' - The server has not suggested MCP yet, but the client is willing
            #   'client_agree' - The server has suggested MCP, and the client has agreed
            #   'client_refuse' - The client refuses to use MCP
            mcpMode                     => 'no_invite',
            # The MCP version that Axmud supports
            constMcpVersion             => 2.1,
            # During MCP negotiation, $self->generateMcpKey generates a random key, which the server
            #   must quote in all future MCP messages. The key is stored here
            mcpAuthKey                  => undef,
            # For the MCP package 'mcp-negotiate', version 1.0, there is no 'mcp-negotiate-end'
            #   message. To cope with this, how long (in seconds) we should spend waiting for it,
            #   after the first MCP out-of-bounds line is received
            mcpWaitTime                 => 30,
            # The time (matches $self->sessionTime) at which this time limit expires
            mcpCheckTime                => undef,
            # When MCP messages define multiline values, all incoming MCP key/value data is stored
            #   in an MCP multiline object (GA::Mcp::Obj::MultiLine) until the server has finished
            #   sending them. Hash in the form
            #   $mcpMultiObjHash{unique_data_tag} = blessed_reference_to_multiline_object
            mcpMultiObjHash             => {},
            # Hash of MCP package objects, inheriting from GA::Generic::MCP
            # The hash includes some or all of the packages specified in GA::Client->mcpPackageHash,
            #   which consists of Axmud-supported MCP packages and MCP packages added by plugins
            # When an MCP package object is added to this hash by $self->detectMCP or
            #   $self->processMcpMsg, it's cloned from the MCP package object with the same name in
            #   GA::Client->mcpPackageHash
            # Hash in the form
            #   $mcpPackageHash{package_name} = blessed_reference_to_package_object
            mcpPackageHash              => {},
            # Hash of MCP package objects which specify alternative MCP packages in their
            #   ->supplantList IVs
            # We don't inform the world we support these packages until the world sends
            #   'mcp-negotiate-end'. At that point, MCP package objects in this hash are either
            #   copied into $self->mcpPackageHash, or destroyed
            # For all other packages, we inform the world we support them as soon as we receive the
            #   initial 'mcp' message
            # Hash in the form
            #   $mcpWaitHash{package_name} = blessed_reference_to_package_object
            mcpWaitHash                 => {},
            # IVs to handle 'mcp-cord' messages
            # Hash of MCP cord types recognised by some part of the Axmud code. When the server
            #   tries to open a cord of a certain type, a message is sent to the function specified
            #   in this hash
            # To add an entry to this hash, your code should call $self->add_mcpCordType; to remove
            #   it, call $self->del_mcpCordType
            # Hash in the form
            #   $mcpCordOpenHash{mcp_cord_type} = function_reference
            mcpCordOpenHash             => {},
            # Hash of unique MCP cord IDs. When an MCP cord message is received, it is passed on to
            #   the function specified in this hash
            # Hash in the form
            #   $mcpCordIDHash{mcp_cord_id} = function_reference (if a cord is open)
            #   $mcpCordIDHash{mcp_cord_id} = undef (if a cord was open, but is now closed)
            # NB 'mcp_cord_id' is the cord ID, with the initial I/R removed (we expect that the
            #   server sends IDs starting with an initial I, and Axmud always sends IDs starting
            #   with a letter R)
            mcpCordIDHash               => {},
            # Hash of functions that should be notified when the server closes an MCP cord; only
            #   contains entries for cords that are still open. Hash in the form
            #   $mcpCordCloseHash{mcp_cord_id} = function_reference
            # NB 'mcp_cord_id' is the cord ID, with the initial I/R removed
            mcpCordCloseHash            => {},

            # List of MXP/Pueblo/MCP debug messages created when processing a token. A message is
            #   added to the list by a call to $self->mxpDebug, $self->puebloDebug or
            #   $self->mcpDebug. When $self->processIncomingData is ready, any debug messages are
            #   displayed. (Doing it this way saves us from some very ugly Gtk3 errors)
            # List in groups of 4, in the form
            #   (protocol, token, num, message...)
            # ...where 'protocol' is the string 'mxp', 'pueblo' or 'mcp', 'token' is the original
            #   MXP, Pueblo or MCP token (e.g. '<H1>'), 'num' is an identifying 4-digit error number
            #   and 'message' is the error message
            # NBH Currently, MXP errors use the range 1000-4999, Pueblo errors use the range
            #   6000-7999, mixed MXP/Pueblo errors use the range 5000-5999, MCP errors use the range
            #   8000-8999 and any other code (e.g. temporary eror messages) can use the range
            #   9000-9999
            protocolDebugList           => [],

            # Other IVs
            # ---------

            # Flag used by ';simulatecommand' to temporarily block any world commands from being
            #   sent to the world (by $self->dispatchCmd); however, everything else - such as
            #   the call to $self->checkAliases and ->updateCmdBuffer - happen as normal, as if the
            #   commands had been sent
            # Set to TRUE if all world commands should be blocked. Set to FALSE otherwise
            disableWorldCmdFlag         => FALSE,

            # Integer (0 or above) used by generic client command functions
            spelunkerMode               => 0,

            # Redirect mode (which transforms all world commands that are (custom) primary and/or
            #   secondary directions, e.g. 'north', 'n' or 'out', into a world command like
            #   'sail north', 'sail n' or 'sail out'
            # The redirect mode string. Every occurence of the character @ is replaced by the
            #   original command, e.g. 'sail @' becomes 'sail north', 'sail n' or 'sail out'. Set to
            #   'undef' when redirect mode  is turned off
            redirectString              => undef,
            # Redirect mode can operate in one of three states:
            #   'primary_only' - redirect primary directions
            #   'primary_secondary' - redirect primary and secondary directions (but not relative
            #       directions; redirect mode doesn't work with relative directions)
            #   'all_exits' - redirect primary and secondary directions, plus any command matching
            #       an exit in the current room (if set; actually the automapper's ->ghostRoom)
            # NB Redirect mode takes priority over assisted moves, if they are turned on
            redirectMode                => 'all_exits',

            # The timeout (in seconds) used by $self->doConnect when attempting to open a telnet,
            #   SSH or SSL connection
            connectTimeOut              => 60,
            # The current list of GA::Obj::Repeat objects (created by ';repeatinterval'), which
            #   store world commands to be sent several times, at fixed intervals.
            repeatObjList               => [],
            # How client commands show standard messages (produced by calls to
            #   GA::Generic::Cmd->complete, ->error or ->improper. Does not affect calls to
            #   Games::Axmud->writeText, ->writeDebug etc)
            #  'show_all' - (default) show all standard messages produced by the command (with calls
            #       to GA::Generic::Cmd->complete, ->error and ->improper)
            #  'hide_complete' - suppress messages produced by a call to GA::Generic::Cmd->complete
            #       (on the successful execution of a command), but display error messages
            #  'hide_system' -  suppress all standard messages produced by the command (with calls
            #       to GA::Generic::Cmd->complete, ->error and ->improper)
            #  'win_error' - show messages produced by a call to GA::Generic::Cmd->complete (on the
            #       successful execution of a command) in the 'main' window, but show error message
            #       calls to ->error and ->improper in a 'dialogue' window
            #  'win_only' - suppress all messages produced by a call to GA::Generic::Cmd->complete
            #       (on the successful execution of a command), but show error message calls to
            #       ->error and ->improper in a 'dialogue' window
            cmdMode                     => 'show_all',

            # For the benefit of the ';peek' command, the last string that was used in a ';poke' or
            #   a ';peek' command ('undef' if neither used during this session)
            prevPokeString              => undef,

            # When the user imports a world model via the Automapper window's 'Import/load world
            #   model' menu item, this flag gets set to TRUE. It allows ;importfiles to transfer a
            #    world model whose ->_parentWorld is not the current world, into the current world's
            #   file structure, after first prompting the user. Once the operation is complete, the
            #   Automapper window loads the file, sets its ->_parentWorld to the rigth value, saves
            #   the file, and then resets this flag
            transferWorldModelFlag      => FALSE,

            # When rewriter triggers have their 'rewrite_global' attribute set, it would be possible
            #   to cause an infinite loop by (for example) replacing a line of '=', 20 characters or
            #   more, by a line of '=' characters exactly 20 characters in length
            # Users tend to get upset when Axmud crashes, so we'll use a maximum number of rewrites
            #   per trigger, per line
            constRewriteMax             => 16,
        };

        # Bless the object into existence
        bless $self, $class;

        # Set remaining IVs
        $self->{session}                = $self;
        $self->{profPriorityList}       = [$axmud::CLIENT->constProfPriorityList];

        $self->resetMsdpData();

        return $self;
    }

    ##################
    # Methods

    sub start {

        # Called by GA::Client->startSession
        # Sets up the session
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the session can't be started
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my (
            $sessionCount, $winObj, $mode, $string, $taskObj, $pluginString, $dialogueWin,
            @list,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->start', @_);
        }

        # Count the number of sessions that exist, besides this one
        $sessionCount = 0;
        foreach my $otherSession ($axmud::CLIENT->ivValues('sessionHash')) {

            if ($otherSession ne $self) {

                $sessionCount++;
            }
        }

        # Create a 'main' window for this session (or use the existing one, if all sessions share a
        #   'main' window)
        $winObj = $self->setMainWin($sessionCount);
        if (! $winObj) {

            return $self->writeError(
                'Could not set up a \'main\' window for this session',
                $self->_objClass . '->start',
            );

        } else {

            $self->ivPoke('mainWin', $winObj);
        }

        # Set the tab used as this session's default tab, creating it if necessary
        if (! $self->setDefaultTab()) {

            # (Don't destroy a 'main' window being used by other sessions)
            if (! $axmud::CLIENT->shareMainWinFlag || ! $sessionCount) {

                $self->mainWin->winDestroy();
            }

            return $self->writeError(
                'Could not set up the \'main\' window for this session',
                $self->_objClass . '->start',
            );
        }

        # Set up profiles for this session
        if (! $self->initTempFlag) {
            $mode = 'start';        # Create a new world profile, if it doesn't yet exist
        } else {
            $mode = 'start_temp';   # Create a new temporary world profile
        }

        if (
            ! $self->setupProfiles(
                $mode,
                $self->initWorld,
                $self->initChar,
                $self->initHost,
                $self->initPort,
                $self->initLoginMode,
            )
        ) {
            # (Error message already displayed)
            return undef;
        }

        # There has been a change in current profiles (from 'nothing' to 'something'), but there are
        #   no tasks running, so they don't need to be informed about it
        $self->ivPoke('currentProfChangeFlag', FALSE);

        # Set the character set used to display text received from the world in this session
        $self->setCharSet();

        # If visible cursors are allowed, inform the default tab's textview object
        $self->textViewCursorUpdate();

        # Temporarily disable text-to-speech for the introductory system messages (a nuisance, if
        #   the user has to hear them at the start of every session)
        $self->ivPoke('ttsTempDisableFlag', TRUE);

        # If this is the first session, display information about the client
        if ($axmud::CLIENT->sessionCount <= 1) {

            $self->writeText(
                $axmud::SCRIPT . ' v' . $axmud::VERSION . ' (' . $axmud::DATE . ') - '
                . $axmud::COPYRIGHT,
            );

            $self->writeText(
                'Type \';about\' for license information, \';help\' or \';quickhelp\' for'
                . ' assistance',
            );

            $self->writeText(' ');      # Empty line

            $self->writeText('Support and new releases available from ', 'echo');
            $self->writeText($axmud::URL, 'link');

            $self->writeText(' ');      # Empty line
        }

        if ($self->initTempFlag) {

            # Display some explanatory text, one line at a time
            @list = (
                'Because you didn\'t specify a name for this world, a temporary world profile',
                '   (called \'' . $self->currentWorld->name . '\') has been created for you.',
                'Temporary worlds can\'t be saved, so any world-related data gathered during',
                '   this session will be lost unless you set a new world profile with the',
                '   \';setworld\' command.',
                'Alternatively, you can close this session and start a new one. Other kinds of',
                '   data (initial tasks, display settings, and so on) are not affected and can',
                '   still be saved using the \';save\' command.',
                ' ',
            );

            foreach my $line (@list) {

                $self->writeText($line);
            }
        }

        if ($self->initOfflineFlag) {

            # This session is operating in 'connect offline' mode
            $self->ivPoke('status', 'offline');

            # Display some reassuring text, one line at a time
            @list = (
                'This session is running in CONNECT OFFLINE mode - data files have been loaded as',
                'usual, but ' . $axmud::SCRIPT . ' is only simulating a connection to the world',
                ' ',
                'Current world     : ' . $self->currentWorld->name,
            );

            if ($self->currentGuild) {

                push (@list, 'Current guild     : ' . $self->currentGuild->name);
            }

            if ($self->currentRace) {

                push (@list, 'Current race      : ' . $self->currentRace->name);
            }

            if ($self->currentChar) {
                $string = $self->currentChar->name;
            } else {
                $string = '<none>';
            }

            push (@list,
                'Current character : ' . $string,
                ' ',
            );

            foreach my $line (@list) {

                $self->writeText($line);
            }

            # Update this session's tab label. The TRUE argument means definitely update it.
            #   (Nothing happens if the session is using a simple tab)
            $self->checkTabLabels(TRUE);

            # Update the connection info strip object for any 'internal' windows used by this
            #   session (should only be one, at this point)
            foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

                $winObj->setHostLabel($self->getHostLabelText());
            }
        }

        # Display a list of loaded plugins
        if ($axmud::CLIENT->pluginHash) {

            $pluginString = '';

            foreach my $pluginObj (
                sort {lc($a->name) cmp lc($b->name)} ($axmud::CLIENT->ivValues('pluginHash'))
            ) {
                if (! $pluginObj->enabledFlag) {
                    $pluginString .= ' -' . $pluginObj->name;
                } else {
                    $pluginString .= ' +' . $pluginObj->name;
                }
            }

            $self->writeText('Plugins loaded:' . $pluginString);
            $self->writeText(' ');
        }

        # If a world hint message is set, display it now (unless the blocking flag is set)
        if (! $axmud::CLIENT->blockWorldHintFlag && $self->currentWorld->worldHint) {

            if ($self->currentWorld->longName) {
                $string = uc($self->currentWorld->longName);
            } else {
                $string = uc($self->currentWorld->name);
            }

            $string .= ': ' . $self->currentWorld->worldHint;

            $self->writeText($string);
            $self->writeText(' ');

            # If this is the first connection to this world, also display the message in a
            #   'dialogue' window
            if (! $self->currentWorld->numberConnects) {

                $self->mainWin->showMsgDialogue(
                    'World hint',
                    'info',
                    $string .= ' (To see this message again, type \';hint\')',
                    'ok',
                );
            }
        }

        # If using a charset other than the default one, display it now
        if ($self->sessionCharSet ne $axmud::CLIENT->constCharSet) {

            $self->writeText('Using charset \'' . $self->sessionCharSet . '\'');
            $self->writeText(' ');
        }

        # When connecting to a world, the 'Connecting...' message will appear on this line, instead
        if ($self->initOfflineFlag) {

            $self->writeText('Session ready');
        }

        # Re-enable text-to-speech after displaying the introductory system messages
        $self->ivPoke('ttsTempDisableFlag', FALSE);
        # Inserting a Gtk3 update here allows all of the introductory messages actually to be
        #   displayed, before any text-to-speech stuff is done
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->start');

        # In blind mode, and only for the first session, display some helpful information
        if (
            $axmud::BLIND_MODE_FLAG
            && $axmud::CLIENT->sessionCount <= 1
            && ! $axmud::CLIENT->blindHelpMsgShownFlag
        ) {
            $self->writeText(
                'Axmud is ready to start. You might like to read the help for visually-impaired'
                . ' users. You can open it in your web browser. In this window, type a semicolon'
                . ' followed by the word blind, without any spaces. Then press return.',
            );

            $self->writeText(' ');

            $axmud::CLIENT->set_blindHelpMsgShownFlag(TRUE);
        }

        # Start the session loop (to which the maintenance, timer, incoming data, task and replay
        #   loops are subservient)
        if (! $self->startSessionLoop()) {

            return $self->writeError(
                'Could not start the session loop',
                $self->_objClass . '->start',
            );
        }

        if (! $self->initOfflineFlag) {

            # Attempt to connect to the world
            if (! $self->doConnect($self->initHost, $self->initPort)) {

                # (The return value is only false when improper arguments supplied)
                return undef;
            }

            # If an attempted connection is immediately refused by the host, $self->status will
            #   already be set to 'disconnected'. In that case, we don't want to do most of the
            #   things usually done by the rest of this function
            $self->spinIncomingLoop();

            if ($self->status eq 'disconnected') {

                $self->stopSessionLoop();

                # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
                $axmud::CLIENT->desktopObj->restrictWidgets();

                return 1;
            }
        }

        # The session may now display received text in its 'main' window tab
        $self->ivPoke('startCompleteFlag', TRUE);
        # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
        $axmud::CLIENT->desktopObj->restrictWidgets();

        # Handle automatic logins
        if ($self->initOfflineFlag) {

            # In 'connect offline' mode, the character is always marked as logged in immediately
            $self->doLogin();

        } else {

            # Set up the automatic login (if any), but obviously don't attempt a login if we
            #   don't know the character's name and password
            if ($self->currentWorld->loginMode ne 'none' && $self->initChar && $self->initPass) {

                if (! $self->setupLogin()) {

                    $self->writeWarning(
                        'Could not set up an automatic login (in login mode '
                        . $self->currentWorld->loginMode
                        . '); use the \';login\' command after logging in manually',
                        $self->_objClass . '->start',
                    );
                }
            }

            # Check for already-received text
            if ($self->initialTextBuffer) {

                # Some text has been received which we haven't displayed yet
                $self->processIncomingData($self->initialTextBuffer);
                # (We don't need to keep that text)
                $self->ivPoke('initialTextBuffer', '');
            }
        }

        return 1;
    }

    sub stop {

        # Called by GA::Client->stopSession and ->stopAllSessions (only)
        # Terminates the session. Any existing connection is terminated (without halting the
        #   session) by a call to $self->doDisconnect or to the callback $self->connectionError
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the session can't be terminated
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my $actualCount;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stop', @_);
        }

        # Stop the session loop (if running)
        if ($self->sessionLoopObj && ! $self->stopSessionLoop()) {

            return $self->writeError(
                'Could not stop the session loop',
                $self->_objClass . '->stop',
            );
        }

        # Terminate the connection, if connected (or connecting)
        if (! $self->doDisconnect()) {

            return $self->writeError(
                'Could not terminate the connection',
                $self->_objClass . '->stop',
            );
        }

        # Update IVs
        $self->ivPoke('status', 'disconnected');
        # Update the world's connection history object, if one was created for this session
        if ($self->connectHistoryObj) {

            $self->connectHistoryObj->set_disconnectedTime();
        }

        # Count the number of sessions that exist, besides this one. We can't rely on
        #   GA::Client->sessionCount, because it might have been updated by the calling functions
        $actualCount = 0;
        foreach my $otherSession ($axmud::CLIENT->ivValues('sessionHash')) {

            if ($otherSession ne $self) {

                $actualCount++;
            }
        }

        # Ask the 'main' window to remove the tab for this session (if allowed)
        # Don't bother if sessions don't share a 'main' window (because this session's 'main'
        #   window is about to be closed anyway)
        # (If the session has ended because the 'main' windows has been destroyed, then the call to
        #   $self->del_winObj will already have set $self->defaultTabObj to 'undef')
        if (
            $axmud::CLIENT->shareMainWinFlag
            && $self->defaultTabObj
            && ! $self->defaultTabObj->paneObj->removeSessionTab($self)
        ) {
            return $self->writeError(
                'Could not remove the tab for a session',
                $self->_objClass . '->stop',
            );
        }

        # Close any 'free' windows produced by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionFreeWins($self)) {

            # As one 'free' window is closed, its child 'free' windows are also closed, so we have
            #   to check the window still exists, before destroying it
            if ($axmud::CLIENT->desktopObj->ivExists('freeWinHash', $winObj->number)) {

                $winObj->winDestroy();
            }
        }

        # If sessions have their own workspace grids, remove the workspace grids (which closes their
        #   'grid' windows, but not this session's 'main' window, which we'll deal with in a moment)
        # If sessions share a workspace grid, do nothing
        $axmud::CLIENT->desktopObj->removeSessionWorkspaceGrids($self);

        # Remove any temporary zonemaps for this session
        foreach my $zonemapObj ($axmud::CLIENT->ivValues('zonemapHash')) {

            if ($zonemapObj->tempFlag && $zonemapObj->tempSession eq $self) {

                $axmud::CLIENT->del_zonemap($zonemapObj);
            }
        }

        # Check if there are any remaining 'grid' windows associated with this session and, if so,
        #   close them (but still don't close the 'main' window)
        $axmud::CLIENT->desktopObj->removeSessionWindows($self);

        # If this session has any 'external' windows on this session's workspace grid, and if this
        #   wasn't the current session, those 'external' windows may be invisible/minimised. Make
        #   them visible
        $axmud::CLIENT->desktopObj->revealGridWins($self);

        # Otherwise, when sessions don't share a 'main' window, we can delete it this session's
        #   'main' window now
        if (! $axmud::CLIENT->shareMainWinFlag) {

            $self->mainWin->winDestroy();
            $self->ivUndef('mainWin');

        } elsif (! $actualCount && ! $axmud::CLIENT->shutdownFlag) {

            # Convert the single remaining 'main' window back into a spare 'main' window
            $axmud::CLIENT->desktopObj->deconvertSpareMainWin($self->mainWin);
        }

        # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
        $axmud::CLIENT->desktopObj->restrictWidgets();

        return 1;
    }

    # Setup

    sub setMainWin {

        # Called by $self->start
        # Creates a new 'main' window or re-uses an existing one
        #
        # Expected arguments
        #   $currentCount   - The number of sessions that exist, besides this one (so can be 0)
        #
        # Return values
        #   'undef' on improper arguments or if the 'main' window can't be created
        #   1 on success

        my ($self, $currentCount, $check) = @_;

        # Local variables
        my (
            $winmap, $winObj, $successFlag, $thisWorkspaceObj, $thisWorkspaceGridObj, $thisZoneObj,
            %workspaceHash,
        );

        # Check for improper arguments
        if (! defined $currentCount || defined $check) {

             return $axmud::CLIENT->writeImproper($self->_objClass . '->setMainWin', @_);
        }

        # If a winmap has been marked as the default for this world, use it (otherwise the function
        #   returns 'undef', and a default winmap is used)
        $winmap = $self->checkWinmapWorlds($self->initWorld);

        # If sessions share a 'main' window, create a workspace grid on every workspace for this
        #   session
        # If sessions don't share a 'main' window, use the shared workspace grid on every
        #   workspace (or create one on every workspace, if this is the first session)
        OUTER: foreach my $workspaceObj ($axmud::CLIENT->desktopObj->listWorkspaces()) {

            my $gridObj;

            if ($axmud::CLIENT->shareMainWinFlag) {
                $gridObj = $workspaceObj->addWorkspaceGrid($self);
            } elsif (! $currentCount) {
                $gridObj = $workspaceObj->addWorkspaceGrid();
            }

            # (Temporarily storing workspace grid object makes the following call to
            #   ->createGridWin a lot simpler)
            if ($gridObj) {

                $workspaceHash{$workspaceObj->number} = $gridObj->number;
            }
        }

        # Create a 'main' window, or use an existing one
        if (
            ($axmud::CLIENT->shareMainWinFlag && $axmud::CLIENT->mainWin)
            || (! $axmud::CLIENT->shareMainWinFlag && ! $currentCount)
        ) {
            # Use the existing shared 'main' window
            $winObj = $axmud::CLIENT->mainWin;
        }

        if (
            ! $currentCount
            && (! $axmud::TEST_MODE_FLAG || $axmud::CLIENT->sessionCount)
            && $winObj
        ) {
            # Convert a spare 'main' window into a normal one
            if (! $axmud::CLIENT->desktopObj->convertSpareMainWin($self, $winObj, $winmap)) {

                # Could not reposition the 'main' window, for some reason. Destroy it, and allow
                #   the code below to create a new one
                $winObj->winDestroy();
                $axmud::CLIENT->reset_mainWin();
                $winObj = undef;
            }
        }

        if (! $winObj) {

            # Create a new 'main' window for this session, using the first available workspace. If
            #   >shareMainWinFlag = TRUE, we can specify the workspace grid to use, too
            OUTER: foreach my $workspaceObj ($axmud::CLIENT->desktopObj->listWorkspaces()) {

                $winObj = $workspaceObj->createGridWin(
                    'main',                                 # Window type
                    'main',                                 # Window name
                    undef,                                  # Window title set automatically
                    $winmap,                                # Winmap name
                    'Games::Axmud::Win::Internal',          # Package name
                    undef,                                  # No known Gtk3::Window
                    undef,                                  # No system internal ID
                    $self,                                  # Owner
                    $self,                                  # Owner session
                    $workspaceHash{$workspaceObj->number},  # 'undef' if ->shareMainWinFlag = FALSE
                );

                if ($winObj) {

                    # New 'main' window created on this workspace
                    last OUTER;
                }
            }
        }

        # Operation complete; if it failed, $winObj is 'undef'
        return $winObj;
    }

    sub checkWinmapWorlds {

        # Called by $self->setMainWin
        # Some winmaps are marked for use as the 'default' winmap for a particular world. Ideally,
        #   each world should have no more than one winmap which is marked as the default for that
        #   world but, just in case, we'll check all winmaps alphabetically, using the first one we
        #   find
        #
        # Expected arguments
        #   $worldName      - A world profile name (if called by $self->setMainWin, the same as
        #                       $self->initWorld)
        #
        # Return values
        #   'undef' on improper arguments or if this world has no default winmaps
        #   Otherwise, returns the first default winmap found

        my ($self, $worldName, $check) = @_;

        # Local variables
        my @winmapList;

        # Check for improper arguments
        if (defined $check) {

             return $axmud::CLIENT->writeImproper($self->_objClass . '->checkWinmapWorlds', @_);
        }

        @winmapList = sort {lc($a->name) cmp lc($b->name)} ($axmud::CLIENT->ivValues('winmapHash'));
        foreach my $winmapObj (@winmapList) {

            if ($winmapObj->ivExists('worldHash', $worldName)) {

                # This is the default winmap for the world
                return $winmapObj->name;
            }
        }

        # No default winmap found for this world
        return undef;
    }

    sub setDefaultTab {

        # Called by $self->start
        # Sets this session's default tab, adding a tab to the 'main' window if necessary
        #
        # Return values
        #   'undef' on improper arguments or if the default pane object / textview object can't be
        #       created
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my ($paneObj, $tabLabelText, $tabObj);

        # Check for improper arguments
        if (defined $check) {

             return $axmud::CLIENT->writeImproper($self->_objClass . '->setDefaultTab', @_);
        }

        # Use the first pane object (GA::Table::Pane) found as the default pane object
        #   (GA::Obj::Workspace->getWinmap has already checked that this 'main' window has one)
        $paneObj = $self->mainWin->findTableObj('pane');

        # Set the initial text to use in the session's tab label (if one is visible)
        if ($axmud::CLIENT->sessionTabMode eq 'char') {
            $tabLabelText = '(' . ucfirst($self->initWorld) . ')';
        } else {
            $tabLabelText = ucfirst($self->initWorld);
        }

        # Add a new tab (containing a textview object)
        if ($axmud::CLIENT->simpleTabFlag && ! $paneObj->tabObjHash) {

            $tabObj = $paneObj->addSimpleTab(
                $self,
                undef,              # Use default colour scheme for 'main' windows
                TRUE,               # Called by this function
                TRUE,               # Is the session's default tab
                $tabLabelText,
            );

        } else {

            $tabObj = $paneObj->addTab(
                $self,
                undef,              # Use default colour scheme for 'main' windows
                TRUE,               # Called by this function
                TRUE,               # Is the session's default tab
                $tabLabelText,
            );
        }

        if (! $tabObj) {

            return undef;

        } else {

            # Update IVs
            $self->ivPoke('defaultTabObj', $tabObj);
            $self->ivPoke('currentTabObj', $tabObj);

            # Don't let anything remove the pane object
            $paneObj->set_allowRemoveFlag(FALSE);

            return 1;
        }
    }

    sub setCharSet {

        # Called by $self->start or GA::Cmd::SetCharSet->do
        # Sets the character set used to display text received from the world in this session
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the character set now used (e.g. 'iso-8859-1', 'utf-8-strict')

        my ($self, $check) = @_;

        # Local variables
        my $charSet;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setCharSet', @_);
        }

        # Decide which IV to use
        if ($self->currentWorld->worldCharSet) {
            $charSet = $self->currentWorld->worldCharSet;
        } else {
            $charSet = $axmud::CLIENT->charSet;
        }

        # Check the character set is available by loading it
        if (! Encode::find_encoding($charSet)) {

            # Emergency fallback is 'null', which $self->incomingDataLoop uses to mean 'don't
            #   convert anything into a new character set'
            $charSet = 'null';
        }

        # Update the IV
        $self->ivPoke('sessionCharSet', $charSet);

        return $charSet;
    }

    sub setModifyFlag {

        # Called by anything
        # Sets a file object's ->modifyFlag.
        # First checks that the file object is stored in this session's file object registry
        #   ($self->sessionFileObjHash). If not passes the request to the GA::Client's own
        #   ->setModifyFlag function
        # (In this way, code - especially in the generic object, Games::Axmud - can set a flag
        #   without knowing in which registry the file object is stored)
        #
        # Expected arguments
        #   $objName    - The unique name of the file object, matching a key in
        #                   $self->sessionFileObjHash ('otherprof', 'worldmodel' or the name of a
        #                   profile associated with the current world profile) or in
        #                   GA::Client->fileObjHash (any other file object)
        #   $flag       - The setting for the flag (TRUE of FALSE)
        #
        # Optional arguments
        #
        #   $func       - The calling function. Ignored for now, if specified
        #
        # Return values
        #   'undef' on improper arguments
        #   $flag on success

        my ($self, $objName, $flag, $func, $check) = @_;

        # Check for improper arguments
        if (! defined $objName || ! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setModifyFlag', @_);
        }

        # Check the file object exists
        if (! $self->ivExists('sessionFileObjHash', $objName)) {

            # Ask the client to consult its registry
            return $axmud::CLIENT->setModifyFlag($objName, $flag, $func);

        } else {

            # Consult the session's registry
            return $self->ivShow('sessionFileObjHash', $objName)->set_modifyFlag($flag);
        }
    }

    sub sessionEmergencySave {

        # Called by GA::Client->doEmergencySave
        # Performs an emergency save, saving all file objects for this session in the specified
        #   directory (which is usually different from the normal Axmud data directory)
        #
        # Expected arguments
        #   $dir    - The directory path in which to save files
        #
        # Return values
        #   'undef' on improper arguments or if any of the save attempts fail (this function
        #       continues saving file objects, even if a save attempt fails)
        #   1 on success

        my ($self, $dir, $check) = @_;

        # Local variables
        my $errorFlag;

        # Check for improper arguments
        if (! defined $dir || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sessionEmergencySave', @_);
        }

        # Create a sub-directory for this world
        $dir .= '/worlds/' . $self->currentWorld->name;

        # Save each file object in turn
        foreach my $fileObj ($self->ivValues('sessionFileObjHash')) {

            # ('worldprof' files have already been saved by the calling function)
            if ($fileObj->fileType ne 'worldprof') {

                if (
                    ! $fileObj->saveDataFile(
                        $fileObj->actualFileName,
                        $dir . '/' . $fileObj->actualFileName,
                        $dir,
                        # The TRUE flag tells the function that an emergency save is in progress
                        TRUE,
                    )
                ) {
                    $errorFlag = TRUE;
                }
            }
        }

        if ($errorFlag) {

            # At least one save attempt failed
            return undef;

        } else {

            # All save attempts succeeded
            return 1;
        }
    }

    # Login

    sub setupLogin {

        # Called by $self->start
        # If the world profile specifies an automatic login (i.e. GA::Profile::World->loginMode is
        #   not set to 'none'), initiate the login process
        # The login process is completed by $self->doLogin
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the setup process fails
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my (
            $mode, $name, $packageName, $newTask, $rawScriptObj, $path, $scriptObj, $missionObj,
            $cloneObj, $msg,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setupLogin', @_);
        }

        # Import the world profile's login IVs
        $mode = $self->currentWorld->loginMode;
        $name = $self->currentWorld->loginObjName;

        # Mode 'immediate' - immediate login (character marked as 'logged in' as soon as the
        #   connection is established - not recommended, as tasks like the Locator task will start
        #   looking for room descriptions in the world's introductory text)
        # This is performed when $self->connectionComplete is called

        # Mode 'lp' - LP/Diku/AberMUD login (consecutive prompts for character/password)
        # Mode 'tiny' - TinyMUD login (send 'connect char pass' at first prompt)
        # Mode 'world_cmd' - send a sequence of world commands at the first prompt
        # Mode 'telnet' - basic telnet login (e.g. 'login:' 'password:')
        if ($mode eq 'lp' || $mode eq 'tiny' || $mode eq 'world_cmd' || $mode eq 'telnet') {

            $self->ivPoke('loginPromptsMode', $mode);
            if (
                $mode eq 'tiny'
                # (in mode 'world_cmd', we look for a login pattern if one has been set; otherwise
                #   we wait for a prompt, as usual)
                || ($mode eq 'world_cmd' && $self->currentWorld->loginConnectPatternList)
            ) {
                $self->ivPoke(
                    'loginConnectPatternList',
                    $self->currentWorld->loginConnectPatternList,
                );
            }

        # Mode 'task' - run a task (character is logged in when the task calls GA::Session->doLogin)
        } elsif ($mode eq 'task' && $self->currentWorld->loginObjName) {

            # $self->findTaskPackageName recognises unique names of currently running tasks (e.g.
            #   'status_task_57'), so before we consult it, check that $name isn't already running
            if ($self->ivExists('currentTaskHash', $name)) {

                $msg = '\'' . $name . '\' isn\'t a valid task name or task label';

            } else {

                # Get the task's package name (e.g. 'Games::Axmud::Task::Status')
                $packageName = Games::Axmud::Generic::Cmd->findTaskPackageName($self, $name);
                if (! $packageName) {

                    $msg = 'Could not start the task \'' . $name . '\'';

                } else {

                    # Create the task object
                    $packageName->new($self, 'current');
                }
            }

        # Mode 'script_task' - run an Axbasic script from within a task (character is logged in if
        #   the script executes a LOGIN statement)
        } elsif ($mode eq 'script_task' && $self->currentWorld->loginObjName) {

            # We need to check that the file containing the script exists, because the Script task
            #   won't pass us a convenient error return value. Create a dummy raw script to do it
            $rawScriptObj = Language::Axbasic::RawScript->new($self, $name);
            if (! $rawScriptObj) {

                $msg = 'Could not run the ' . $axmud::BASIC_NAME . ' script \'' . $name . '\'';

            } else {

                # Load the script into the raw script object
                $path = $axmud::DATA_DIR . '/scripts/' . $name . '.bas';
                if (! $rawScriptObj->loadFile($path)) {

                    $msg = 'Could not load the ' . $axmud::BASIC_NAME . ' script \'' . $name . '\'';

                } else {

                    # Create the task object
                    $newTask = Games::Axmud::Task::Script->new($self, 'current');
                    if (! $newTask) {

                        $msg = 'Could not start a Script task running the ' . $axmud::BASIC_NAME
                                    . ' script \'' . $name . '\'';

                    } else {

                        # Tell it which script to execute
                        $newTask->set_scriptName($name);
                    }
                }
            }

        # Mode 'script' - run an Axbasic script (character is logged in if the script executes a
        #   LOGIN statement)
        } elsif ($mode eq 'script' && $self->currentWorld->loginObjName) {

            # Create the raw script object
            $rawScriptObj = Language::Axbasic::RawScript->new($self, $name);
            if (! $rawScriptObj) {

                $msg = 'Could not run the ' . $axmud::BASIC_NAME . ' script \'' . $name . '\'';

            } else {

                # Load the script into the raw script object
                $path = $axmud::DATA_DIR . '/scripts/' . $name . '.bas';
                if (! $rawScriptObj->loadFile($path)) {

                    $msg = 'Could not load the ' . $axmud::BASIC_NAME . ' script \'' . $name . '\'';

                } else {

                    # Create a script object, which processes the raw script, removing extraneous
                    #   whitespace, empty lines, comments, etc
                    $scriptObj = Language::Axbasic::Script->new($self, $rawScriptObj);
                    if (! $scriptObj) {

                        $msg = 'Could not run the ' . $axmud::BASIC_NAME . ' script \'' . $name
                                    . '\'';

                    } else {

                        # Execute the script
                        $scriptObj->implement();
                    }
                }
            }

        # Mode 'mission' - start a mission (character is logged in if the mission uses the ';login'
        #   client command)
        } elsif ($mode eq 'mission' && $self->currentWorld->loginObjName) {

            # If the mission exists...
            if (! $self->currentWorld->ivExists('missionHash', $name)) {

                $msg = 'The mission \'' . $name . '\' doesn\'t exist';

            } else {

                $missionObj = $self->currentWorld->ivShow('missionHash', $name);
                # The mission object stored in the current world might be executed simultaneously by
                #   more than one session, so we create a clone of it, and store the clone in
                #   $self->currentMission
                $cloneObj = $missionObj->clone($self);
                if (! $cloneObj) {

                    $msg = 'The mission \'' . $name . '\' couldn\'t be started',

                } else {

                    $self->set_currentMission($cloneObj);
                    $self->ivPoke('loginPromptsMode', $mode);

                    # If the world profile's ->loginSpecialList is set, make a local copy of the
                    #   list
                    $self->ivPoke('loginSpecialList', $self->currentWorld->loginSpecialList);

                    # Start the mission. The TRUE flag means to refrain from displaying confirmation
                    #   messages
                    if (! $cloneObj->startMission(TRUE)) {

                        $msg = 'Could not start the mission \'' . $name . '\'';

                    } else {

                        # Automatically send the first group of commands (as if ';mission' had been
                        #   typed by the user
                        $cloneObj->continueMission($self);
                    }
                }
            }
        }

        if ($msg) {

            return $self->writeWarning(
                'Automatic login failed (' . $msg . ')',
            );

        } else {

            # Setup succeeded
            return 1;
        }
    }

    sub doLogin {

        # Called by $self->start, ->setupLogin, ->processLineSegment, ->setLoginPatterns and
        #   GA::Cmd::Login->do
        # Marks the character as logged in to the world. Updates IVs and starts all initial tasks
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the character is already marked as 'logged in'
        #   1 on sucess

        my ($self, $check) = @_;

        # Local variables
        my (
            $permFlag, $taskCount, $scriptCount, $cmdCount, $msg,
            @reportList, @sendList,
            %cmdHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doLogin', @_);
        }

        # If the character is already marked as 'logged in', do nothing
        if ($self->loginFlag) {

            return undef;
        }

        # Otherwise, mark the character as logged in
        $self->ivPoke('loginFlag', TRUE);

        # In login mode 'mission', terminate a mission that hasn't finished yet (usually because
        #   after a sudden disconnection, some worlds require fewer keypresses to complete the login
        #   process)
        if ($self->loginPromptsMode eq 'mission' && $self->currentMission) {

            $self->reset_currentMission();
        }

        # Reset IVs used to process the login
        $self->ivPoke('loginPromptsMode', 'none');
        $self->ivEmpty('loginSuccessPatternList');
        $self->ivEmpty('loginPromptPatternList');
        $self->ivEmpty('loginConnectPatternList');
        $self->ivPoke('loginConnectFoundFlag', FALSE);
        $self->ivEmpty('loginSpecialList');
        $self->ivUndef('loginWarningTime');

        # Update the current world profile's connection variables (but not while in 'offline' mode
        #   or during an MXP crosslinking operation)
        if ($self->status ne 'offline' && $self->mxpRelocateMode eq 'none') {

            $self->currentWorld->ivPoke('lastConnectDate', $axmud::CLIENT->localDate());
            $self->currentWorld->ivPoke('lastConnectTime', $axmud::CLIENT->localClock());
            $self->currentWorld->ivIncrement('numberConnects');
            if ($self->currentChar) {

                $self->currentWorld->ivPoke('lastConnectChar', $self->currentChar->name);
            }
        }

        # If MSDP is enabled, we ask the server to REPORT any reportable variables that it supports,
        #   and to SEND any sendable variables that it supports
        if (
            $axmud::CLIENT->useMsdpFlag
            && ! $self->currentWorld->ivExists('telnetOverrideHash', 'msdp')
        ) {
            foreach my $var ($self->ivKeys('msdpGenericReportableFlagHash')) {

                if ($self->ivShow('msdpGenericReportableFlagHash', $var)) {

                    push (@reportList, $var);
                }
            }

            if (@reportList) {

                $self->optSendMsdpReport(@reportList);
            }

            foreach my $var ($self->ivKeys('msdpGenericSendableFlagHash')) {

                if ($self->ivShow('msdpGenericSendableFlagHash', $var)) {

                    push (@sendList, $var);
                }
            }

            if (@sendList) {

                $self->optSendMsdpSend(@sendList);
            }
        }

        # Send details about the preferred terminal size, if allowed
        if ($self->currentWorld->sendSizeInfoFlag && $self->status ne 'offline') {

            if ($self->currentWorld->columns) {

                $self->sendModCmd('cols', 'number', $self->currentWorld->columns);
            }

            if ($self->currentWorld->rows) {

                $self->sendModCmd('rows', 'number', $self->currentWorld->rows);
            }
        }

        # For the benefit of IRE worlds (that don't do MXP telnet option negotation), artificially
        #   enable MXP if the flags are set
        if ($self->currentWorld->ivExists('mxpOverrideHash', 'perm')) {
            $permFlag = $self->currentWorld->ivShow('mxpOverrideHash', 'perm');
        } else {
            $permFlag = $axmud::CLIENT->allowMxpPermFlag;
        }

        if ($axmud::CLIENT->useMxpFlag && $permFlag && $self->mxpMode eq 'no_invite') {

            $self->ivPoke('mxpMode', 'client_agree');
        }

        # If echo mode is still enabled ($self->echoMode is 'client_agree'), tell
        #   $self->processIncomingData to wait for the first text token before taking action to
        #   artificially unobscure the command entry box
        if ($self->echoMode eq 'client_agree') {

            $self->ivPoke('specialEchoMode', 'waiting');
        }

        # If the Automapper window is due to open automatically, open it (but not during an MXP
        #   crosslinking operation)
        if (
            $self->worldModelObj->autoOpenWinFlag
            && $self->mapObj
            && ! $self->mapWin
            && $self->mxpRelocateMode eq 'none'
        ) {
            $self->mapObj->openWin();
        }

        # Deal with initial tasks, scripts and missions (except during an MXP crosslinking
        #   operation)
        if ($self->mxpRelocateMode eq 'none') {

            # Copy tasks stored in the client's initial tasklist and each current profile's initial
            #   tasklist into the current tasklist
            $taskCount = $self->startInitTasks(TRUE);

            # Perform one spin of the task loop to allow tasks which are waiting to initialise to do
            #   so before we start sending world commands
            $self->spinSessionLoop($self->sessionLoopObj, 'task');

            # Start scripts from the client's initial scriptlist and each current profile's initial
            #   scriptlist
            $scriptCount = $self->startInitScripts(TRUE);

            # Start one (and only one) initial mission. Check current profiles in priority order,
            #   and start the first mission found
            $msg = '';
            OUTER: foreach my $category ($self->profPriorityList) {

                my $profObj;

                if ($self->ivExists('currentProfHash', $category)) {

                    $profObj = $self->ivShow('currentProfHash', $category);

                    if (
                        $profObj->initMission
                        && $self->pseudoCmd('startmission ' . $profObj->initMission)
                    ) {
                        # Only start one mission
                        $msg = ' started initial mission \'' . $profObj->initMission . '\',';
                        last OUTER;
                    }
                }
            }
        }

        # If no Raw Text and/or Raw Token tasks have started, then the contents of
        #   $self->rawTextBufferList and ->rawTokenBufferList are no longer required
        if (! $self->ivExists('currentTaskNameHash', 'raw_text_task')) {

            $self->ivEmpty('rawTextBufferList');
        }

        if (! $self->ivExists('currentTaskNameHash', 'raw_token_task')) {

            $self->ivEmpty('rawTokenBufferList');
        }

        # Execute initial commands like 'look', 'score', if specified. Client, echo and perl
        #   commands can also be executed at this point. Each profile carries its own list of
        #   commands; send current profiles in priority order, but don't send duplicate commands
        $cmdCount = 0;
        OUTER: foreach my $category ($self->profPriorityList) {

            my $profObj;

            if ($self->ivExists('currentProfHash', $category)) {

                $profObj = $self->ivShow('currentProfHash', $category);

                INNER: foreach my $cmd ($profObj->initCmdList) {

                    # Don't send duplicate commands
                    if (! exists $cmdHash{$cmd}) {

                        $self->doInstruct($cmd);
                        $cmdHash{$cmd} = undef;
                        $cmdCount++;
                    }
                }
            }
        }

        # Fire any hooks that are using the 'login' hook event (except during an MXP crosslinking
        #   operation)
        if ($self->mxpRelocateMode eq 'none') {

            $self->checkHooks('login');
        }

        # Prepare confirmation. For the convenience of visually-impaired readers, don't read
        #   out the initial tasks, scripts and commands if system messages are being converted to
        #   speech
        # The message itself is not displayed until the end of the call to ->spinIncomingLoop (this
        #   prevents the message interrupting a block of text received from the world)
        if (! $self->mxpRelocateQuietFlag) {

            if ($axmud::CLIENT->systemAllowTTSFlag && $axmud::CLIENT->ttsSystemFlag) {

                $self->ivPoke('loginConfirmText', 'Automatic login complete');

            } elsif (! $taskCount && ! $scriptCount && ! $cmdCount) {

                $self->ivPoke(
                    'loginConfirmText',
                    'Automatic login complete (no initial tasks, scripts or commands)',
                );

            } else {

                $self->ivPoke(
                    'loginConfirmText',
                    'Automatic login complete (initial tasks: ' . $taskCount . ', initial scripts: '
                    . $scriptCount . $msg . ', initial commands: ' . $cmdCount . ')',
                );
            }
        }

        # Execute the client command ';test', if the global flag is set (but not during an MXP
        #   crosslinking operation)
        if (
            $axmud::TEST_MODE_FLAG
            && $axmud::TEST_MODE_CMD_FLAG
            && $self->mxpRelocateMode eq 'none'
        ) {
            $self->pseudoCmd('test');
        }

        # Terminate an MXP crosslinking operation, if one is in progress
        if ($self->mxpRelocateMode ne 'none') {

            $self->ivPoke('mxpRelocateMode', 'none');
            $self->ivUndef('mxpRelocateHost');
            $self->ivUndef('mxpRelocatePort');
            $self->ivPoke('mxpRelocateQuietFlag', FALSE);
            $self->ivPoke('mxpRelocateQuietLineFlag', FALSE);
        }

        return 1;
    }

    sub startInitTasks {

        # Called by $self->doLogin, $self->setupProfiles and GA::Generic::Cmd->setProfile
        #
        # Starts the tasks specified in the initial tasklist of each profile in a list supplied by
        #   the calling function, in the right order
        # So, if two profiles are supplied and their initial tasklists both contain a Status task,
        #   the Status task already started is the one in the first profile's initial tasklist (we
        #   cannot have two Status tasks running at the same time because their jealousy flags are
        #   set to TRUE)
        # For two tasks of the same type, whose jealousy flag is set to FALSE, they will both be
        #   created (in the order of the supplied list)
        # (The profile list supplied should already be in priority order, with the highest-priority
        #   profile first)
        #
        # If the supplied list is empty, the initial tasklists from all current profiles are used
        #   in their usual priority order
        # The flag is set to TRUE when called by $self->doLogin, in which case this function first
        #   starts tasks in the GA::Client's initial tasklist (which takes priority over any initial
        #   tasks stored by profiles)
        #
        # Expected arguments
        #   $globalFlag - If set to TRUE, starts tasks in the GA::Client's initial tasklist first
        #
        # Optional arguments
        #   @profList   - A list of profile objects in priority order (highest priority first). The
        #                   list can contain 0 or 1 items without causing an error
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the number of initial tasks created (may be 0)

        my ($self, $globalFlag, @profList) = @_;

        # Local variables
        my (
            $count, $failCount, $msg,
            @taskList,
            %taskHash,
        );

        # Check for improper arguments
        if (! defined $globalFlag) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startInitTasks', @_);
        }

        $count = 0;
        $failCount = 0;

        # If @profList is empty, populate it with all current profiles in their usual priority order
        if (! @profList) {

            foreach my $category ($self->profPriorityList) {

                if ($self->ivExists('currentProfHash', $category)) {

                    push (@profList, $self->ivShow('currentProfHash', $category));
                }
            }
        }

        # If the flag is specified, use every task in the GA::Client's initial tasklist
        if ($globalFlag) {

            foreach my $name ($axmud::CLIENT->initTaskOrderList) {

                push (@taskList, $axmud::CLIENT->ivShow('initTaskHash', $name));
            }
        }

        # Go through all the initial tasklists, loading initial tasks into a hash in the form
        #   $taskHash{task_formal_name} = blessed_reference_to_task_object
        # If an initial task already exists in the hash, what we do depends on the task's jealousy
        #   flag.
        # If it's set to TRUE, ignore that task, preserving the task already in the hash. If
        #   it's set to FALSE, add the task to @taskList (and make sure there's an entry in the
        #   hash for that type of task)
        OUTER: foreach my $profObj (@profList) {

            # If this is a current profile, and its initial tasklist exists...
            if (
                $self->ivExists('currentProfHash', $profObj->category)
                && $self->ivShow('currentProfHash', $profObj->category) eq $profObj
                && $profObj->initTaskHash
            ) {
                # Add each task to @taskList
                foreach my $name ($profObj->initTaskOrderList) {

                    my $taskObj = $profObj->ivShow('initTaskHash', $name);

                    if (! $taskObj->jealousyFlag || ! exists $taskHash{$taskObj->name}) {

                        push (@taskList, $taskObj);
                        $taskHash{$taskObj->name} = undef;  # Replaces an existing entry

                    } else {

                        # Initial task won't be started
                        $failCount++;
                    }
                }
            }
        }

        OUTER: foreach my $taskObj (@taskList) {

            # If the task code is in a plugin, but the plugin didn't load, then the blessed task
            #   object $taskObj will exist, but the code (including all of its accessors) isn't
            #   available
            # (In other words, $taskObj->{name} works, but $taskObj->name doesn't)
            # In this situation, Axmud produces a 'Can't locate object method "jealousyFlag"' error,
            #   which is a bit confusing for the user. So we'll check the task is actually available
            #   right now
            if (! $axmud::CLIENT->ivExists('taskPackageHash', $taskObj->{name})) {

                $failCount++;
                next OUTER;
            }

            # If ->jealousyFlag is set to TRUE and there's already a task of this type in the
            #   current tasklist, don't bother trying to start it
            if ($taskObj->jealousyFlag) {

                foreach my $otherTaskObj ($self->ivValues('currentTaskHash')) {

                    if ($otherTaskObj->name eq $taskObj->name) {

                        $failCount++;
                        next OUTER;
                    }
                }
            }

            # Clone the initial task, and place the cloned copy into the current tasklist
            if (! $taskObj->clone($self, 'current', $taskObj->profName, $taskObj->profCategory)) {
                $failCount++;
            } else {
                $count++;
            }
        }

        if ($globalFlag && $failCount) {

            # Called by $self->doLogin, so display the results
            if ($failCount == 1) {
                $msg = '1 initial task';
            } else {
                $msg = $failCount . ' initial tasks';
            }

            $self->writeWarning(
                'Could not copy ' . $msg . ' into the current tasklist',
                $self->_objClass . '->startInitTasks',
            );
        }

        # Return the number of tasks created (may be 0)
        return $count;
    }

    sub startInitScripts {

        # Called by $self->doLogin, $self->setupProfiles and GA::Generic::Cmd->setProfile
        #
        # Starts the scripts specified in the initial scriptlist of each profile in a list supplied
        #   by the calling function, in the right order
        # (The profile list supplied should already be in priority order, with the highest-priority
        #   profile first)
        # Each script is only started once - duplicate scripts are ignored
        #
        # If the supplied list is empty, the initial scriptlists from all current profiles are used
        #   in their usual priority order
        # The flag is set to TRUE when called by $self->doLogin, in which case this function first
        #   starts scripts in the GA::Client's initial scriptlist
        #
        # Expected arguments
        #   $globalFlag - If set to TRUE, starts tasks in the GA::Client's initial scriptlist first
        #
        # Optional arguments
        #   @profList   - A list of profile objects in priority order (highest priority first). The
        #                   list can contain 0 or 1 items without causing an error
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the number of initial scripts created (may be 0)

        my ($self, $globalFlag, @profList) = @_;

        # Local variables
        my (
            $count, $failCount, $msg,
            @list,
        );

        # Check for improper arguments
        if (! defined $globalFlag) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startInitScripts', @_);
        }

        # If @profList is empty, populate it with all current profiles in their usual priority order
        if (! @profList) {

            foreach my $category ($self->profPriorityList) {

                if ($self->ivExists('currentProfHash', $category)) {

                    push (@profList, $self->ivShow('currentProfHash', $category));
                }
            }
        }

        # If the flag is specified, use every script in the GA::Client's initial scriptlist
        if ($globalFlag) {

            foreach my $script ($axmud::CLIENT->initScriptOrderList) {

                # @list is in the form (script_name, mode, 'undef', ...)
                push (@list, $script, $axmud::CLIENT->ivShow('initScriptHash', $script), undef);
            }
        }

        # Go through all the profiles' initial scriptlists, adding initial scripts as we find them
        OUTER: foreach my $profObj (@profList) {

            # If this is a current profile, and its initial scriptlist exists...
            if (
                $self->ivExists('currentProfHash', $profObj->category)
                && $self->ivShow('currentProfHash', $profObj->category) eq $profObj
                && $profObj->initScriptHash
            ) {
                foreach my $script ($profObj->initScriptOrderList) {

                    # @list is in the form (script_name, mode, profile_object, ...)
                    push (@list, $script, $profObj->ivShow('initScriptHash', $script), $profObj);
                }
            }
        }

        $count = 0;
        $failCount = 0;

        if (@list) {

            do {
                my ($script, $mode, $profObj, $newTask);

                $script = shift @list;
                $mode = shift @list;
                $profObj = shift @list;

                if ($mode eq 'no_task') {

                    # Mode 'no_task': Run without task
                    if (! $self->pseudoCmd('runscript ' . $script)) {
                        $failCount++;
                    } else {
                        $count++;
                    }

                } else {

                    # Mode 'run_task': Run with task
                    # Mode 'run_task_win': Run with task in 'forced window' mode

                    # Create the task object
                    if ($profObj) {

                        $newTask = Games::Axmud::Task::Script->new(
                            $self,
                            'current',
                            $profObj->name,
                            $profObj->category,
                        );

                    } else {

                        $newTask = Games::Axmud::Task::Script->new($self, 'current');
                    }

                    if (! $newTask) {

                        $failCount++;

                    } else {

                        $count++;

                        # Set the task's paramenters
                        $newTask->set_scriptName($script);
                        if ($mode eq 'run_task_win') {

                            $newTask->ivPoke('forcedWinFlag', TRUE);
                        }
                    }
                }

            } until (! @list);
        }

        if ($globalFlag && $failCount) {

            # Called by $self->doLogin, so display the results
            if ($failCount == 1) {
                $msg = '1 initial script';
            } else {
                $msg = $failCount . ' initial scripts';
            }

            $self->writeWarning(
                'Could not start ' . $msg,
                $self->_objClass . '->startInitScripts',
            );
        }

        # Return the number of scripts created (may be 0)
        return $count;
    }

    sub processCmdLoginMode {

        # Called by $self->processPrompt or ->spinMaintainLoop in login mode 'world_cmd'
        # Sends a series of login commands, substituting @account@, @name@ and @password@ in any
        #   commands that contain them
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $initChar, $initAccount, $initPass,
            @loginCmdList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processCmdLoginMode', @_);
        }

        # Import the list of commands to send
        @loginCmdList = $self->currentWorld->loginCmdList;
        # Prepare to substitute '@account@, '@name@' and '@password@' in any commands that
        #   contain them
        if ($self->currentChar) {

            $initChar = $self->currentChar->nickname;

        } elsif (defined $self->initChar) {

            $initChar = $self->initChar;

        } else {

            # We will use '@name@' literally, if character name not set
            $initChar = '';
        }

        $initAccount = $self->initAccount;
        if (! $initAccount) {

            $initAccount = '';     # Likewise for '@account@'
        }

        $initPass = $self->initPass;
        if (! $initPass) {

            $initPass = '';     # Likewise for '@password@'
        }

        foreach my $cmd (@loginCmdList) {

            $cmd =~ s/\@name\@/$initChar/;
            $cmd =~ s/\@account\@/$initAccount/;
            $cmd =~ s/\@password\@/$initPass/;
            if ($cmd) {

                $self->worldCmd($cmd);
            }
        }

        # Wait for login success patterns (if there are any), otherwise mark the character
        #   as logged in
        $self->setLoginPatterns();

        return 1;
    }

    sub setLoginPatterns {

        # Called by $self->processPrompt after sending a username and/or password to the world (in
        #   login modes 2-5)
        # If the world profile defines any login success patterns, we have to wait for them;
        #   otherwise, we can mark the character as 'logged in' right now
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setLoginPatterns', @_);
        }

        # If there are no login success patterns, the character should now be marked as
        #   logged in
        if (! $self->currentWorld->loginSuccessPatternList) {

            $self->ivPoke('loginPromptsMode', 'none');
            $self->doLogin();

        } else {

            # Wait for one of the patterns to appear
            $self->ivPoke('loginSuccessPatternList', $self->currentWorld->loginSuccessPatternList);
        }

        return 1;
    }

    # Profiles

    sub setupProfiles {

        # Called by $self->start, GA::Cmd::SetWorld->do
        # Sets up current profiles, together with associated cages and dictionaries. For profiles
        #   that already exist, read data from files. For profiles that don't yet exist, create them
        #
        # Expected arguments
        #   $mode       - Set to one of the following values:
        #                   'start' - called by $self->start
        #                   'start_temp' - called by $self->start for a temporary world profile
        #                   'set_exist' - called by ';setworld' for an existing world profile
        #                   'set_new' - called by ';setworld' for a new world profile
        #   $worldName  - The name of the world profile to be set as the current world profile. If
        #                   the profile doesn't exist, it is created
        #
        # Optional arguments
        #   $charName   - The name of the character profile to be set as the current character
        #                   profile. If 'undef', no current character profile is set. If the
        #                   profile doesn't exist, it is created.
        #   $host, $port, $loginMode
        #               - When called by $self->start, the host/port/login mode. If the $worldName
        #                   profile has to be be created, the profile has its host, port and login
        #                   mode set to these values. Otherwise, all set to 'undef'
        #
        # Return values
        #   'undef' on improper arguments, or if there any errors reading/writing the files, or if
        #       file loading/saving isn't allowed (because of global flags), or if the user chooses
        #       the 'cancel' button when asked about saving files
        #   1 otherwise

        my ($self, $mode, $worldName, $charName, $host, $port, $loginMode, $check) = @_;

        # Local variables
        my (
            $worldProfFileObj, $otherProfFileObj, $worldModelFileObj, $dictsFileObj, $result,
            $saveMode, $worldObj, $basicObj, $dictName, $dictObj, $worldModelObj, $updateCageFlag,
            $otherSession, $mapObj, $otherProfPath, $worldModelPath,
            %newHash,
        );

        # Check for improper arguments
        if (
            ! defined $mode
            || (
                $mode ne 'start' && $mode ne 'start_temp' && $mode ne 'set_exist'
                && $mode ne 'set_new'
            )
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->setupProfiles', @_);
        }

        # PART 1    - deal with existing profiles
        #
        # Mode 'set_exist'/'set_new' :
        #   There is a current world, so if any data stored in the corresponding 'worldprof' file
        #       and the related 'otherprof'/'worldmodel' files have been changed, ask the user's
        #       permission to save the files
        # Reset this session's profile and cage registries
        if ($mode eq 'set_exist' || $mode eq 'set_new') {

            # Get the relevant file objects
            $worldProfFileObj = $self->ivShow('sessionFileObjHash', $self->currentWorld->name);
            $otherProfFileObj = $self->ivShow('sessionFileObjHash', 'otherprof');
            $worldModelFileObj = $self->ivShow('sessionFileObjHash', 'worldmodel');

            # Ask permission from the user to save all the profile-related data in memory (if it has
            #   been modified)
            if (
                ! $self->currentWorld->noSaveFlag
                && (
                    $worldProfFileObj->modifyFlag
                    || $otherProfFileObj->modifyFlag
                    || $worldModelFileObj->modifyFlag
                )
            ) {
                $result = $self->mainWin->showMsgDialogue(
                    'New world profile',
                    'question',
                    'Save all data related to the current world profile? (If you select \'no\','
                    . ' the changes you have made to this data will be lost)',
                    'yes-no',
                );

                if ($result eq 'yes') {

                    # Save the current world profile's file
                    if ($worldProfFileObj->modifyFlag && ! $worldProfFileObj->saveDataFile()) {

                        return $self->writeError(
                            'Could not save the current world profile to file - new current world'
                            . ' profile not set',
                            $self->_objClass . '->setupProfiles',
                        );
                    }

                    # Save its 'otherprof' and 'worldmodel' files
                    if ($otherProfFileObj->modifyFlag && ! $otherProfFileObj->saveDataFile()) {

                        return $self->writeError(
                            'Could not save profiles to file - new current world profile not set',
                            $self->_objClass . '->setupProfiles',
                        );
                    }

                    if ($worldModelFileObj->modifyFlag && ! $worldModelFileObj->saveDataFile()) {

                        return $self->writeError(
                            'Could not save world model to file - new current world profile not'
                            . ' set',
                            $self->_objClass . '->setupProfiles',
                        );
                    }
                }
            }

            # Remove any tasks in the current tasklist which were created from initial tasks/scripts
            #   belonging to one the old current profiles
            $self->haltProfileTasks();
            # Reset (remove) all interfaces from this session
            $self->resetProfileInterfaces();

            # Reset this session's registries
            $self->ivPoke('profPriorityList', $axmud::CLIENT->constProfPriorityList);
            $self->ivEmpty('templateHash');
            $self->ivEmpty('profHash');
            $self->ivEmpty('currentProfHash');
            $self->ivUndef('currentWorld');
            $self->ivUndef('currentGuild');
            $self->ivUndef('currentRace');
            $self->ivUndef('currentChar');

            $self->ivEmpty('cageHash');
            $self->ivEmpty('currentCageHash');
            $self->ivEmpty('inferiorCageHash');

            $self->ivUndef('currentDict');

            $self->ivEmpty('sessionFileObjHash');

            $self->ivUndef('worldModelObj');

            # We need to inform tasks that the current profiles have changed
            $self->ivPoke('currentProfChangeFlag', TRUE);
        }

        # PART 2   - Create a new automapper object (all modes)
        $mapObj = Games::Axmud::Obj::Map->new($self);
        if (! $mapObj) {

            $self->writeWarning(
                'Could not create new automapper object',
                $self->_objClass . '->setupProfiles',
            );

        } else {

            # Update IVs
            $self->ivPoke('mapObj', $mapObj);
        }

        # PART 3    - Create new world profile
        #
        # Mode 'start'/'start_temp'/'set_new'
        #   If the world profile corresponding to $worldName doesn't yet exist, create one (and set
        #       up its cages, etc)
        if ($mode ne 'set_exist' && ! $axmud::CLIENT->ivExists('worldProfHash', $worldName)) {

            # Create a file object for the world profile
            $worldProfFileObj = Games::Axmud::Obj::File->new('worldprof', $worldName);
            # Create two file objects for the other two files objects associated with this
            #   profile, replacing the ones that we deleted earlier in this function
            $otherProfFileObj = Games::Axmud::Obj::File->new('otherprof', $worldName, $self);
            $worldModelFileObj = Games::Axmud::Obj::File->new('worldmodel', $worldName, $self);
            if (! $worldProfFileObj || ! $otherProfFileObj || ! $worldModelFileObj) {

                # (Improper arguments error already displayed)
                return $self->writeError(
                    'Could not set up current profiles - error creating file objects',
                    $self->_objClass . '->setupProfiles',
                );

            } else {

                # Add these file objects to the file object registries
                $self->ivAdd('sessionFileObjHash', $worldName, $worldProfFileObj);
                $self->ivAdd('sessionFileObjHash', 'otherprof', $otherProfFileObj);
                $self->ivAdd('sessionFileObjHash', 'worldmodel', $worldModelFileObj);
                # The GA::Client also stores the 'worldprof' file object
                $axmud::CLIENT->add_fileObj($worldProfFileObj);
                # Some files have to be saved shortly
                $saveMode = 'save';
            }

            # Create the new world profile
            $worldObj = Games::Axmud::Profile::World->new($self, $worldName);
            if (! $worldObj) {

                # (Error already displayed)
                return $self->writeError(
                    'Could not set up current profiles - error creating new world profile',
                    $self->_objClass . '->setupProfiles',
                );

            } else {

                # Mark it as the current profile
                $self->ivAdd('profHash', $worldObj->name, $worldObj);
                $self->ivAdd('currentProfHash', $worldObj->category, $worldObj);
                $self->ivPoke('currentWorld', $worldObj);

                # Also add the world profile to the client's list of world profiles
                $axmud::CLIENT->add_worldProf($worldObj);

                # In mode 'start_temp', it's a temporary world profile. Implement this by setting
                #   its ->noSaveFlag
                if ($mode eq 'start_temp') {

                    $worldObj->ivPoke('noSaveFlag', TRUE);
                }

                # Set the host/port/login mode, if supplied as arguments
                if (defined $host) {

                    if ($axmud::CLIENT->ipv6Check($host)) {
                        $worldObj->ivPoke('ipv6', $host);
                    } elsif ($axmud::CLIENT->ipv4Check($host)) {
                        $worldObj->ivPoke('ipv4', $host);
                    } else {
                        $worldObj->ivPoke('dns', $host);
                    }
                }

                if (defined $port) {

                    $worldObj->ivPoke('port', $port);
                }

                if (defined $loginMode) {

                    $worldObj->ivPoke('loginMode', $loginMode);
                }

                # If this world profile has been created from the Connections window, using a world
                #   from the basic mudlist, use the latter's remaining IVs
                $basicObj = $axmud::CLIENT->ivShow('constBasicWorldHash', $worldObj->name);
                if ($basicObj) {

                    $worldObj->ivPoke('longName', $basicObj->longName);
                    $worldObj->ivPoke('adultFlag', $basicObj->adultFlag);

                    # If the basic world object specifies a Russian-language world, automatically
                    #   set the world profile's charset as KOI-8 (which seems to be more common
                    #   that UTF-8)
                    if (
                        lc($basicObj->language) eq 'russian'
                        || lc($basicObj->language) eq 'russkiy'
                    ) {
                        # Use 'koi8-r', if available, otherwise use 'utf8', if available
                        if (defined $axmud::CLIENT->ivFind('charSetList', 'koi8-r')) {
                            $worldObj->ivPoke('worldCharSet', 'koi8-r');
                        } elsif (defined $axmud::CLIENT->ivFind('charSetList', 'utf8')) {
                            $worldObj->ivPoke('worldCharSet', 'utf8');
                        }

                    # Do the equivalent for Chinese-language worlds
                    } elsif (lc($basicObj->language) eq 'chinese') {

                        # Use 'euc-cn', if available, otherwise use 'MacChineseSimp', if available
                        if (defined $axmud::CLIENT->ivFind('charSetList', 'euc-cn')) {
                            $worldObj->ivPoke('worldCharSet', 'euc-cn');
                        } elsif (defined $axmud::CLIENT->ivFind('charSetList', 'MacChineseSimp')) {
                            $worldObj->ivPoke('worldCharSet', 'MacChineseSimp');
                        }
                    }
                }
            }

            # (The rest of this section is re-used in part 4; change this one, change that one)

            # Create the new world profile's associated cage
            if (! $self->createCages($worldObj, TRUE)) {

                # Some objects couldn't be created. Destroy any newly-created cages, if any
                $self->destroyCages($worldObj, TRUE);

                # Unset IVs set above
                $self->ivDelete('profHash', $worldObj->name);
                $self->ivDelete('currentProfHash', $worldObj->category);
                $self->ivUndef('currentWorld');
                $axmud::CLIENT->del_worldProf($worldObj);

                # (Improper arguments error already displayed)
                return $self->writeError(
                    'Could not set up current profiles - error creating cages',
                    $self->_objClass . '->setupProfiles',
                );
            }

            # Create a dictionary for the profile (unless a dictionary with the same name as the
            #   profile already exists). However, for temporary profiles, use (or create) a
            #   dictionary called 'temp_dict'
            if ($mode eq 'start_temp') {
                $dictName = 'temp_dict';
            } else {
                $dictName = $worldName;
            }

            if (! $axmud::CLIENT->ivExists('dictHash', $dictName)) {

                $dictObj = Games::Axmud::Obj::Dict->new($self, $dictName);
                if (! $dictObj) {

                    # (Error already displayed)
                    return $self->writeError(
                        'Could not set up current profiles - error creating dictionary',
                        $self->_objClass . '->setupProfiles',
                    );

                } else {

                    # Update IVs. Add the dictionary object to its registry
                    $axmud::CLIENT->add_dict($dictObj);
                    # Set its language, if a corresponding basic world object exists
                    if ($basicObj && $basicObj->language ne 'English') {

                        $dictObj->ivPoke('language', $basicObj->language);

                        # Upload the phrasebook to the dictionary, if a phrasebook exists for this
                        #   language
                        OUTER: foreach my $pbObj ($axmud::CLIENT->ivValues('constPhrasebookHash')) {

                            if (lc($basicObj->language) eq lc($pbObj->targetName)) {

                                $dictObj->uploadPhrasebook($pbObj);
                                last OUTER;
                            }
                        }
                    }

                    # Some files have to be saved shortly
                    $saveMode = 'save_dict';
                }

            } else {

                # The dictionary already exists
                $dictObj = $axmud::CLIENT->ivShow('dictHash', $dictName);
            }

            # Make this dictionary as the current dictionary
            $worldObj->ivPoke('dict', $dictName);
            $self->ivPoke('currentDict', $dictObj);

            # Create a world model for the profile
            $worldModelObj = Games::Axmud::Obj::WorldModel->new($self);
            if (! $worldModelObj) {

                # Display a warning, but don't give up
                $self->writeWarning(
                    'Could not create a world model',
                    $self->_objClass . '->setupProfiles',
                );

            } else {

                # Update IVs
                $self->ivPoke('worldModelObj', $worldModelObj);
            }

            # Create a directory in /logs especially for this world, first deleting the old
            #   directory if the global flag is set
            if ($axmud::CLIENT->deleteWorldLogsFlag) {

                $axmud::CLIENT->deleteWorldLogDir($worldName);
            }

            $axmud::CLIENT->createWorldLogDir($worldName);

            # Change the profile's flag to show it has been set up for the first time
            $worldObj->ivPoke('setupCompleteFlag', TRUE);

        } elsif ($mode ne 'set_new' && $axmud::CLIENT->ivExists('worldProfHash', $worldName)) {

            $worldObj = $axmud::CLIENT->ivShow('worldProfHash', $worldName);

            if (! $worldObj->setupCompleteFlag) {

                # PART 4    - Setup an existing world profile
                #
                # Mode 'start'/'start_temp'/'set_exist'
                #   If the world profile corresponding to $worldName exists but has never been a
                #       current profile (probably because it was created with the ';addworld'
                #       command, set it up

                # Create two file objects for the two files objects associated with this profile,
                #   replacing the ones that we deleted earlier in this function
                $otherProfFileObj = Games::Axmud::Obj::File->new('otherprof', $worldName, $self);
                $worldModelFileObj = Games::Axmud::Obj::File->new('worldmodel', $worldName, $self);
                if (! $otherProfFileObj || ! $worldModelFileObj) {

                    # (Improper arguments error already displayed)
                    return $self->writeError(
                        'Could not set up current profiles - error creating file objects',
                        $self->_objClass . '->setupProfiles',
                    );

                } else {

                    # Add these file objects to the file object registries
                    $self->ivAdd('sessionFileObjHash', 'otherprof', $otherProfFileObj);
                    $self->ivAdd('sessionFileObjHash', 'worldmodel', $worldModelFileObj);
                    # The world profile's file object must appear in the GA::Client's registry
                    #   and the GA::Session's registry too
                    $self->ivAdd(
                        'sessionFileObjHash',
                        $worldName,
                        $axmud::CLIENT->ivShow('fileObjHash', $worldName),
                    );

                    # Some files have to be saved shortly
                    $saveMode = 'save';
                }

                # Mark this world profile as the current one
                $self->ivAdd('profHash', $worldObj->name, $worldObj);
                $self->ivAdd('currentProfHash', $worldObj->category, $worldObj);
                $self->ivPoke('currentWorld', $worldObj);

                # (The rest of this section is the same as the corresponding section of part 4)
                # Create this world profile's associated cage
                if (! $self->createCages($worldObj, TRUE)) {

                    # (Improper arguments error already displayed)
                    return $self->writeError(
                        'Could not set up current profiles - error creating cages',
                        $self->_objClass . '->setupProfiles',
                    );
                }

                # Create a dictionary for the profile (unless a dictionary with the same name as the
                #   profile already exists)
                if (! $axmud::CLIENT->ivExists('dictHash', $worldName)) {

                    $dictObj = Games::Axmud::Obj::Dict->new($self, $worldName);
                    if (! $dictObj) {

                        # (Error already displayed)
                        return $self->writeError(
                            'Could not set up current profiles - error creating dictionary',
                            $self->_objClass . '->setupProfiles',
                        );

                    } else {

                        # Update IVs. Add the dictionary object to its registry
                        $axmud::CLIENT->add_dict($dictObj);

                        # (In this situation, don't set the language from the corresponding basic
                        #   world object, and don't upload a phrasebook)

                        # Some files have to be saved shortly
                        $saveMode = 'save_dict';
                    }

                } else {

                    # The dictionary already exists
                    $dictObj = $axmud::CLIENT->ivShow('dictHash', $worldName);
                }

                # Make this dictionary as the current dictionary
                $worldObj->ivPoke('dict', $dictObj->name);
                $self->ivPoke('currentDict', $dictObj);

                # Create a world model for the profile
                $worldModelObj = Games::Axmud::Obj::WorldModel->new($self);
                if (! $worldModelObj) {

                    # Display a warning, but don't give up
                    $self->writeWarning(
                        'Could not create a world model',
                        $self->_objClass . '->setupProfiles',
                    );

                } else {

                    # Update IVs
                    $self->ivPoke('worldModelObj', $worldModelObj);
                }

                # Create a directory in /logs especially for this world, first deleting the old
                #   directory if the global flag is set
                if ($axmud::CLIENT->deleteWorldLogsFlag) {

                    $axmud::CLIENT->deleteWorldLogDir($worldName);
                }

                $axmud::CLIENT->createWorldLogDir($worldName);

                # Change the profile's flag to show it has been set up for the first time
                $worldObj->ivPoke('setupCompleteFlag', TRUE);

            # PART 5    - Use an already-loaded world profile
            #
            # Mode 'start'/'set_exist'
            #   If the world profile corresponding to $worldName is being used by a different
            #       session, this session needs to use the same Perl objects (and therefore, we
            #       don't need to load the 'worldprof', 'otherprof' and 'worldmodel' data files)
            } elsif (($otherSession) = $axmud::CLIENT->findSessions($worldObj->name, $self)) {

                # Copy the contents of the other session's registries directly into this session

                # Copy data that has already been loaded from the 'otherprof' file
                $self->ivPoke('profPriorityList', $otherSession->profPriorityList);
                $self->ivPoke('templateHash', $otherSession->templateHash);
                $self->ivPoke('profHash', $otherSession->profHash);
                $self->ivPoke('cageHash', $otherSession->cageHash);
                # (Only one current profile so far - the world profile)
                $self->ivAdd('currentProfHash', $worldObj->category, $worldObj);
                $self->ivPoke('currentWorld', $worldObj);

                # Copy data that has already been loaded from the 'worldmodel' file
                $self->ivPoke('worldModelObj', $otherSession->worldModelObj);

                # Copy the other session's file object registry
                $self->ivPoke('sessionFileObjHash', $otherSession->sessionFileObjHash);

                # Copy the other session's current dictionary
                $self->ivPoke('currentDict', $otherSession->currentDict);

            # PART 6    - Load data for an existing world profile
            #
            # Mode 'start'/'set_exist':
            #   If the world profile corresponding to $worldName exists and has been a current
            #       profile before, it already has things like cages, a world model, and so on. Load
            #       these things into memory from the 'otherprof' and 'worldmodel' files
            } else {

                # Mark this world profile as the current one
                $self->ivAdd('profHash', $worldObj->name, $worldObj);
                $self->ivAdd('currentProfHash', $worldObj->category, $worldObj);
                $self->ivPoke('currentWorld', $worldObj);

                # Create two file objects for the two files objects associated with this profile,
                #   replacing the ones that we deleted earlier in this function
                $otherProfFileObj = Games::Axmud::Obj::File->new('otherprof', $worldName, $self);
                $worldModelFileObj = Games::Axmud::Obj::File->new('worldmodel', $worldName, $self);
                if (! $otherProfFileObj || ! $worldModelFileObj) {

                    # (Improper arguments error already displayed)
                    return $self->writeError(
                        'Could not set up current profiles - error creating file objects',
                        $self->_objClass . '->setupProfiles',
                    );

                } else {

                    # Add these file objects to the file object registries
                    $self->ivAdd('sessionFileObjHash', 'otherprof', $otherProfFileObj);
                    $self->ivAdd('sessionFileObjHash', 'worldmodel', $worldModelFileObj);
                    # The world profile's file object must appear in the GA::Client's registry and
                    #   the GA::Session's registry too
                    $self->ivAdd(
                        'sessionFileObjHash',
                        $worldName,
                        $axmud::CLIENT->ivShow('fileObjHash', $worldName),
                    );

                    # Load the 'otherprof' and 'worldmodel' data files (which sets IVs such as the
                    #   cage registries). If the files are big, show a 'loading...' popup
                    $otherProfPath = $axmud::DATA_DIR . $otherProfFileObj->standardPath;
                    if (
                        (-e $otherProfPath)
                        && (-s $otherProfPath) > $axmud::CLIENT->constLargeFileSize
                    ) {
                        $self->mainWin->showBusyWin();
                    }

                    if (! $otherProfFileObj->loadDataFile()) {

                        # Try loading the automatic backup, i.e. 'otherprof.axm.bu'
                        if (! $otherProfFileObj->loadDataFile(undef, undef, undef, TRUE)) {

                            # (Error not already displayed)
                            return $self->writeError(
                                'Could not set up current profiles - error loading the'
                                . ' \'otherprof\' file (file might be corrupt or missing)',
                                $self->_objClass . '->setupProfiles',
                            );

                        } else {

                            # The contents of the backup, now loaded into memory, must be saved at
                            #   some point
                            $otherProfFileObj->set_modifyFlag(TRUE);
                            # Don't overwrite the existing backup file with the faulty one
                            $otherProfFileObj->set_preserveBackupFlag(TRUE);
                        }
                    }

                    $worldModelPath = $axmud::DATA_DIR . $worldModelFileObj->standardPath;
                    if (
                        ! $axmud::CLIENT->busyWin
                        && (-e $worldModelPath)
                        && (-s $worldModelPath) > $axmud::CLIENT->constLargeFileSize
                    ) {
                        $self->mainWin->showBusyWin();
                    }

                    if (! $worldModelFileObj->loadDataFile()) {

                        # Try loading the automatic backup, i.e. 'worldmodel.axm.bu'
                        if (! $worldModelFileObj->loadDataFile(undef, undef, undef, TRUE)) {

                            # (Error not already displayed)
                            return $self->writeError(
                                'Could not set up current profiles - error loading the'
                                . ' \'worldmodel\' file (file might be corrupt or missing)',
                                $self->_objClass . '->setupProfiles',
                            );

                        } else {

                            # The contents of the backup, now loaded into memory, must be saved at
                            #   some point
                            $worldModelFileObj->set_modifyFlag(TRUE);
                            # Don't overwrite the existing backup file with the faulty one
                            $worldModelFileObj->set_preserveBackupFlag(TRUE);
                        }
                    }

                    if ($axmud::CLIENT->busyWin) {

                        $self->mainWin->closeDialogueWin($axmud::CLIENT->busyWin);
                    }
                }

                # If the user has coded (in Perl) any new cages, the new current world profile
                #   doesn't know about them yet. Make sure cages are up to date
                $self->updateCages(TRUE);       # TRUE - don't display create/destroy messages
                $updateCageFlag = TRUE;

                # Set the current dictionary
                if ($worldObj->dict) {

                    if ($axmud::CLIENT->ivExists('dictHash', $worldObj->dict)) {

                        $dictObj = $axmud::CLIENT->ivShow('dictHash', $worldObj->dict);

                    } else {

                        # The dictionary is missing; this can happen when setting a pre-configured
                        #   world as the current world for the first time. Silently create a new
                        #   dictionary
                        $dictObj = Games::Axmud::Obj::Dict->new($self, $worldName);
                        if ($dictObj) {

                            # Update IVs. Add the dictionary object to its registry
                            $axmud::CLIENT->add_dict($dictObj);
                            # Some files have to be saved shortly
                            $saveMode = 'save_dict';
                        }
                    }

                } else {

                    # If there is, by any chance, a dictionary with the same name as the world, use
                    #   it
                    if ($axmud::CLIENT->ivExists('dictHash', $worldName)) {

                        $dictObj = $axmud::CLIENT->ivShow('dictHash', $worldName);
                        # Tell the profile that it has a dictionary
                        $worldObj->ivPoke('dict', $dictObj->name);
                    }
                }

                if (! $dictObj) {

                    $self->writeWarning(
                        'Could not mark a dictionary as the current dictionary - try creating one'
                        . ' with the \';setdictionary\' command',
                        $self->_objClass . '->setupProfiles',
                    );

                } else {

                    $self->ivPoke('currentDict', $dictObj);
                }

                # If loading a pre-configured world, a /logs directory won't exist for it, so
                #   create one
                $axmud::CLIENT->createWorldLogDir($worldObj->name);
            }
        }

        # PART 7    - Set up new characters/passwords
        #
        # All modes:
        #   Each world profile has a ->newPasswordHash IV. It is mainly set by the Connections
        #       window, where the user can specify new characters (with or without their passwords)
        #       and modify the passwords of existing characters. Implement those changes now
        %newHash = $self->currentWorld->newPasswordHash;
        OUTER: foreach my $char (keys %newHash) {

            my ($pass, $charObj);

            $pass = $newHash{$char};     # May be 'undef'

            # If a matching character profile doesn't already exist, create it (code adapted from
            #   GA::Generic::Cmd->addProfile)
            if (! $self->ivExists('profHash', $char)) {

                $charObj
                    = Games::Axmud::Profile::Char->new($self, $char, $self->currentWorld->name);

                if (! $charObj) {

                    $self->writeWarning(
                        'Could not add character profile \'' . $char . '\'',
                        $self->_objClass . '->setupProfiles',
                    );

                    next OUTER;
                }


                # Create the profile's associated cages
                if (! $self->createCages($charObj)) {

                    $self->writeWarning(
                        'Could not add character profile \'' . $char . '\'',
                        $self->_objClass . '->setupProfiles',
                    );

                    next OUTER;
                }

                # Update IVs
                $self->add_prof($charObj);
                # Tell the current world it's acquired a new associated definiton
                $self->currentWorld->ivAdd('profHash', $char, 'char');
            }

            # In either case, update the world profile's password hashes
            $self->currentWorld->ivAdd('passwordHash', $char, $pass);
        }

        # There is also a corresponding ->newAccountHash IV. Process it in the same way
        %newHash = $self->currentWorld->newAccountHash;
        OUTER: foreach my $char (keys %newHash) {

            my ($account, $charObj);

            $account = $newHash{$char};     # May be 'undef'

            # The matching character profile should already exist, having been created just above
            #   (if necessary), but we'll check anyway
            if (! $self->ivExists('profHash', $char)) {

                $charObj
                    = Games::Axmud::Profile::Char->new($self, $char, $self->currentWorld->name);

                if (! $charObj) {

                    $self->writeWarning(
                        'Missing character profile \'' . $char . '\', spotted when checking'
                        . ' associated account names',
                        $self->_objClass . '->setupProfiles',
                    );

                    next OUTER;
                }

            } else {

                # Character profile exists, as we expected, so update the account hash
                $self->currentWorld->ivAdd('accountHash', $char, $account);
            }
        }

        # All new characters/passwords/associated accounts processed
        $self->currentWorld->ivEmpty('newPasswordHash');
        $self->currentWorld->ivEmpty('newAccountHash');

        # PART 8    - Set up initial tasks/scripts
        #
        # Mode 'set_exist'/'set_new' :
        #   If the current world profile has any initial tasks or initial scripts, clone them into
        #       the current tasklist. The FALSE argument means 'don't consult the global initial
        #       tasklist/scriptlist'. (For modes 'start'/'start_temp', $self->doLogin calls
        #       $self->startInitTasks and $self->startInitScripts)
        if ($mode eq 'set_exist' || $mode eq 'set_new') {

            $self->startInitTasks(FALSE, $self->currentWorld);
            $self->startInitScripts(FALSE, $self->currentWorld);
        }

        # PART 9    - Set the current character (if specified) (all modes)
        if ($charName) {

            $self->pseudoCmd('setchar ' . $charName, 'hide_complete');
        }

        # PART 10    - Process cages (unless it's already been done by a call to $self->updateCages)
        #   (all modes)
        if (! $updateCageFlag) {

            foreach my $obj ($self->ivValues('currentProfHash')) {

                $self->setCurrentCages($obj->name, $obj->category);
            }

            $self->setCageInferiors();

            # Set up interfaces for this profile
            $self->setProfileInterfaces($worldName);
        }

        # PART 11   - save files, if necessary (all modes)
        if ($saveMode) {

            # Import file objects
            $worldProfFileObj = $self->ivShow('sessionFileObjHash', $worldName);
            $otherProfFileObj = $self->ivShow('sessionFileObjHash', 'otherprof');
            $worldModelFileObj = $self->ivShow('sessionFileObjHash', 'worldmodel');

            # Save files
            $axmud::CLIENT->configFileObj->saveConfigFile();
            $worldProfFileObj->saveDataFile();
            $otherProfFileObj->saveDataFile();
            $worldModelFileObj->saveDataFile();

            if ($saveMode eq 'save_dict') {

                $dictsFileObj = $axmud::CLIENT->ivShow('fileObjHash', 'dicts');
                $dictsFileObj->saveDataFile();
            }
        }

        # PART 12 - Update the automapper object (all modes)
        if ($self->mapObj) {

            # The world model has been loaded, and the automapper object requires it
            $mapObj->set_worldModelObj($self->worldModelObj);

            # Appropriate the existing Automapper window (belonging to the previous automapper
            #   object), if there is one
            if ($self->mapWin) {

                $mapObj->openWin();
            }
        }

        return 1;
    }

    sub findSuperiorList {

        # Can be called by anything
        # When supplied with a profile category (e.g. 'world', 'char', 'race', 'faction' etc),
        #   returns a list of all profile categories in $self->profPriorityList that have a higher
        #   priority
        #
        # Expected arguments
        #   $category   - The profile category to check
        #
        # Return values
        #   An empty list on improper arguments, if $category has the highest priority, or if the
        #       category isn't in the priority list
        #   Otherwise returns a list of profile categories with higher priority
        #       e.g. $self->profPriorityList = ('char', 'race', 'guild', 'world')
        #       e.g. If $category is 'guild', returns the list ('char', 'race')

        my ($self, $category, $check) = @_;

        # Local variables
        my (
            $match,
            @emptyList, @priorityList, @returnArray,
        );

        # Check for improper arguments
        if (! defined $category || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findSuperiorList', @_);
            return @emptyList;
        }

        # Import IVs
        @priorityList = $self->profPriorityList;

        # Find $category's position in $self->profPriorityList
        OUTER: for (my $count = 0; $count < scalar @priorityList; $count++) {

            if ($category eq $priorityList[$count]) {

                $match = $count;
                last OUTER;
            }
        }

        if (defined $match) {

            # $category exists in $self->profPriorityList at index $match
            if ($match > 0) {

                for (my $count = 0; $count < $match; $count++) {

                    push @returnArray, $priorityList[$count];
                }
            }
        }

        # Return the list (which might be empty if $category isn't in the list, or if it is in the
        #   list and has the highest priority)
        return @returnArray;
    }

    sub findInferiorList {

        # Can be called by anything
        # When supplied with a profile category (e.g. 'world', 'char', 'race', 'faction' etc),
        #   returns a list of all profile categories in $self->profPriorityList that have a lower
        #   priority
        #
        # Expected arguments
        #   $category   - The profile category to check
        #
        # Return values
        #   An empty list on improper arguments, if $category has the lowest priority, or if the
        #       category isn't in the priority list
        #   Otherwise returns a list of profile categories with lower priority
        #       e.g. $self->profPriorityList = ('char', 'race', 'guild', 'world')
        #       e.g. If $category is 'race', returns the list ('guild', 'world')

        my ($self, $category, $check) = @_;

        # Local variables
        my (
            $match,
            @emptyList, @priorityList, @returnArray,
        );

        # Check for improper arguments
        if (! defined $category || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findInferiorList', @_);
            return @emptyList;
        }

        # Import IVs
        @priorityList = $self->profPriorityList;

        # Find $category's position in $self->profPriorityList
        OUTER: for (my $count = 0; $count < scalar @priorityList; $count++) {

            if ($category eq $priorityList[$count]) {

                $match = $count;
                last OUTER;
            }
        }

        if (defined $match) {

            # $category exists in $self->profPriorityList at index $match
            if ($match < (scalar @priorityList - 1)) {

                for (my $count = ($match + 1); $count < scalar @priorityList; $count++) {

                    push @returnArray, $priorityList[$count];
                }
            }
        }

        # Return the list (which might be empty if $category isn't in the list, or if it is in the
        #   list and has the lowest priority)
        return @returnArray;
    }

    # Active interfaces

    sub setProfileInterfaces {

        # Called by $self->setupProfiles, ->createCages, ->updateCages and
        #   GA::Generic::Cmd->setProfile, GA::Cmd::SetCustomProfile->do
        # When a profile is set as the current profile, all of the (inactive) interfaces stored in
        #   the profile's cages must have active interfaces (stored in $self->interfaceHash) created
        #   for them
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $name     - The name of the current profile whose inactive interfaces must be made
        #                   active. If 'undef', all current profiles are processed
        #
        # Return values
        #   'undef' on improper arguments, if a specified profile doesn't exist or if the specified
        #       profile's category isn't in the profile priority list ($self->profPriorityList)
        #   Otherwise returns the number of active interfaces created (may be 0)

        my ($self, $name, $check) = @_;

        # Local variables
        my (
            $count,
            @interfaceList, @profList, @modList, @sortedList,
            %priorityHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setProfileInterfaces', @_);
        }

        @interfaceList = ('trigger', 'alias', 'macro', 'timer', 'hook');

        # Compile a list of current profiles to process
        if (defined $name) {

            # Check the specified profile exists
            if (! $self->ivExists('profHash', $name)) {

                return $self->writeError(
                    'Unrecognised profile \'' . $name . '\'',
                    $self->_objClass . '->setProfileInterfaces',
                );

            } else {

                push (@profList, $self->ivShow('profHash', $name));
            }

        } else {

            # Use all current profiles
            @profList = $self->ivValues('currentProfHash');
        }

        # Convert the profile priority list into a hash for quick lookup
        $count = 0;
        foreach my $category ($self->profPriorityList) {

            $count++;
            $priorityHash{$category} = $count;
        }

        # Remove any profiles of categories that don't appear on the priority list
        foreach my $obj (@profList) {

            if (exists $priorityHash{$obj->category}) {

                push (@modList, $obj);
            }
        }

        if (! @modList) {

            # The specified profile, $name, didn't survive the cut. (The priority list always
            #   includes 'world', and there is always a current world, so it can't be the case that
            #   $name wasn't specified by the calling function)
            return undef;
        }

        # Sort the list of profiles in priority order (highest priority first)
        @sortedList
            = sort {lc($priorityHash{$a->category}) cmp lc($priorityHash{$b->category})} (@modList);

        # Process each profile in turn
        $count = 0;
        OUTER: foreach my $profObj (@sortedList) {

            my (@superiorList, @inferiorList);

            # Get a list of profiles with higher priority than this one
            @superiorList = $self->findSuperiorList($profObj->category);
            # Get a list of profiles with lower priority than this one
            @inferiorList = $self->findInferiorList($profObj->category);

            MIDDLE: foreach my $type (@interfaceList) {  # 'trigger', 'alias', etc

                my (
                    $cage,
                    %intHash,
                );

                # Find the cage for this profile and interface type
                $cage = $self->findCage($type, $profObj->name);
                if ($cage) {

                    # Import the cage's list of inactive interfaces
                    %intHash = $cage->interfaceHash;

                    # For each inactive interface, if there are no active interfaces with the same
                    #   name belonging to cages associated with superior profiles, create an active
                    #   interface
                    # If there are active interfaces with the same name belonging to cages
                    #   associated with inferior profiles, destory those active interfaces
                    INNER: foreach my $interfaceName (keys %intHash) {

                        my $interfaceObj = $intHash{$interfaceName};
                        if (
                            $self->injectInterface(
                                $interfaceObj,
                                $interfaceName,
                                $profObj->name,         # Profile name
                                \@superiorList,
                                \@inferiorList,
                            )
                        ) {
                            $count++;
                        }
                    }
                }
            }
        }

        # Return the number of active interfaces created (may be 0)
        return $count;
    }

    sub resetProfileInterfaces {

        # Called by $self->setupProfiles, GA::Generic::Cmd->setProfile, ->unsetProfile,
        #   ->deleteProfile, GA::Cmd::SetCustomProfile->do, UnsetCustomProfile->do
        #
        # When a current profile is unset as a current profile, all of the active interfaces created
        #   for it must be destroyed
        # For each active interface that's destroyed, if there's an inactive interface with the same
        #   name, belonging to a cage associated with an inferior profile, an active interface must
        #   be created for it
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $name     - The name of the current profile whose active interfaces must be destroyed.
        #                   If 'undef', the active interfaces for all current profiles are destroyed
        #
        # Return values
        #   'undef' on improper arguments, if a specified profile doesn't exist or if the specified
        #       profile's category isn't in the profile priority list ($self->profPriorityList)
        #   Otherwise returns the number of active interfaces destroyed (may be 0)

        my ($self, $name, $check) = @_;

        # Local variables
        my (
            $count,
            @interfaceList, @profList, @modList, @sortedList,
            %priorityHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetProfileInterfaces', @_);
        }

        @interfaceList = ('trigger', 'alias', 'macro', 'timer', 'hook');

        # Compile a list of current profiles to process
        if (defined $name) {

            # Check the specified profile exists
            if (! $self->ivExists('profHash', $name)) {

                return $self->writeError(
                    'Unrecognised profile \'' . $name . '\'',
                    $self->_objClass . '->resetProfileInterfaces',
                );

            } else {

                push (@profList, $self->ivShow('profHash', $name));
            }

        } else {

            # Use all current profiles
            @profList = $self->ivValues('currentProfHash');
        }

        # Convert the profile priority list into a hash for quick lookup
        $count = 0;
        foreach my $category ($self->profPriorityList) {

            $count++;
            $priorityHash{$category} = $count;
        }

        # Remove any profiles of categories that don't appear on the priority list
        foreach my $obj (@profList) {

            if (exists $priorityHash{$obj->category}) {

                push (@modList, $obj);
            }
        }

        if (! @modList) {

            # The specified profile, $name, didn't survive the cut. (The priority list always
            #   includes 'world', and there is always a current world, so it can't be the case that
            #   $name wasn't specified by the calling function)
            return undef;
        }

        # Sort the list of profiles in priority order (highest priority first)
        @sortedList
            = sort {lc($priorityHash{$a->category}) cmp lc($priorityHash{$b->category})} (@modList);

        # Process each profile in turn
        $count = 0;
        OUTER: foreach my $profObj (@sortedList) {

            # Get a list of profiles with lower priority than this one
            my @inferiorList = $self->findInferiorList($profObj->category);

            MIDDLE: foreach my $type (@interfaceList) {  # 'trigger', 'alias', etc

                my (
                    $cage,
                    %intHash,
                );

                # Find the cage for this profile and interface type
                $cage = $self->findCage($type, $profObj->name);
                if ($cage) {

                    # Import the cage's list of inactive interfaces
                    %intHash = $cage->interfaceHash;

                    # For each inactive interface, if there's an active interface based on it,
                    #   destroy the active interface
                    # At the same time, if there's an inactive interface with the same name
                    #   belonging to a cage associated with an inferior profile, create an active
                    #   interface for it
                    INNER: foreach my $interfaceName (keys %intHash) {

                        my ($interfaceObj, $result);

                        $interfaceObj = $intHash{$interfaceName};
                        $result = $self->recallInterface(
                            $interfaceObj,
                            $interfaceName,
                            \@inferiorList,
                        );

                        if ($result) {

                            $count++;
                        }
                    }
                }
            }
        }

        # Return the number of active interfaces destroyed (may be 0)
        return $count;
    }

    sub injectInterface {

        # Called by $self->setProfileInterfaces or GA::Generic::Cmd->addInterface
        # Given a specified inactive interface, this function checks whether there are any
        #   inactive interfaces with the same name whose cages are associated with superior or
        #   inferior profiles
        # If there are no interfaces attached to superior cages, an active interface is created
        #   using the attributes of the specfied (inactive) interface
        # If there is an interface attached to an inferior cage, the corresponding active interface
        #   is destroyed
        # As a result of these operations, there will never be any more than one active interface
        #   with a certain name, no matter how many inactive interfaces share that name
        # NB If two or more sessions are using the current world, active interfaces are updated for
        #   all of them
        #
        # Expected arguments
        #   $interfaceObj       - Blessed reference of the inactive interface object
        #   $interfaceName      - The inactive interface's name
        #   $assocProf          - The associated profile of the cage which stores the inactive
        #                           interface
        #   $superiorListRef    - Reference to a list of profile categories superior to $assocProf's
        #                           category (may be an empty list)
        #   $inferiorListRef    - Reference to a list of profile categories inferior to $assocProf
        #                           (may be an empty list)
        #
        # Return values
        #   'undef' on improper arguments, or for any errors
        #   1 if an 'active' interface is created for the specified interface
        #   2 is the specified interface remains 'inactive'
        #   3 if an active interface corresponding to the specified interface already exists (and
        #       was not replaced)

        my (
            $self, $interfaceObj, $interfaceName, $assocProf, $superiorListRef, $inferiorListRef,
            $replaceFlag, $check,
        ) = @_;

        # Local variables
        my ($createActiveFlag, $activeObj, $match, $assocProfObj);

        # Check for improper arguments
        if (
            ! defined $interfaceObj || ! defined $interfaceName || ! defined $assocProf
            || ! defined $superiorListRef || ! defined $inferiorListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->injectInterface', @_);
        }

        # If an active interface has already been created for this inactive interface, we don't
        #   need to create another
        if ($self->ivExists('interfaceHash', $interfaceName)) {

            $activeObj = $self->ivShow('interfaceHash', $interfaceName);
            if ($activeObj->parent eq $interfaceObj) {

                return 3;
            }
        }

        # Other cages with a higher priority might already have an inactive interface with the same
        #   name. If so, the existing inactive interface takes priority over $interfaceObj, and we
        #   don't create a new active interface
        OUTER: foreach my $profCategory (@$superiorListRef) {

            my $cage = $self->findCurrentCage($interfaceObj->category, $profCategory);
            if ($cage && $cage->ivExists('interfaceHash', $interfaceName)) {

                $match = $profCategory;
                last OUTER;
            }
        }

        if (! $match) {

            # There is no superior trigger, so we need to create an active interface
            $createActiveFlag = TRUE;

            # Other cages with a lower priority might already have an inactive interface with the
            #   same name. If so, $interfaceObj takes priority over it. We need to destroy the
            #   existing active interface, and create a new one modelled on $interfaceObj
            # Look for a lower-priority trigger with the same name
            OUTER: foreach my $profCategory (@$inferiorListRef) {

                my $cage = $self->findCurrentCage($interfaceObj->category, $profCategory);
                if ($cage && $cage->ivExists('interfaceHash', $interfaceName)) {

                    $match = $profCategory;
                    last OUTER;
                }

            }

            if (defined $match) {

                # An inferior cage has an inactive interface with the same name. We must destroy the
                #   corresponding active interface
                if (! $self->removeInterface($self->ivShow('interfaceHash', $interfaceName))) {

                    # Couldn't destroy existing active interface (error message already displayed)
                    return undef;
                }
            }
        }

        # Create a new active interface, if required
        if ($createActiveFlag) {

            $assocProfObj = $self->ivShow('profHash', $assocProf);

            $activeObj = Games::Axmud::Interface::Active->new(
                $self,
                $interfaceObj->category,
                TRUE,               # An independent active interface
                $interfaceObj,      # The inactive interface, upon which the active one is based
                $assocProf,         # The inactive interface's associated profile
                $assocProfObj->category,
                                    # The associated profile's category
            );

            if (! $activeObj) {

                # Couldn't create the active interface (error message already displayed)
                return undef;

            } else {

                # Add the new active interface to every session using the same current world as this
                #   one (this session is affected too, obviously)
                foreach my $otherSession ($axmud::CLIENT->listSessions()) {

                    if ($otherSession->currentWorld eq $self->currentWorld) {

                        $otherSession->addInterface($activeObj);
                    }
                }

                # Return 1 to show creation of an active interface
                return 1;
            }

        } else {

            # Return 2 to show $interfaceObj didn't have an active interface created for it
            return 2;
        }
    }

    sub recallInterface {

        # Called by $self->resetProfileInterfaces and GA::Generic::Cmd->deleteInterface
        # Given a specified inactive interface, this function checks whether there is a
        #   corresponding active interface. If so, the active interface is deleted
        # If the active interface is deleted, checks whether there's an inactive interface with the
        #   same name, associated with an inferior profile. If so, an active interface is creatd for
        #   it
        # As a result of these operations, there will never be any more than one active interface
        #   with a certain name, no matter how many inactive interfaces share that name
        # NB Active interfaces which aren't based on an inactive interface can be deleted with a
        #   call to ->deleteInterface
        # NB If two or more sessions are using the current world, active interfaces are updated for
        #   all of them
        #
        # Expected arguments
        #   $interfaceObj       - Blessed reference of the inactive interface object
        #   $interfaceName      - The inactive interface's name
        #   $inferiorListRef    - Reference to a list of profile categories inferior to $assocProf
        #                           (may be an empty list)
        #
        # Optional arguments
        #   $noRecurseFlag      - Set to TRUE when this function is called by the ->recallInterface
        #                           method of other sessions sharing the same current world; this
        #                           function does not call any other sessions. If set to FALSE (or
        #                           'undef'), other sessions are updated
        #
        # Return values
        #   'undef' on improper arguments, if the specified inactive interface has no corresponding
        #       active interface, or for any errors
        #   1 if an active interface exists, but there was an error deleting it
        #   2 if the active interface is deleted, and there's an inactive interface with the same
        #       name, associated with an inferior profile, but there was an error creating an active
        #       interface for it
        #   3 if the active interface is deleted, and there's an inactive interface with the same
        #       name, associated with an inferior profile, for which an active interface was created
        #       created
        #   4 if the active interface is deleted, and there's no inactive interface with the same
        #       name, associated with an inferior profile

        my ($self, $interfaceObj, $interfaceName, $inferiorListRef, $noRecurseFlag, $check) = @_;

        # Local variables
        my (
            $match, $inactiveObj, $newActiveObj,
            @sessionList,
        );

        # Check for improper arguments
        if (! defined $interfaceObj || ! defined $interfaceName || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->recallInterface', @_);
        }

        if (! $noRecurseFlag) {

            # Every session using the same current world as this one must have its active interfaces
            #   updated. Call those sessions first, before updating interfaces in this session
            OUTER: foreach my $otherSession ($axmud::CLIENT->listSessions()) {

                if (
                    $otherSession->currentWorld eq $self->currentWorld
                    && $otherSession ne $self
                ) {
                    $otherSession->recallInterface(
                        $interfaceObj,
                        $interfaceName,
                        $inferiorListRef,
                        TRUE,                   # Only need one call to each ->recallInterface
                    );
                }
            }
        }

        # Now update this session's active interfaces

        # Look for an active interface corresponding to the inactive interface $interfaceObj
        OUTER: foreach my $activeObj ($self->ivValues('interfaceHash')) {

            if (defined $activeObj->parent && $activeObj->parent eq $interfaceObj) {

                $match = $activeObj;
                last OUTER;
            }
        }

        if (! $match) {

            # No corresponding active interface to delete
            return undef;

        # Delete the active interface
        } elsif (! $self->removeInterface($match)) {

            # Return 1 to show a general error deleting the active interface
            return 1;
        }

        # Other cages with a lower priority might already have an inactive interface with the same
        #   same name - if so, we must create a corresponding active interface
        OUTER: foreach my $profCategory (@$inferiorListRef) {

            my $cage = $self->findCurrentCage($interfaceObj->category, $profCategory);
            if ($cage && $cage->ivExists('interfaceHash', $interfaceName)) {

                # The TRUE flag means 'don't consult other cages'
                $inactiveObj = $cage->ivShow('interfaceHash', $interfaceName, TRUE);
                last OUTER;
            }
        }

        if ($inactiveObj) {

            # Create an active interface corresponding to the inferior cage's inactive interface
            $newActiveObj = $self->createActiveInterface(
                TRUE,               # An independent active interface, not a dependent one
                $inactiveObj,       # Active interface based on the inactive interface $interfaceObj
            );

            if (! $newActiveObj) {

                # Return 2 to show a general error creating a new active interface
                return 2;

            } else {

                # Return 3 to show the deleted active interface has been replaced by a new one
                #   corresponding to an inactive interface from an inferior cage
                return 3;
            }

        } else {

            # Return 4 to show the active interface was deleted, and no other active interface
            #   was created
            return 4;
        }
    }

    sub updateInterfaces {

        # Called by GA::Generic::Cmd->modifyInterface in response to ;modifytrigger, and so on
        # When an inactive interface is modified, active interfaces in every session sharing the
        #   same current world might need to be updated. ->modifyInterface calls this function in
        #   every affected session
        #
        # Expected arguments
        #   $inactiveObj    - The inactive interfaces which has been modified
        #   %attribHash     - The hash of modified interface attributes created by the calling
        #                       function (should not be empty)
        #
        # Return values
        #   'undef' on improper arguments or if there's an error modifying an active interface
        #   1 otherwise

        my ($self, $inactiveObj, %attribHash) = @_;

        # Check for improper arguments
        if (! defined $inactiveObj || ! %attribHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateInterfaces', @_);
        }

        foreach my $activeObj ($self->ivValues('interfaceHash')) {

            my $timerEnableFlag;

            if (defined $activeObj->parent && $activeObj->parent eq $inactiveObj) {

                # For active timer interfaces that are disabled, but are about to become
                #   enabled, some additional IVs need to be set
                if (
                    $activeObj->category eq 'timer'     # It's a timer
                    && ! $activeObj->enabledFlag        # Currently disabled
                    && exists ($attribHash{'enabled'})  # The 'enabled' attribute will be set
                ) {
                    $timerEnableFlag = TRUE;
                }

                # Modify the interface
                if (! $activeObj->modifyAttribs($self, %attribHash)) {

                    return undef;

                } else {

                    if ($timerEnableFlag && $activeObj->enabledFlag) {

                        # A disabled timer has become enabled. Set a few IVs
                        $activeObj->becomeEnabled();
                    }

                    return 1;
                }
            }
        }
    }

    sub deleteInterface {

        # Can be called by anything
        # This function deletes an active interface that's not based on an inactive interface
        #   (created by a call to $self->createIndependentInterface or
        #   $self->createDependentInterface)
        # (There is no Axmud command to delete active interfaces - but since the code can create
        #   them, we also need a way for the code to delete them)
        #
        # Actually, the active interface is not deleted immediately - if $self->checkTriggers (etc)
        #   is halfway through checking triggers, it wouldn't be a good idea to tamper with the
        #   interface registries until it's finished. Instead, we add the interface to a list of
        #   doomed interfaces, and let the task loop delete them.
        #
        # Expected arguments
        #   $interface      - Name of the active interface to delete
        #
        # Optional arguments
        #   $noMsgFlag      - Should be set to TRUE when called by tasks in their shutdown code (or
        #                       anything similar); if the interface doesn't exist (presumably
        #                       because it has already been deleted during a disconnection), don't
        #                       show an error message.
        #                   - If set to FALSE (or 'undef'), trying to delete an interface that
        #                       doesn't now exist generates an error message
        #
        # Return values
        #   'undef' on improper arguments, if the interface doesn't exist or if it is based on an
        #       inactive interface
        #   1 otherwise

        my ($self, $interface, $noMsgFlag, $check) = @_;

        # Local variables
        my ($obj);

        # Check for improper arguments
        if (! defined $interface || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->deleteInterface', @_);
        }

        # Check the interface exists
        if (! $self->ivExists('interfaceHash', $interface)) {

            if ($noMsgFlag) {

                return undef;

            } else {

                return $self->writeError(
                    'Active interface \'' . $interface . '\' doesn\'t exist',
                    $self->_objClass . '->deleteInterface',
                );
            }

        } else {

            $obj = $self->ivShow('interfaceHash', $interface);
        }

        # Check the active interface isn't based on an inactive interface
        if ($obj->assocProf) {

            return $self->writeError(
                'Active interface \'' . $interface . '\' can\'t be deleted directly because it is'
                . ' based on an inactive interface (try \';delete' . $obj->category . '\' instead)',
                $self->_objClass . '->deleteInterface',
            );
        }

        # Disable the interface (so it doesn't fire unexpectedly)
        $obj->set_enabledFlag(FALSE);
        # Mark the interface for deletion
        $self->ivPush('deleteInterfaceList', $obj);

        return 1;
    }

    sub tidyInterfaces {

        # Called by $self->spinTaskLoop every time a task is marked as 'finished', in order to
        #   destroy all the active interfaces associated with the task
        # Can be called by tasks when they want to reset themselves. Any active interfaces they
        #   created are destroyed, so the tasks can create new ones
        #
        # Expected arguments
        #   $taskObj    - Blessed reference of the task
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the number of interfaces deleted (may be 0)

        my ($self, $taskObj, $check) = @_;

        # Local variables
        my (
            $count,
            %hash,
        );

        # Check for improper arguments
        if (! defined $taskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->tidyInterfaces', @_);
        }

        # Import the interface registry
        %hash = $self->interfaceHash;

        # Go through every entry in the interface registry, deleting any active interfaces
        #   associated with $taskObj
        $count = 0;
        foreach my $interfaceObj (values %hash) {

            # If the active interface is associated with a task, the ->callClass IV will be set to
            #   the blessed reference of that task
            if ($interfaceObj->callClass && $interfaceObj->callClass eq $taskObj) {

                $count++;

                # Delete the corresponding entries in Axmud's interface registries
                $self->removeInterface($interfaceObj);
            }
        }

        # Return the number of active interfaces deleted (may be 0)
        return $count;
    }

    sub addInterface {

        # Called by $self->injectInterface or ->setupInterface
        # Adds an active interface from this GA::Session's registries
        # Should not be called by anything else (the correct way to add an active interface is
        #   to call $self->injectInterface, ->createIndependentInterface or
        #   ->createDependentInterface)
        #
        # Expected arguments
        #   $interfaceObj    - The active interface object to add
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $interfaceObj, $check) = @_;

        # Local variables
        my ($initialDelay, $randDelay, $minDelay, $delay);

        # Check for improper arguments
        if (! defined $interfaceObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->addInterface', @_);
        }

        # Give the interface a number unique for this session
        $interfaceObj->set_number($self->interfaceCount);
        $self->ivIncrement('interfaceCount');

        # Add interface to registries
        $self->ivAdd('interfaceHash', $interfaceObj->name, $interfaceObj);
        $self->ivAdd('interfaceNumHash', $interfaceObj->number, $interfaceObj);

        if ($interfaceObj->category eq 'trigger') {

            # Update the trigger registries
            $self->ivAdd('triggerHash', $interfaceObj->number, $interfaceObj->stimulus);
            $self->posnInterface('triggerOrderList', $interfaceObj);

        } elsif ($interfaceObj->category eq 'alias') {

            # Update the alias registries
            $self->ivAdd('aliasHash', $interfaceObj->number, $interfaceObj->stimulus);
            $self->posnInterface('aliasOrderList', $interfaceObj);

        } elsif ($interfaceObj->category eq 'macro') {

            # Update the macro registries
            $self->ivAdd('macroHash', $interfaceObj->number, $interfaceObj->stimulus);
            $self->posnInterface('macroOrderList', $interfaceObj);

            # The GA::Client also has a registry of keycodes that are used by active macro
            #   interfaces. Make sure it contains an entry for this macro
            $axmud::CLIENT->add_activeKeycode($interfaceObj->stimulus);

        } elsif ($interfaceObj->category eq 'timer') {

            if (index($interfaceObj->stimulus, ':') == -1) {

                # Timer whose stimulus is numeric

                # Get the timer's initial delay, in seconds (might be 0)
                $initialDelay = $interfaceObj->ivShow('attribHash', 'initial_delay');

                # $interfaceObj->stimulus is a time interval, in seconds. The timer loop only fires
                #   once every tenth of a second, so if the interval isn't a multiple of a tenth of
                #   a second ('1' or '5' or '0.2'), it is automatically rounded
                if (! defined $self->sessionTime) {

                    # The timer loop hasn't spun yet. The timer will start counting after the timer
                    #   loop starts, at which time $self->sessionTime will be set to 0
                    $delay = $initialDelay;

                } elsif ($initialDelay) {

                    # The timer fires for the first time after its initial delay, and thereafter
                    #   after the delay stored in the 'stimulus' attribute
                    $delay = $initialDelay + $self->sessionTime;

                } elsif ($interfaceObj->ivShow('attribHash', 'random_delay')) {

                    # The timer fires for the first time after a random delay between 0 (or the
                    #   value set by the 'random_min' attribute), and $interfaceObj->stimulus
                    $minDelay = $interfaceObj->ivShow('attribHash', 'random_min');
                    # Check: if the minimum random delay is greater than $interfaceObj->stimulus,
                    #   ignore it
                    if ($minDelay >= $interfaceObj->stimulus) {

                        $minDelay = 0;
                    }

                    $randDelay = rand($interfaceObj->stimulus - $minDelay) + $minDelay;
                    $delay = $self->sessionTime + $randDelay;

                } else {

                    # The timer fires for the first time after the delay stored in the 'stimulus'
                    #   attribute ((even if the 'random_delay' attribute is set)
                    $delay = $self->sessionTime + $interfaceObj->stimulus;
                }

                # Set the time at which the timer will fire for the first time by adding an entry to
                #   the active timer registry
                $self->ivAdd('timerNumHash', $interfaceObj->number, $delay);

            } else {

                # Timer whose stimulus is a clock time in the form HH::MM or 99::MM

                # Any of the timer's delay attributes, if specified, are ignored; so we can just
                #   update IVs
                $self->ivAdd('timerClockHash', $interfaceObj->number, $interfaceObj->stimulus);
            }

            # The parallel registry, $self->timerOrderList, contains all the keys of
            #   $self->timerHash but in ascending order (so that timers always fire in a predictable
            #   order)
            $self->posnInterface('timerOrderList', $interfaceObj);

        } elsif ($interfaceObj->category eq 'hook') {

            # Update the hook registries
            $self->ivAdd('hookHash', $interfaceObj->number, $interfaceObj->stimulus);
            $self->posnInterface('hookOrderList', $interfaceObj);
        }

        return 1;
    }

    sub removeInterface {

        # Called by $self->injectInterface, ->recallInterface and ->tidyInterfaces
        # Called by $self->checkTimers (when an active timer interface expires)
        # Called by $self->deleteInterface (to delete an active interface that's not based on an
        #   inactive interface)
        #
        # Deletes an active interface from this GA::Session's registries
        # Should not be called by anything else (the correct way to remove an active interface is
        #   to call $self->recallInterface or ->deleteInterface)
        #
        # Expected arguments
        #   $interfaceObj    - The active interface object to remove
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $interfaceObj, $check) = @_;

        # Local variables
        my $index;

        # Check for improper arguments
        if (! defined $interfaceObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->removeInterface', @_);
        }

        # Check that the interface hasn't alredy been removed (possible, if the interface has
        #   been added to $self->deleteInterfaceList, marked to be deleted on the next spin of the
        #   task loop)
        if (! $self->ivExists('interfaceHash', $interfaceObj->name)) {

            # Interface already removed
            return 1;
        }

        $self->ivDelete('interfaceHash', $interfaceObj->name);
        $self->ivDelete('interfaceNumHash', $interfaceObj->number);

        if ($interfaceObj->category eq 'trigger') {

            # Remove the entry in the main active trigger registry
            $self->ivDelete('triggerHash', $interfaceObj->number);
            # Remove the corresponding entry in the cooldown expire registry (if any)
            $self->ivDelete('triggerCooldownHash', $interfaceObj->number);
            # Remove the corresponding entry in the ordered registry
            $index = $self->ivFind('triggerOrderList', $interfaceObj->number);
            $self->ivSplice('triggerOrderList', $index, 1);

        } elsif ($interfaceObj->category eq 'alias') {

            # Remove the entry in the main active alias registry
            $self->ivDelete('aliasHash', $interfaceObj->number);
            # Remove the corresponding entry in the cooldown expire registry (if any)
            $self->ivDelete('aliasCooldownHash', $interfaceObj->number);
            # Remove the corresponding entry in the ordered registry
            $index = $self->ivFind('aliasOrderList', $interfaceObj->number);
            $self->ivSplice('aliasOrderList', $index, 1);

        } elsif ($interfaceObj->category eq 'macro') {

            # Remove the entry in the main active macro registry
            $self->ivDelete('macroHash', $interfaceObj->number);
            # Remove the corresponding entry in the cooldown expire registry (if any)
            $self->ivDelete('macroCooldownHash', $interfaceObj->number);
            # Remove the corresponding entry in the ordered registry
            $index = $self->ivFind('macroOrderList', $interfaceObj->number);
            $self->ivSplice('macroOrderList', $index, 1);

            # The GA::Client also has a registry of keycodes that are used by active macro
            #   interfaces. It would be a bad idea to modify this registry by checking every active
            #   macro in every session - especially if we're deleting several hundred macros in
            #   one go
            # Instead, set a flag for the timer loop. When the loop spins (once every tenth of a
            #   second), if this flag is TRUE, the whole client registry is updated
            $axmud::CLIENT->set_resetKeycodesFlag(TRUE);

        } elsif ($interfaceObj->category eq 'timer') {

            # Remove the entry in the main active timer registry (the timer will exist in one or the
            #   other)
            $self->ivDelete('timerNumHash', $interfaceObj->number);
            $self->ivDelete('timerClockHash', $interfaceObj->number);
            # Remove the corresponding entry in the ordered registry
            $index = $self->ivFind('timerOrderList', $interfaceObj->number);
            $self->ivSplice('timerOrderList', $index, 1);

        } elsif ($interfaceObj->category eq 'hook') {

            # Remove the entry in the main active hook registry
            $self->ivDelete('hookHash', $interfaceObj->number);
            # Remove the corresponding entry in the cooldown expire registry (if any)
            $self->ivDelete('hookCooldownHash', $interfaceObj->number);
            # Remove the corresponding entry in the ordered registry
            $index = $self->ivFind('hookOrderList', $interfaceObj->number);
            $self->ivSplice('hookOrderList', $index, 1);
        }

        return 1;
    }

    sub posnInterface {

        # Called by $self->addInterface, when a new active interface is added to one of the
        #   registries $self->triggerOrderList, ->aliasOrderList, ->macroOrderList, ->timerOrderList
        #   or ->hookOrderList
        # The specified active interface, $interfaceObj, has a corresponding inactive interface
        #   which can specify that the active interface should appear before (or after) other
        #   named interfaces
        # Check the registries. If necessary, position the specified active interface at some
        #   point in the registry before (or after) other named interfaces; otherwise, position it
        #   at the end of the registry
        #
        # Expected arguments
        #   $iv             - The registry to which $interfaceObj must be added - 'triggerOrderList'
        #                       etc
        #   $interfaceObj   - The specified active interface
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $iv, $interfaceObj, $check) = @_;

        # Local variables
        my ($inactiveObj, $first, $last);

        # Check for improper arguments
        if (! defined $iv || ! defined $interfaceObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->posnInterface', @_);
        }

        # Get the corresponding inactive interface
        $inactiveObj = $interfaceObj->parent;
        if (! $inactiveObj) {

            # The active interface, $interfaceObj, doesn't have an inactive parent (because it was
            #   created by a call to $self->createIndependentInterface or
            #   $self->createDependentInterface), so it can be positioned at the end of the registry
            $self->ivPush($iv, $interfaceObj->number);

        } elsif (
            (! $inactiveObj->beforeHash && ! $inactiveObj->afterHash)
            || ! $self->$iv
        ) {
            # The inactive interface's before/after hashes are empty; or this is the first active
            #   interface to be added to the registry - so simply add the active interface to the
            #   end of the registry
            $self->ivPush($iv, $interfaceObj->number);

        } else {

            # Find the latest index at which the interface could be inserted, in order to be before
            #   other named interfaces
            for (my $index = 0; $index < $self->ivNumber($iv); $index++) {

                my ($thisObj, $name);

                # Get the active interface at position $index
                $thisObj = $self->ivIndex($index);
                # Get the name of the corresponding inactive interface
                $name = $thisObj->parent->name;

                if ($inactiveObj->ivExists('beforeHash', $name)) {

                    # $interfaceObj must appear at this index (or earlier)
                    if (! defined $first || $first > $index) {

                        $first = $index;
                    }
                }
            }

            # Find the earliest index at which the interface could be inserted, in order to be
            #   after other named interfaces
            for (my $index = 0; $index < $self->ivNumber($iv); $index++) {

                my ($thisObj, $name);

                # Get the active interface at position $index
                $thisObj = $self->ivIndex($index);
                # Get the name of the corresponding inactive interface
                $name = $thisObj->parent->name;

                if ($inactiveObj->ivExists('afterHash', $name)) {

                    # $interfaceObj must appear later than this index
                    if (! defined $last || $last < $index) {

                        $last = $index + 1;
                    }
                }
            }

            if (! defined $first && ! defined $last) {

                # Specified interface can be placed at the end of the ordered list
                $self->ivPush($iv, $interfaceObj->number);

            } elsif (defined $first && defined $last) {

                # The 'after' hash takes priority over the 'before' hash, so use $last
                $self->ivSplice($iv, $last, 1, $interfaceObj->number);

            } elsif (defined $first) {

                $self->ivSplice($iv, $first, 1, $interfaceObj->number);

            } else {

                $self->ivSplice($iv, $last, 1, $interfaceObj->number);
            }
        }

        return 1;
    }

    sub moveInterface {

        # Quick-and-dirty alternative to ->posnInterface, can be called by any code, usually after
        #   creating active interface(s) that are not based on an inactive interface
        # Moves the active interface(s) to the beginning (or end) of their order lists
        #
        # Expected arguments
        #   $flag       - Set to TRUE if the interfaces should be moved to the beginning of their
        #                   order lists, FALSE if they should be moved to the end
        #
        # Optional arguments
        #   @list       - List of names of active interface names. If the list is empty, no
        #                   interfaces are moved
        #
        # Return values
        #   'undef' on improper arguments or if @list is empty
        #   1 otherwise

        my ($self, $flag, @list) = @_;

        # Check for improper arguments
        if (! defined $flag) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->moveInterface', @_);
        }

        if (! @list) {

            # No interfaces to move
            return undef;
        }

        if ($flag) {

            # Reverse the list of interfaces, so that if it contains (for example) the triggers
            #   numbered [11, 12, 13], $self->triggerOrderList (etc) is modified so that it begins
            #   with triggers [11, 12, 13]
            @list = reverse @list;
        }

        OUTER: foreach my $name (@list) {

            my ($obj, $iv, @orderList);

            # Get the active interface object
            $obj = $self->ivShow('interfaceHash', $name);
            if (! $obj) {

                next OUTER;
            }

            # Set which IV stores this type of interface
            $iv = $obj->category . 'OrderList';
            @orderList = $self->$iv;

            # Find the position of the interface in the list
            INNER: for (my $count = 0; $count < (scalar @orderList); $count++) {

                if ($orderList[$count] == $obj->number) {

                    # Remove the interface from its position, and move it to the beginning (or end)
                    #   of the list
                    splice (@orderList, $count, 1);
                    if ($flag) {
                        unshift(@orderList, $obj->number);
                    } else {
                        push (@orderList, $obj->number);
                    }

                    last INNER;
                }
            }

            # Update the IV
            $self->ivPoke($iv, @orderList);
        }

        return 1;
    }

    sub createIndependentInterface {

        # Can be called by anything to create an independent active interface whose attributes
        #   aren't copied from an existing inactive interface, stored in some cage
        # Most usually called by tasks. 'Independent' interfaces perform some action in 'response'
        #   to a 'stimulus'.
        #
        # 'Dependent' interfaces call an object method as their 'response', and are created by a
        #   call to ->createDependentInterface. Both this function and ->createDependentInterface
        #   pass their arguments to ->setupInterface; we use three functions instead of one so it's
        #   clear, in the task code, what sort of interface is being created
        # To create an active interface using attributes copied from an existing inactive interface,
        #   stored in a cage, call ->injectInterface
        #
        # This function carries out a few basic checks before passing its arguments to
        #   ->setupInterface
        #
        # Expected arguments
        #   $type       - Which type of interface: 'trigger' ,'alias', 'macro', 'timer' or 'hook'
        #   $stimulus   - The stimulus, e.g. for a trigger interface, a regex like
        #                   'You kill the (.*) orc'
        #   $response   - The response, e.g. for a trigger interface, a string like
        #                   'You bravely attack your foe!'
        #
        # Optional arguments
        #   @args       - A list of attributes, in the form
        #                   (attribute, value, attribute, value)
        #               - Attributes can be two of the four standard attributes ('name', 'enabled')
        #                   or any of the category-specific attributes (e.g. 'gaglog' for a
        #                   trigger interface
        #               - The standard attributes 'stimulus' and 'response' can't be used in @args
        #
        # Return values
        #   'undef' on improper arguments, if $callClass isn't a blessed reference or if @args
        #       contains an odd number of elements
        #   Otherwise, returns the value of the call to $self->setupInterface

        my ($self, $type, $stimulus, $response, @args) = @_;

        # Check for improper arguments
        if (! defined $type || ! defined $stimulus || ! defined $response) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->createIndependentInterface',
                @_,
            );
        }

        # Carry out a few basic checks
        if (@args > 0 && @args % 2 == 1) {

            return $self->writeError(
                'Missing value in attribute/value pair',
                $self->_objClass . '->createIndependentInterface',
            );

        } elsif (
            $type ne 'trigger' && $type ne 'alias' && $type ne 'macro' && $type ne 'timer'
            && $type ne 'hook'
        ) {
            return $self->writeError(
                'Unrecognised interface type \'' . $type . '\'',
                $self->_objClass . '->createIndependentInterface',
            );

        } elsif ($self->status eq 'disconnected') {

            # Can't create this kind of interface after a disconnection
            return $self->writeError(
                'Can\'t create independent active interfaces after disconnection',
                $self->_objClass . '->createDependentInterface',
            );

        } else {

            # Set up the interface
            return $self->setupInterface(
                $type,
                TRUE,      # Inependent interface
                $stimulus,
                $response,
                @args,
            );
        }
    }

    sub createDependentInterface {

        # Can be called by anything to create a dependent active interface whose attributes
        #   aren't copied from an existing inactive interface, stored in some cage
        # Most usually called by tasks. 'Dependent' interfaces call an Axmud object method as their
        #   'response' to a 'stimulus'; in this case, a specially-created method in the task will
        #   usually be called. (Built-in tasks have methods that end with 'Seen', such as
        #   ->triggerSeen, for this sort of call)
        #
        # 'Independent' interfaces define their own 'response', and are created by a call to
        #   ->createIndependentInterface. Both this function and ->createIndependentInterface pass
        #   their arguments to ->setupInterface; we use three functions instead of one so it's
        #   clear, in the task code, what sort of interface is being created
        # To create an active interface using attributes copied from an existing inactive interface,
        #   stored in a cage, call ->injectInterface
        #
        # This function carries out a few basic checks before passing its arguments to
        #   ->setupInterface
        #
        # Expected arguments
        #   $type       - Which type of interface: 'trigger' ,'alias', 'macro', 'timer' or 'hook'
        #   $stimulus   - The stimulus, e.g. for a trigger interface, a string like
        #                   'You kill the (.*) orc'
        #   $callClass  - The object to call in response
        #   $callMethod - The object's method to call in response
        #
        # Optional arguments
        #   @args       - A list of attributes, in the form
        #                   (attribute, value, attribute, value)
        #               - Attributes can be two of the four standard attributes ('name', 'enabled')
        #                   or any of the category-specific attributes (e.g. 'gaglog' for a
        #                   trigger interface)
        #               - The standard attributes 'stimulus' and 'response' can't be used in @args
        #
        # Return values
        #   'undef' on improper arguments, if $callClass isn't a blessed reference, if $type isn't
        #       a valid interface type or if @args contains an odd number of elements
        #   Otherwise, returns the value of the call to $self->setupInterface

        my ($self, $type, $stimulus, $callClass, $callMethod, @args) = @_;

        # Check for improper arguments
        if (
            ! defined $type || ! defined $stimulus || ! defined $callClass || ! defined $callMethod
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->createDependentInterface',
                @_,
            );
        }

        # Carry out a few basic checks
        if (! ref $callClass) {

            return $self->writeError(
                'Unrecognised call class \'' . $callClass . '\' (must be a blessed reference)',
                $self->_objClass . '->createDependentInterface',
            );

        } elsif (
            $type ne 'trigger' && $type ne 'alias' && $type ne 'macro' && $type ne 'timer'
            && $type ne 'hook'
        ) {
            return $self->writeError(
                'Unrecognised interface type \'' . $type . '\'',
                $self->_objClass . '->createDependentInterface',
            );

        } elsif (@args > 0 && @args % 2 == 1) {

            return $self->writeError(
                'Missing value in attribute/value pair',
                $self->_objClass . '->createDependentInterface',
            );

        } elsif ($self->status eq 'disconnected') {

            # Can't create this kind of interface after a disconnection
            return $self->writeError(
                'Can\'t create dependent active interfaces after disconnection',
                $self->_objClass . '->createDependentInterface',
            );

        } else {

            # Set up the interface
            return $self->setupInterface(
                $type,
                FALSE,      # Dependent interface
                $stimulus,
                $callClass,
                $callMethod,
                @args,
            );
        }
    }

    sub setupInterface {

        # Called by $self->createDependentInterface and ->createIndependentInterface with a list of
        #   arguments that has already undergone basic checks (must not be called by any other
        #   function)
        # Creates the GA::Interface::Active object, sets its attributes, and adds the object to this
        #   GA::Session's active interface registries
        #
        # Expected arguments
        #   $type       - Which type of interface: 'trigger' ,'alias', 'macro', 'timer' or 'hook'
        #   $indepFlag  - TRUE for independent interfaces, FALSE for dependent interfaces
        #   $stimulus   - The stimulus, e.g. for a trigger interface, a regex like
        #                   'You kill the (.*) orc'
        #   @args       - The remaining arguments
        #
        # Return values
        #   'undef' on improper arguments, if any of the @args are invalid or if the object can't
        #       be created
        #   Otherwise, returns the new GA::Interface::Active object

        my ($self, $type, $indepFlag, $stimulus, @args) = @_;

        # Local variables
        my (
            $response, $callClass, $callMethod, $activeObj, $result, $interfaceModelObj,
            %attribHash,
        );

        # Check for improper arguments
        if (! defined $type || ! defined $indepFlag || ! defined $stimulus) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setupInterface', @_);
        }

        # Extract some arguments from @args
        if ($indepFlag) {

            # Independent interface
            $response = shift @args;

        } else {

            # Dependent interface
            $callClass = shift @args;
            $callMethod = shift @args;
        }

        # Now, any remaining arguments are in the form (attribute, value, attribute, value...)

        # Extract the remaining @args, which will be a list in the form
        #   (attribute, value, attribute, value...)
        if (@args) {

            do {

                my ($attrib, $value);

                $attrib = shift @args;
                $value = shift @args;

                if (! defined $value) {

                    return $self->writeError(
                        'Undefined value matching attribute \'' . $attrib . '\'',
                        $self->_objClass . '->setupInterface',
                    );

                # @args shouldn't contain 'stimulus', 'response', because they have already been
                #   specified
                # For dependent interfaces, @args shouldn't contain 'name', because a name will be
                #   automatically generated by this function (but it can be specified with
                #   independent interfaces)
                } elsif (
                    $attrib eq 'stimulus'
                    || $attrib eq 'response'
                ) {
                    return $self->writeError(
                        'Recognised but forbidden attribute \'' . $attrib . '\'',
                        $self->_objClass . '->setupInterface',
                    );

                # Warn about duplicate attributes
                } elsif (exists $attribHash{$value}) {

                    # Not a fatal error
                    $self->writeWarning(
                        'Duplicate attribute \'' . $attrib . '\'',
                        $self->_objClass . '->setupInterface',
                    );

                } else {

                    # Use this attribute-value pair
                    $attribHash{$attrib} = $value;
                }

            } until (! @args);
        }

        # Set the 'name' attribute, if %attribHash doesn't already contain one
        if (! exists $attribHash{'name'}) {

            if ($indepFlag) {

                # e.g. 'indep_trigger_36'
                $attribHash{'name'} = 'indep_' . $type . '_' . ($self->interfaceCount + 1);

            } else {

                # e.g. 'status_task_trigger_36'
                $attribHash{'name'}
                    = $callClass->_objName . '_' . $type . '_' . ($self->interfaceCount + 1);
            }
        }

        # Add the stimulus and response to %attribHash
        $attribHash{'stimulus'} = $stimulus;
        if ($indepFlag) {

            # (For dependent active interfaces, the response is made up of two components, set
            #   later in this function)
            $attribHash{'response'} = $response;
        }

        # Set the 'enabled' attribute, if %attribHash doesn't already contain one
        if (! exists $attribHash{'enabled'}) {

            # Enabled by default
            $attribHash{'enabled'} = TRUE;
        }

        # Create the active interface
        $activeObj = Games::Axmud::Interface::Active->new(
            $self,
            $type,
            $indepFlag,
        );

        if (! $activeObj) {

            # Couldn't create the active interface (error message already displayed)
            return undef;
        }

        # Set the active interface's attributes (including the ->name and ->enabledFlag attributes,
        #   if they are specified in %attribHash), overwriting the default attributes created just
        #   above
        $result = $activeObj->modifyAttribs($self, %attribHash);
        if (! $result) {

            # Error message already displayed
            return undef;
        }

        # For dependent active interfaces, the response is made up of two components
        if (! $indepFlag) {

            $activeObj->set_callClass($callClass);
            $activeObj->set_callMethod($callMethod);
        }

        # Add the new active interface to this GA::Session's registries
        if (! $self->addInterface($activeObj)) {
            return undef;
        } else {
            return $activeObj;
        }
    }

    # Fire active interfaces

    sub checkTriggers {

        # Called by $self->processLineSegment
        # Checks every active trigger interface. Fires every trigger that should be fired in
        #   response to a line of text received from the world (before it is displayed in the 'main'
        #   window)
        #
        # Checking against aliases triggers with the first trigger in $self->triggerOrderList, and
        #   ends when:
        #   - the first matching trigger whose 'keep_checking' attribute is TRUE, or
        #   - the first matching trigger whose 'response' attribute is a Perl command, or
        #   - the end of the active trigger list is reached
        #
        # The specified line, $stripLine, gets modified by any matching rewriter triggers.
        # The accompanying %tagHash (which holds the escape sequences in the original $line before
        #   they were removed, and now converted into Axmud colour/style tags) is also modified by
        #   any matching rewriter triggers, preserving the positions of those tags for parts of the
        #   line that were not modified (and adding new tags if matching triggers define a style)
        # Non-rewriter triggers specify an instruction, which is executed before the line is
        #   checked against other triggers (if allowed)
        #
        # NB This function ignores any splitter triggers - these are checked in calls to
        #   $self->checkSplitLine
        #
        # Expected arguments
        #   $text        - The line of text received from the world (including any escape sequences)
        #   $stripText   - The same line, with escape sequences removed
        #   $newLineFlag - Flag set to TRUE if the line of text ended with a newline character,
        #                    FALSE if not
        #
        # Optional arguments
        #   %tagHash     - A hash containing the removed escape sequences converted into Axmud
        #                    colour/style tags, in the form
        #                    $tagHash{position} = reference_to_a_list_of_Axmud_colour_and_style_tags
        #                  ...where $position is the position in $stripText where one or more escape
        #                    sequences occured (the first character is position 0. An empty hash
        #                    if $text contains no escape sequences)
        #
        # Return values
        #   An empty list on improper arguments or if no triggers fire
        #   Otherwise, returns a list in the form
        #       ($modText, $gagFlag, $gagLogFlag, \@instructList, \@dependentCallList, %tagHash)
        #   ...where:
        #       - $modText is the modified form of $stripText
        #       - $gagFlag is TRUE if the last trigger that fired had its 'gag' attribute set,
        #           FALSE if it didn't have its 'gag' attribute set
        #       - $gagLogFlag is TRUE if the last trigger that fired had its 'gag_log' attribute
        #           set, FALSE if it didn't have its 'gag_log' attribute set
        #       - @instructList is a list of instructions (not including Perl commands, which have
        #           already been evaluated), in the order their triggers fired, for the calling
        #           function to execute
        #       - @dependentCallList contains list references, each one of which stores the
        #           arguments used when a fired dependent trigger is processed by
        #           $self->processLineSegment
        #       - %tagHash is a modified hash of Axmud colour/style tags

        my ($self, $text, $stripText, $newLineFlag, %tagHash) = @_;

        # Local variables
        my (
            $prevLength, $bufferObj, $prevText, $modText, $fullText, $firstFireFlag, $lastObj,
            $gagFlag, $gagLogFlag,
            @emptyList, @instructList, @dependentCallList, @deleteList,
            %hash,
        );

        # Check for improper arguments
        if (! defined $text || ! defined $stripText || ! defined $newLineFlag) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->checkTriggers', @_);
            return @emptyList;
        }

        # Import the active trigger registry for quick lookup
        %hash = $self->triggerHash;
        # If some part of this line has already been displayed, we can test non-rewriter triggers
        #   against the whole line, ignoring any matches that match only the previously-displayed
        #   portion of the line
        # However, rewriter triggers can only be tested against the new portion of the line, stored
        #   in $stripText
        # Get the previously-displayed portion, if any
        $prevLength = 0;
        if ($self->displayBufferLast) {

            $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
            if ($bufferObj && ! $bufferObj->newLineFlag) {

                $prevText = $bufferObj->stripLine;
                $prevLength = length($prevText);
            }
        }

        # ($fullText, containing the whole line, is updated every time $modText is updated, i.e.
        #   whenever a rewriter trigger fires)
        $modText = $stripText;
        if ($prevLength) {
            $fullText = $prevText . $modText;
        } else {
            $fullText = $modText;
        }

        # Check every active trigger interface, in the correct order, to see if it is due to fire
        OUTER: foreach my $number ($self->triggerOrderList) {

            my (
                $obj, $regex, $noResponseFlag, $paneName, $substitution, $result, $compareString,
                $ignoreFlag, $globalFlag, $loopCount, $thisObj, $startOffset, $stopOffset,
                $lengthChange, $grpStringListRef, $matchMinusListRef, $matchPlusListRef,
                $instruction, $modInstruct, $perlFlag, $class, $method, $mode, $start, $stop,
                @matchMinusList, @matchPlusList,
                %oldTagHash,
            );

            $obj = $self->ivShow('interfaceNumHash', $number);
            $regex = $obj->stimulus;
            $noResponseFlag = $obj->ivShow('attribHash', 'ignore_response');

            # If the trigger is disabled, don't fire it
            # If the trigger is in the middle of its cooldown period, don't fire it
            # If the trigger is a splitter trigger, don't fire it (splitter triggers are tested
            #   elsewhere)
            # If the trigger requires a line ending with a newline character and the line doesn't
            #   end with one, don't fire it
            # If the trigger requires a login and the character isn't logged in, don't fire it
            # If the trigger requires a named pane, don't fire it
            $paneName = $obj->ivShow('attribHash', 'pane_name');
            if ($paneName eq '') {

                $paneName = undef;
            }

            if (
                ! $obj->enabledFlag
                || $self->ivExists('triggerCooldownHash', $number)
                || $obj->ivShow('attribHash', 'splitter')
                || ($obj->ivShow('attribHash', 'need_prompt') && $newLineFlag)
                || ($obj->ivShow('attribHash', 'need_login') && ! $self->loginFlag)
                || (defined $paneName && $self->currentTabObj->paneObj->name ne $paneName)
                || (
                    ! defined $paneName
                    && $obj->ivShow('attribHash', 'default_pane')
                    && $self->currentTabObj ne $self->defaultTabObj
                )
            ) {
               next OUTER;
            }

            if ($obj->indepFlag) {

                # An independent trigger

                # If the trigger ->response begins with the Perl command sigil, checking against
                #   other triggers halts and the Perl command is evaluated
                # Otherwise, the ->response is executed as an instruction and checking against
                #   other triggers continues (if allowed)
                #
                # However, 'rewriter' triggers behave in a different way. Their ->response is used
                #   in a substitution to modify the received line of text. Checking against other
                #   triggers continues (if allowed, in which case any of them that are rewriter
                #   triggers can also modify the line of text)
                # (If the 'ignore_response' attribute is set, we ignore the 'rewriter' attribute;
                #   a trigger can't be both)
                if ($obj->ivShow('attribHash', 'rewriter') && ! $noResponseFlag) {

                    # An indendent rewriter trigger
                    $substitution = $obj->response;
                    # Perform this trigger's pattern match on the result of previous matches
                    $result = $modText;
                    # Compare the length of $result before and after each substitution with this var
                    $compareString = $result;

                    $ignoreFlag = $obj->ivShow('attribHash', 'ignore_case');
                    $globalFlag = $obj->ivShow('attribHash', 'rewrite_global');

                    # If $globalFlag is true, it would be nice to do substitutions with s//g, but
                    #   it's not possible to accurately keep track of tag positions after a
                    #   substitution that might have twice (or more) on the same line
                    # Solution is a do..until loop, which terminates when $globalFlag is FALSE. If
                    #   $globalFlag is currently false, we do one substitution; if $globalFlag is
                    #   currently TRUE, we continue doing substitutions until the regex no longer
                    #   matches the line - at that point, $globalFlag is set to FALSE, which
                    #   terminates the loop
                    # To prevent infinite loops, Axmud doesn't do more than $self->constRewriteMax
                    #   rewrite operations per line, per trigger
                    $loopCount = 0;     # Give up when this gets to 16
                    do {

                        $loopCount++;
                        if ($loopCount >= $self->constRewriteMax) {

                            $globalFlag = FALSE;   # No more rewrites on this line, for this trigger
                        }

                        if (
                            ($ignoreFlag && $result =~ m/$regex/i)
                            || (! $ignoreFlag && $result =~ m/$regex/)
                        ) {
                            # The trigger has fired
                            $thisObj = $obj;        # Marks the trigger as having fired
                            if (! $firstFireFlag && $thisObj->ivShow('attribHash', 'temporary')) {

                                # A temporary trigger, which must be deleted when it first fires
                                push (@deleteList, $thisObj);
                                # Don't add it to the list more than once
                                $firstFireFlag = TRUE;

                            } elsif ($thisObj->ivShow('attribHash', 'cooldown')) {

                                # Apply the cooldown period
                                $self->ivAdd(
                                    'triggerCooldownHash',
                                    $thisObj->number,
                                    $self->sessionTime + $thisObj->ivShow('attribHash', 'cooldown'),
                                );
                            }

                            # Perform the rewriting operation

                            # Before v1.1.183, we required a substitution containing group substring
                            #   variables ($1, $2 etc) to be enclosed within double quotes, so that
                            #   it's safe for this function to use the /ee modifier without the risk
                            #   of executing arbitrary Perl code
                            # Since then, we automatically add the initial/final double quotes,
                            #   unless the substitution already contains both (because it was
                            #   written before v.1.183)

                            # Remove the obsolete initial/final quotes, if present
                            $substitution =~ s/^\"(.*)\"$/$1/;
                            # Any double quotes inside the substitution must be escaped
                            $substitution =~ s/\"/\\\"/g;
                            # Now add our own initial/final quotes
                            $substitution = "\"$substitution\"";

                            if ($ignoreFlag) {

                                $result =~ s/$regex/$substitution/iee;
                                @matchMinusList = @-;
                                @matchPlusList = @+;

                            } else {

                                $result =~ s/$regex/$substitution/ee;
                                @matchMinusList = @-;
                                @matchPlusList = @+;
                            }

                            # We need to modify the hash of Axmud colour/style tags, %tagHash
                            #
                            # e.g.
                            #               0123456789012345678901234567890123456789012
                            #                   ...............
                            #   $modText = "the quick brown fox jumps over the lazy dog";
                            #   $modText =~ s/quick (.*) fox)/speedy $1 cat/;
                            #
                            # The offsets for the replaced text are $-[0] = 4 and $+[0] = 19
                            # Any colour/style tags which occur at offsets 0-4 or 19+ are preserved,
                            #   but all colour/style tags at offsets 5-18 are destroyed
                            # Any colour/style tags at offset 19+ must have their offset increased
                            #   (if $modText has increased in length) or decreased (if $modText has
                            #   decreased in length)
                            # (@-)
                            $startOffset = $matchMinusList[0];
                            # (@+)
                            $stopOffset = $matchPlusList[0];
                            # (Negative if shorter)
                            $lengthChange = length ($result) - length ($compareString);
                            # (Next do..until loop iteration, compare length of this iteration)
                            $compareString = $result;

                            # Modify the hash
                            %oldTagHash = %tagHash;
                            %tagHash = ();

                            foreach my $textNum (keys %oldTagHash) {

                                my ($listRef, $newLineNum);

                                $listRef = $oldTagHash{$textNum};

                                # Preserve only the escape sequences at positions (e.g.) 0-4, 19+
                                if ($textNum <= $startOffset) {

                                    $tagHash{$textNum} = $listRef;     # e.g. offsets 0-4

                                } elsif ($textNum >= $stopOffset) {

                                    $newLineNum = $textNum + $lengthChange;
                                    $tagHash{$newLineNum} = $listRef;
                                }
                            }

                        } else {

                            # The regex no longer matches the line, so stop doing substitutions
                            $globalFlag = FALSE;
                        }

                    } until (! $globalFlag);

                    # Future modifier triggers modify the modified line
                    $modText = $result;
                    if ($prevLength) {
                        $fullText = $prevText . $modText;
                    } else {
                        $fullText = $modText;
                    }

                } else {

                    # An independent trigger (not a rewriter). Perform the pattern match
                    if ($obj->ivShow('attribHash', 'ignore_case')) {

                        # Test the regex against the whole line, including any previously-displayed
                        #   portion, but ignore a match if it applies only to the previously-
                        #   displayed portion
                        # The TRUE argument means to do a case-insensitive match
                        (
                            $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                            $matchPlusListRef,
                        ) = $self->matchTrigger($fullText, $regex, $prevLength, TRUE);

                        if ($ignoreFlag) {

                            # $regex matches the previously-displayed portion of the line
                            # Having ignored that, we now test $regex against the line portion that
                            #   hasn't been displayed yet, $modText, in case there's a match there
                            (
                                $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                                $matchPlusListRef,
                            ) = $self->matchTrigger($modText, $regex, 0, TRUE);
                        }

                    } else {

                        # The FALSE argument means to do a case-insensitive match
                        (
                            $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                            $matchPlusListRef,
                        ) = $self->matchTrigger($fullText, $regex, $prevLength, FALSE);

                        if ($ignoreFlag) {

                            # $regex matches the previously-displayed portion of the line
                            # Having ignored that, we now test $regex against the line portion that
                            #   hasn't been displayed yet, $modText, in case there's a match there
                            (
                                $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                                $matchPlusListRef,
                            ) = $self->matchTrigger($modText, $regex, 0, FALSE);
                        }
                    }

                    if ($result && ! $noResponseFlag) {

                        # The trigger has (potentially) fired
                        $instruction = $obj->response;

                        # Allow the instruction to make use of group substring variables ($1, $2
                        #   etc) by use of a clever substitution
                        # (Specifically, perform a substitution on the incoming line, $modText,
                        #   which gives us the contents of any group substrings; then replace $1, $2
                        #   in the instruction with the contents of those group substrings)
                        $modInstruct = $modText;
                        $substitution = $instruction;
                        # Any double quotes inside the substitution must be escaped
                        $substitution =~ s/\"/\\\"/g;
                        # Now add our own initial/final quotes
                        $substitution = "\"$substitution\"";

                        $substitution = "\"$instruction\"";
                        if ($obj->ivShow('attribHash', 'ignore_case')) {
                            $modInstruct =~ s/^.*$regex.*$/$substitution/iee;
                        } else {
                            $modInstruct =~ s/^.*$regex.*$/$substitution/ee;
                        }

                        if (
                            $axmud::CLIENT->perlSigilFlag
                            && index ($modInstruct, $axmud::CLIENT->constPerlSigil) == 0
                        ) {
                            # An independent trigger using a Perl command as an instruction

                            # Store data so that it's available to the Perl mini-programme
                            $self->ivAdd('perlCmdDataHash', '_line', $text);
                            $self->ivAdd('perlCmdDataHash', '_stripLine', $stripText);
                            $self->ivAdd('perlCmdDataHash', '_modLine', $modText);
                            $self->ivPoke('perlCmdTagHash', %tagHash);
                            $self->ivPoke('perlCmdGrpStringList', @$grpStringListRef);
                            # (Also store the interface which fired)
                            $self->ivAdd('perlCmdDataHash', '_interface', undef);

                            # Evaluate the Perl command, getting an instruction as a return value
                            $modInstruct = $self->perlCmd($modInstruct, TRUE);

                            # The stored data is no longer needed
                            $self->ivAdd('perlCmdDataHash', '_line', undef);
                            $self->ivAdd('perlCmdDataHash', '_stripLine', undef);
                            $self->ivAdd('perlCmdDataHash', '_modLine', undef);
                            $self->ivEmpty('perlCmdTagHash');
                            $self->ivEmpty('perlCmdGrpStringList');
                            $self->ivAdd('perlCmdDataHash', '_interface', undef);

                            # Don't check any other triggers
                            $perlFlag = TRUE;
                        }

                        # If the call to ->perlCmd returned 'undef' (because the Perl mini-programme
                        #   was invalid), we'll treat this trigger as not having fired
                        if (defined $modInstruct) {

                            # The trigger has (definitely) fired
                            $thisObj = $obj;
                            # Add the instruction to the list of instructions to process, when
                            #   control is returned to the calling function
                            push (@instructList, $modInstruct);

                            if ($thisObj->ivShow('attribHash', 'temporary')) {

                                # A temporary trigger, which must be deleted when it first fires
                                push (@deleteList, $thisObj);

                            } elsif ($thisObj->ivShow('attribHash', 'cooldown')) {

                                # Apply the cooldown period
                                $self->ivAdd(
                                    'triggerCooldownHash',
                                    $thisObj->number,
                                    $self->sessionTime + $thisObj->ivShow('attribHash', 'cooldown'),
                                );
                            }
                        }

                    } elsif ($result && $noResponseFlag) {

                        # The trigger has fired, but we ignore the trigger response. Just set
                        #   $thisObj so that any trigger style can be applied
                        $thisObj = $obj;

                        if ($thisObj->ivShow('attribHash', 'temporary')) {

                            # A temporary trigger, which must be deleted when it first fires
                            push (@deleteList, $thisObj);

                        } elsif ($thisObj->ivShow('attribHash', 'cooldown')) {

                            # Apply the cooldown period
                            $self->ivAdd(
                                'triggerCooldownHash',
                                $thisObj->number,
                                $self->sessionTime + $thisObj->ivShow('attribHash', 'cooldown'),
                            );
                        }
                    }
                }

            } else {

                # A dependent trigger. If its 'rewriter' attribute is TRUE, ignore the attribute
                #   (dependent triggers can't be rewriters). If the 'style_mode' attribute is not
                #   0, ignore the attribute (dependent triggers can't be used with styles)
                # Perform the pattern match
                if ($obj->ivShow('attribHash', 'ignore_case')) {

                    # The TRUE argument means to do a case-insensitive match
                    ($result, $ignoreFlag, $grpStringListRef, $matchMinusListRef, $matchPlusListRef)
                        = $self->matchTrigger($fullText, $regex, $prevLength, TRUE);

                    if ($ignoreFlag) {

                        # $regex matches the previously-displayed portion of the line
                        # Having ignored that, we now test $regex against the line portion that
                        #   hasn't been displayed yet, $modText, in case there's a match there
                        (
                            $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                            $matchPlusListRef,
                        ) = $self->matchTrigger($modText, $regex, $prevLength, TRUE);
                    }

                } else {

                    # The FALSE argument means to do a case-insensitive match
                    ($result, $ignoreFlag, $grpStringListRef, $matchMinusListRef, $matchPlusListRef)
                        = $self->matchTrigger($fullText, $regex, $prevLength, FALSE);

                    if ($ignoreFlag) {

                        (
                            $result, $ignoreFlag, $grpStringListRef, $matchMinusListRef,
                            $matchPlusListRef,
                        ) = $self->matchTrigger($modText, $regex, $prevLength, FALSE);
                    }
                }

                if ($result) {

                    # The trigger has fired
                    $thisObj = $obj;
                    if ($thisObj->ivShow('attribHash', 'temporary')) {

                        # A temporary trigger, which must be deleted when it first fires
                        push (@deleteList, $thisObj);

                    } elsif ($thisObj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'triggerCooldownHash',
                            $thisObj->number,
                            $self->sessionTime + $thisObj->ivShow('attribHash', 'cooldown'),
                        );
                    }

                    if (! $noResponseFlag) {

                        $class = $obj->callClass;
                        $method = $obj->callMethod;

                        # As soon as the received line of text has been displayed,
                        #   $self->processLineSegment can call $class->$method. Store the arguments
                        #   to be used in the call until then
                        push (
                            @dependentCallList,
                            [
                                $class,
                                $method,
                                $self,
                                $number,
                                $text,
                                $stripText,
                                $modText,
                                $grpStringListRef,
                                $matchMinusListRef,
                                $matchPlusListRef,
                            ],
                        );
                    }
                 }
            }

            if ($thisObj) {

                # The trigger did fire
                $lastObj = $thisObj;        # At least one trigger has fired

                # If $self->matchTrigger was called, dereference $matchMinusListRef. If a rewriter
                #   trigger fired, we already have @matchMinusList
                if (defined $matchMinusListRef) {

                    @matchMinusList = @$matchMinusListRef;
                    @matchPlusList = @$matchPlusListRef;
                }

                # If it's an independent trigger and the 'style_mode' attribute is set, we need to
                #   apply the style to the whole (or part of) $modText (styles can't be applied to
                #   dependent triggers)
                $mode = $thisObj->ivShow('attribHash', 'style_mode');
                if ($thisObj->indepFlag && $mode) {

                    # Set the first and last character in $modText to which the style should be
                    #   applied. $start is the offset of the first character, $stop is the offset
                    #   of the character AFTER the last oneN (which may be outside $modText)
                    if ($mode == -1) {

                        # Apply the style to the whole line
                        $start = 0;
                        $stop = length ($modText);

                    } elsif ($mode == -2) {

                        # Apply the style to the matched text
                        $start = $matchMinusList[0];        # @-
                        $stop = $matchPlusList[0];          # @+

                    } else {

                        # Apply the style to matched substring
                        $start = $matchMinusList[$mode];    # @-
                        $stop = $matchPlusList[$mode];      # @+
                    }

                    if (defined $start && defined $stop) {

                        %oldTagHash = %tagHash;

                        ($result, %tagHash) = $self->applyTriggerStyle(
                            $thisObj,
                            $modText,
                            $start,
                            $stop,
                            %tagHash,
                        );

                        if (! defined $result) {

                            # In case ->applyTriggerStyle fails, restore values
                            %tagHash = %oldTagHash;
                        }
                    }
                }

                # Should we continue checking other triggers?
                if ($perlFlag || ! $thisObj->ivShow('attribHash', 'keep_checking')) {

                    # Don't check any more triggers
                    last OUTER;
                }
            }
        }

        if (! $lastObj) {

            # No trigger fired, so $modText is unaltered
            return @emptyList;

        } else {

            # Prepare the list of return values. Use attributes from the last trigger that fired
            if ($lastObj->ivShow('attribHash', 'gag')) {
                $gagFlag = TRUE;
            } else {
                $gagFlag = FALSE;
            }

            if ($lastObj->ivShow('attribHash', 'gag_log')) {
                $gagLogFlag = TRUE;
            } else {
                $gagLogFlag = FALSE;
            }

            # Any temporary triggers which fired can now be deleted
            foreach my $obj (@deleteList) {

                $self->removeInterface($obj);
            }

            return ($modText, $gagFlag, $gagLogFlag, \@instructList, \@dependentCallList, %tagHash);
        }
    }

    sub matchTrigger {

        # Called by $self->checkTriggers (only) to check a non-rewriter trigger against a line of
        #   received text (whole or partial)
        #
        # Expected arguments
        #   $text       - The whole or partial line of text
        #   $regex      - The trigger stimulus to be compared against $text
        #   $prevLength - The first part of $text might be text that's already been displayed. If
        #                   so, $prevLength is the length of that portion. This function will
        #                   ignore any match that lies exclusively within the previously-displayed
        #                   text, but will accept any match that lies partially inside it. If $text
        #                   doesn't contain any text that's been displayed, $prevLength will be 0
        #   $noCaseFlag - Flag set to TRUE for a case-insensitive match, FALSE for a case-sensitive
        #                   match
        #
        # Return values
        #   An empty list on improper arguments
        #   If $regex does not match $text, returns a list in the form
        #       (FALSE, ignore_flag)
        #   ...where 'ignore_flag' is TRUE if $regex matches $text, but exclusively within the
        #       previously-displayed portion; FALSE if $regex matches $text partially inside any
        #       previously-displayed portion or wholly outside of it
        #   If $regex matches $text, returns a list in the form
        #       (TRUE, FALSE, group_string_list_ref, match_minus_list_ref, match_plus_list_ref)
        #   ...where 'group_string_list_ref' is a reference to a list of matching substrings,
        #       'match_minus_list_ref' is a reference to @-, and 'match_plus_list_ref' is a
        #       reference to @+
        my ($self, $text, $regex, $prevLength, $noCaseFlag, $check) = @_;

        # Local variables
        my (
            $result,
            @emptyList, @grpStringList, @matchMinusList, @matchPlusList,
        );

        # Check for improper arguments
        if (
            ! defined $text || ! defined $regex || ! defined $prevLength || ! defined $noCaseFlag
            || defined $check
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->matchTrigger', @_);
            return @emptyList;
        }

        if ($noCaseFlag) {

            @grpStringList = ($text =~ m/$regex/i);
            if (@grpStringList) {

                $result = TRUE;

                # Group substring data is local to this code block, so store it now
                @matchMinusList = @-;
                @matchPlusList = @+;
            }

        } else {

            @grpStringList = ($text =~ m/$regex/);
            if (@grpStringList) {

                $result = TRUE;

                # Group substring data is local to this code block, so store it now
                @matchMinusList = @-;
                @matchPlusList = @+;
            }
        }

        if (! $result) {

            return (FALSE);

        } elsif ($prevLength && $matchPlusList[0] < $prevLength) {

            return (FALSE, TRUE);

        } else {

            # If the regex contained groups, @grpStringList contains the matching substrings. If it
            #   didn't, @grpStringList contains a single value, 1
            if ((scalar @matchMinusList) == 1) {

                # Remove the single value
                @grpStringList = ();
            }

            # (Index 0 should contain the whole matched string, index 1 should be the first matched
            #   substring, corresponding to $1, etc)
            unshift (
                @grpStringList,
                substr($text, $matchMinusList[0], $matchPlusList[0] - $matchMinusList[0]),
            );

            return (TRUE, FALSE, \@grpStringList, \@matchMinusList, \@matchPlusList);
        }
    }

    sub applyTriggerStyle {

        # Called by $self->checkTriggers when a trigger, whose 'style_mode' attribute is set, fires
        # Applies the style to the line (or a portion of the line)
        #
        # Expected arguments
        #   $obj            - The GA::Interface::Trigger object which has fired
        #   $line           - The line which caused the trigger to fire
        #   $start, $stop   - Offsets describing which portion of the line are subject to the
        #                       style. $start is the offset of the first character in the portion;
        #                       $stop is the offset of the character after the last character in
        #                       the portion (which may be outside $line)
        #
        # Optional arguments
        #   %tagHash        - A hash containing Axmud colour/style tags, in the form
        #                       $tagHash{position} = reference_to_list_of_Axmud_colour_&_style_tags
        #                     ...where $position is the position in $line where one or more colour
        #                       or style tags apply (the first character is position 0. An empty
        #                       hash if no colour/style tags apply to $line, yet)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list in the form
        #       (1, %tagHash)
        #   ...where 1 shows success, and %tagHash is the (possibly modified) hash of Axmud colour
        #       and style tags

        my ($self, $obj, $line, $start, $stop, %tagHash) = @_;

        # Local variables
        my (
            $textViewObj, $listRef, $flag, $attribsOffFlag,
            @emptyList, @posnList, @styleList,
            %styleHash, %styleOffHash, %attribHash, %prevHash, %startHash, %stopHash, %flagHash,
        );

        # Check for improper arguments
        if (! defined $obj || ! defined $line || ! defined $start || ! defined $stop) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->applyTriggerStyle', @_);
            return @emptyList;
        }

        # Import the current textview object (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # Get a sorted list of positions at which an Axmud colour/style tag applies
        @posnList = sort {$a <=> $b} (keys %tagHash);
        # A list of trigger styles (which include both Axmud colour tags and style tags) processed
        #   by this function
        @styleList = (
            'text',
            'underlay',
            'italics',
            'underline',
            'blink_slow',
            'blink_fast',
            'strike',
            'link',
        );

        # A hash of trigger styles that correspond to Axmud style tags (for quick lookup)
        %styleHash = (
            'italics'       => undef,
            'underline'     => undef,
            'blink_slow'    => undef,
            'blink_fast'    => undef,
            'strike'        => undef,
            'link'          => undef,
        );

        # A hash off Axmud style tags that turn off those style tags
        %styleOffHash = (
             # This tag        turns off this one
            'italics_off'   => 'italics',
            'underline_off' => 'underline',
            'blink_off'     => 'blink_slow',        # (blink_fast is handled as an exception)
            'strike_off'    => 'strike',
            'link_off'      => 'link',
        );

        # Get the trigger's current style attributes (->attribHash contains other key-value pairs,
        #   besides the style attributes)
        foreach my $style (@styleList) {

            # ->attribHash keys are 'style_text', 'style_blink_fast' etc
            $attribHash{$style} = $obj->ivShow('attribHash', 'style_' . $style);
        }

        if (! %attribHash) {

            # No trigger styles to apply
            return (1, %tagHash);
        }

        # PART 1
        # Fetch the colour and style tags that applied at the end of the previous line
        #   - For colour tags, 'undef' if default colours being used, or if there was no previous
        #       line)
        #   - For style tags, FALSE if off, TRUE if on
        # NB ->prevColourStyleHash can also contain 'dummy' style tags like 'bold' and
        #   'reverse_off', which we don't need here
        foreach my $style (@styleList) {

            # ->colourStyleHash keys are 'text', 'blink_fast', etc
            $prevHash{$style} = $textViewObj->ivShow('prevColourStyleHash', $style);
        }

        # PART 2
        # Work out which colours/styles apply at the start of the portion. Go through every offset
        #   in %tagHash in ascending order, until we reach an offset at the start of the portion
        #   ($start), or the nearest offset before it
        %startHash = %prevHash;

        OUTER: foreach my $posn (@posnList) {

            if ($posn > $start) {

                last OUTER;
            }

            # Get the list of Axmud colour/style tags that were applied at this offset
            $listRef = $tagHash{$posn};
            if (! defined $listRef) {

                # (If there is no list of colour/style tags at this offset, so create one)
                @$listRef = ();
            }

            # Work out the colours/styles that would actually be displayed at this offset, storing
            #   it in a hash in the same format as GA::Client->constColourStyleHash
            %startHash = $textViewObj->applyColourStyleTags(
                $self,
                $listRef,
                %startHash,
            );
       }

        # PART 3
        # Work out which colours/styles apply at the end of the portion. Go through every offset
        #   in %tagHash, ignoring those up to and including $start, and those after $stop
        %stopHash = %startHash;

        OUTER: foreach my $posn (@posnList) {

            if ($posn > $stop) {

                last OUTER;

            } elsif ($posn <= $start) {

                next OUTER;     # We've already checked these offsets in PART 2
            }

            # Get the list of Axmud colour/style tags that were applied at this offset
            $listRef = $tagHash{$posn};
            if (! defined $listRef) {

                # (If there is no list of colour/style tags at this offset, so create one)
                @$listRef = ();
            }

            # Work out the colours/styles that would actually be displayed at this offset, storing
            #   it in a hash in the same format as GA::Client->constColourStyleHash
            %stopHash = $textViewObj->applyColourStyleTags(
                $self,
                $listRef,
                %stopHash,
            );
        }

        # PART 4
        # Apply style/colour tags at the start of the portion; use the existing list of tags at
        #   that offset, and add new ones to the end of the list, as appropriate
        $listRef = $tagHash{$start};
        if (! defined $listRef) {

            # (If there is no list of tags at this offset, create one)
            @$listRef = ();
        }

        foreach my $trigStyle (keys %attribHash) {

            # Text colours
            if ($trigStyle eq 'text') {

                if (
                    $attribHash{'text'}
                    && (
                        ! $startHash{'text'}
                        || $startHash{'text'} ne $attribHash{'text'}
                    )
                ) {
                    # Change the text colour by adding a new tag to the list
                    push (@$listRef, $attribHash{'text'});
                }

            # Underlay colours
            } elsif ($trigStyle eq 'underlay') {

                if (
                    $attribHash{'underlay'}
                    && (
                        ! $startHash{'underlay'}
                        || $startHash{'underlay'} ne $attribHash{'underlay'}
                    )
                ) {
                    # Change the underlay colour by adding a new tag to the list
                    push (@$listRef, $attribHash{'underlay'});
                }

            # Styles
            } else {

                # A style
                if ($attribHash{$trigStyle} && ! $startHash{$trigStyle}) {

                    # Style is off, so turn it on
                    push (@$listRef, $trigStyle);

                } elsif (! $attribHash{$trigStyle} && $startHash{$trigStyle}) {

                    # Style is on, so turn it off
                    if ($trigStyle eq 'blink_slow' || $trigStyle eq 'blink_fast') {
                        push (@$listRef, 'blink_off');
                    } else {
                        push (@$listRef, $trigStyle . '_off');
                    }
                }
            }
        }

        $tagHash{$start} = $listRef;

        # PART 5
        # Apply style/colour tags at the end of the portion; use the existing list of tags at
        #   that offset, and add new ones to the end of the list, as appropriate
        $listRef = $tagHash{$stop};
        if (! defined $listRef) {

            # (If there is no list of tags at this offset, create one)
            @$listRef = ();
        }

        foreach my $trigStyle (keys %attribHash) {

            # Text colours
            if ($trigStyle eq 'text') {

                if (
                    $attribHash{'text'}
                    && (
                        ! $stopHash{'text'}
                        || $stopHash{'text'} ne $attribHash{'text'}
                    )
                ) {
                    # Change the text colour by adding a new tag to the list
                    if ($stopHash{'text'}) {
                        push (@$listRef, $stopHash{'text'});
                    } else {
                        push (@$listRef, $self->currentTabObj->textViewObj->textColour);
                    }
                }

            # Underlay colours
            } elsif ($trigStyle eq 'underlay') {

                if (
                    $attribHash{'underlay'}
                    && (
                        ! $stopHash{'underlay'}
                        || $stopHash{'underlay'} ne $attribHash{'underlay'}
                    )
                ) {
                    # Change the underlay colour by adding a new tag to the list
                    if ($stopHash{'underlay'}) {
                        push (@$listRef, $stopHash{'underlay'});
                    } else {
                        push (@$listRef, $self->currentTabObj->textViewObj->underlayColour);
                    }
                }

            # Styles
            } else {

                # A style
                if ($attribHash{$trigStyle} && ! $stopHash{$trigStyle}) {

                    # Style is on, so turn it off
                    if ($trigStyle eq 'blink_slow' || $trigStyle eq 'blink_fast') {
                        push (@$listRef, 'blink_off');
                    } else {
                        push (@$listRef, $trigStyle . '_off');
                    }

                } elsif (! $attribHash{$trigStyle} && $stopHash{$trigStyle}) {

                    # Style is on, so turn it off
                    push (@$listRef, $trigStyle);
                }
            }
        }

        $tagHash{$stop} = $listRef;

        # Operation complete
        return (1, %tagHash);
    }

    sub checkAliases {

        # Called by $self->worldCmd
        # Checks every active alias interface. Fires every alias that should be fired in response to
        #   a world command (before it is sent to the world).
        #
        # Checking against aliases starts with the first alias in $self->aliasOrderList, and ends
        #   when:
        #   - the first matching alias whose 'keep_checking' attribute is TRUE, or
        #   - the first matching alias whose 'response' attribute evaluates as a Perl command, or
        #   - the end of the active alias list is reached
        #
        # The specified command, $originalCmd, gets converted into an instruction that should be:
        #   - the original world command, which is returned as a string
        #   - an instruction containing one or more modified world commands, e.g. 'north' or
        #       'north;eat cake;kill orc', which is returned as a string
        #   - an instruction consisting of a Perl command which is evaluated, and the return value
        #       returned to be used as a world command
        # If the return value starts with any kind of sigil, the calling function will ignore it
        #   - only world commands are actually sent to the world
        #
        # Expected arguments
        #   $originalCmd    - The original world command (always a single world command, not a
        #                       sequence of world commands)
        #
        # Return values
        #   'undef' on improper arguments or if no command is to be sent to the world
        #   Otherwise, returns the command to send to the world (which will be the same as
        #       $originalCmd, if no aliases fired)

        my ($self, $originalCmd, $check) = @_;

        # Local variables
        my (
            $cmd, $depFireFlag, $returnValue,
            @deleteList,
            %hash,
        );

        # Check for improper arguments
        if (! defined $originalCmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkAliases', @_);
        }

        # Import the active alias registry for quick lookup
        %hash = $self->aliasHash;

        # Check every active alias interface, in the correct order, to see if it is due to fire
        $cmd = $originalCmd;
        OUTER: foreach my $number ($self->aliasOrderList) {

            my (
                $obj, $regex, $substitution, $result, $class, $method,
                @grpStringList,
            );

            $obj = $self->ivShow('interfaceNumHash', $number);
            $regex = $obj->stimulus;

            # If the alias is disabled, don't fire it
            # If the alias is in the middle of its cooldown period, don't fire it
            if (
                ! $obj->enabledFlag
                || $self->ivExists('aliasCooldownHash', $number)
            ) {
               next OUTER;
            }

            if ($obj->indepFlag) {

                # An independent alias

                # If the alias ->response begins with a forward slash, checking against other
                #   aliases halts and the Perl command is evaluated (at the end of this function)
                # Otherwise, we perform a substitution. If the resulting string begins with a
                #   forward slash, checking against other aliases halts, and the Perl command is
                #   evaluated
                # Otherwise, the resulting string is checked against the next alias (if allowed).
                #   The last resulting string is used as an instruction
                if (substr ($regex, 0, 1) eq '/') {

                    # An independent alias has fired
                    $depFireFlag = FALSE;
                    if ($obj->ivShow('attribHash', 'temporary')) {

                        # A temporary alias, which must be deleted when it first fires
                        push (@deleteList, $obj);

                    } elsif ($obj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'aliasCooldownHash',
                            $obj->number,
                            $self->sessionTime + $obj->ivShow('attribHash', 'cooldown'),
                        );
                    }

                    # Don't check against any more aliases
                    $cmd = $regex;
                    last OUTER;
                }

                # Otherwise, perform the substitution, using the alias's ->stimulus as a regex. If
                #   the $result is different from $cmd, we say the alias has fired
                $substitution = $obj->response;
                $result = $cmd;

                # Before v1.1.183, we required a substitution containing group substring variables
                #   ($1, $2 etc) to be enclosed within double quotes, so that it's safe for this
                #   function to use the /ee modifier without the risk of executing arbitrary Perl
                #   code
                # Since then, we automatically add the initial/final double quotes, unless the
                #   substitution already contains both (because it was written before v.1.183)

                # Remove the obsolete initial/final quotes, if present
                $substitution =~ s/^\"(.*)\"$/$1/;
                # Any double quotes inside the substitution must be escaped
                $substitution =~ s/\"/\\\"/g;
                # Now add our own initial/final quotes
                $substitution = "\"$substitution\"";

                if ($obj->ivShow('attribHash', 'ignore_case')) {
                    $result =~ s/$regex/$substitution/iee;
                } else {
                    $result =~ s/$regex/$substitution/ee;
                }

                if ($result ne $cmd) {

                    # An independent alias has fired
                    $depFireFlag = FALSE;
                    if ($obj->ivShow('attribHash', 'temporary')) {

                        # A temporary alias, which must be deleted when it first fires
                        push (@deleteList, $obj);

                    } elsif ($obj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'aliasCooldownHash',
                            $obj->number,
                            $self->sessionTime + $obj->ivShow('attribHash', 'cooldown'),
                        );
                    }

                    # Any further alias substitutions are performed on the result, not the original
                    #   command
                    $cmd = $result;

                    # Should we continue checking other aliases?
                    if (
                        $obj->ivShow('attribHash', 'keep_checking')
                        || substr ($result, 0, 1) eq '/'
                    ) {
                        # Don't check any more aliases
                        last OUTER;
                    }
                }

            } else {

                # A dependent alias
                # Perform the patern match, using the alias's ->stimulus as a regex. If TRUE, call
                #   some part of the Axmud code
                if ($obj->ivShow('attribHash', 'ignore_case')) {
                    @grpStringList = ($cmd =~ m/$regex/i);
                } else {
                    @grpStringList = ($cmd =~ m/$regex/);
                }

                if (@grpStringList) {

                    # A dependent alias has fired
                    $depFireFlag = TRUE;
                    if ($obj->ivShow('attribHash', 'temporary')) {

                        # A temporary alias, which must be deleted when it first fires
                        push (@deleteList, $obj);

                    } elsif ($obj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'aliasCooldownHash',
                            $obj->number,
                            $self->sessionTime + $obj->ivShow('attribHash', 'cooldown'),
                        );
                    }

                    # Call the specified function
                    $class = $obj->callClass;
                    $method = $obj->callMethod;
                    $class->$method($self, $number, $originalCmd, @grpStringList);

                    # Should we continue checking other aliases?
                    if ($obj->ivShow('attribHash', 'keep_checking')) {

                        # Don't check any more aliases
                        last OUTER;
                    }
                }
            }
        }

        # Any temporary aliases which fired can now be deleted
        foreach my $obj (@deleteList) {

            $self->removeInterface($obj);
        }

        # If at least one alias fired, and the last alias to fire was a dependent alias, we return
        #   'undef' to show that there's no command to be sent to the world
        if ($depFireFlag) {

            return undef;

        # Otherwise, if the modified $cmd begins with a forward slash, evaluate it like a Perl
        #   command and use the return value as a world command
        # (However, if the original command began with a forward slash, it's because the user
        #   typed something like ',,/create', meaning 'send everything after the ,, as a literal
        #   world command' - so don't try to execute a Perl command
        } elsif (substr ($cmd, 0, 1) eq '/' && substr($originalCmd, 0, 1) ne '/') {

            # Store data so that it's available to the Perl mini-programme
            $self->ivAdd('perlCmdDataHash', '_originalCmd', $originalCmd);
            # (Also store the interface which fired)
            $self->ivAdd('perlCmdDataHash', '_interface', undef);

            $returnValue = $self->perlCmd($cmd);

            # The stored data is no longer needed
            $self->ivAdd('perlCmdDataHash', '_originalCmd', undef);
            $self->ivAdd('perlCmdDataHash', '_interface', undef);

            return $returnValue;        # If 'undef', no command is sent to the world

        } else {

            # Return the original command (or the modified instruction, if any alias has fired)
            return $cmd;
        }
    }

    sub checkMacros {

        # Called by ->signal_connect in GA::Win::Internal->setKeyPressEvent
        # Checks every active macro interface. Fires every macro that should be fired in response to
        #   a keypress
        #
        # Expected arguments
        #   $keycode    - The standard keycode, e.g. 'f5' (or keycode string, e.g. 'ctrl shift f5')
        #                   representing the keypress
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns FALSE if no macros fire and TRUE if any macros fire

        my ($self, $keycode, $check) = @_;

        # Local variables
        my (
            $fireFlag,
            @deleteList,
            %hash,
        );

        # Check for improper arguments
        if (! defined $keycode || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkMacros', @_);
        }

        # Import the active macro registry for quick lookup
        %hash = $self->macroHash;

        # Check every active macro interface, in the correct order, to see if it is due to fire
        $fireFlag = FALSE;
        OUTER: foreach my $number ($self->macroOrderList) {

            my ($obj, $class, $method);

            if ($hash{$number} eq $keycode) {

                # The macro keycode matches. Check the interface number is valid
                if (! $self->ivExists('interfaceNumHash', $number)) {

                    $self->writeError(
                        'Invalid active macro interface #' . $number,
                        $self->_objClass . '->checkMacros',
                    );

                    # Avoid multiple error messages by stopping checking macros now
                    last OUTER;

                } else {

                    $obj = $self->ivShow('interfaceNumHash', $number);
                }

                # If the macro is disabled, don't fire it
                # If the macro is in the middle of its cooldown period, don't fire it
                if ($obj->enabledFlag && ! $self->ivExists('macroCooldownHash', $number)) {

                    $fireFlag = TRUE;

                    # Store the keycode so that it's available to a macro response that starts with
                    #   '/' (meaning it's a Perl mini-programme)
                    $self->ivAdd('perlCmdDataHash', '_keycode', $keycode);
                    # (Also store the interface which fired)
                    $self->ivAdd('perlCmdDataHash', '_interface', $number);

                    # For an independent macro, perform the instruction in ->response
                    if ($obj->indepFlag) {

                        $self->doInstruct($obj->response);

                    # For a dependent macro, make a function call
                    } else {

                        $class = $obj->callClass;
                        $method = $obj->callMethod;

                        $class->$method($self, $obj->number, $keycode);
                    }

                    # $keycode is no longer needed
                    $self->ivAdd('perlCmdDataHash', '_keycode', undef);
                    $self->ivAdd('perlCmdDataHash', '_interface', undef);

                    if ($obj->ivShow('attribHash', 'temporary')) {

                        # Mark a temporary active macro to be deleted
                        push (@deleteList, $obj);

                    } elsif ($obj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'macroCooldownHash',
                            $obj->number,
                            $self->sessionTime + $obj->ivShow('attribHash', 'cooldown'),
                        );
                    }
                }
            }
        }

        # Any temporary macros which fired can now be deleted
        foreach my $obj (@deleteList) {

            $self->removeInterface($obj);
        }

        return $fireFlag;
    }

    sub checkTimers {

        # Called by $self->spinTimerLoop
        # Checks every active timer interface. Calls $self->checkTimers for every timer that's due
        #   to be fired
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns FALSE if no timers fire and TRUE if any timers fire

        my ($self, $check) = @_;

        # Local variables
        my (
            $sessionTime, $clock, $clockMins, $clockFlag, $fireFlag,
            %numHash, %clockHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkTimers', @_);
        }

        # Import IVs for quick lookup
        $sessionTime = $self->sessionTime;
        %numHash = $self->timerNumHash;
        %clockHash = $self->timerClockHash;

        # Get the current clock time
        $clock = substr($axmud::CLIENT->localClock(), 0, 5);        # Clock time in form HH:MM
        $clockMins = '99:' . substr($clock, 3);                     # Hourly time in form 99:MM
        # Timers who stimulus is a clock time are checked once a minute. If it's time to check them
        #   again, set a flag
        if (! defined $self->timerLastClock || $self->timerLastClock ne $clock) {

            $clockFlag = TRUE;
            # The next check happens a minute from now (give or take)
            $self->ivPoke('timerLastClock', $clock);
        }

        # Check every active timer interface, in the correct order, to see if it is due to fire
        $fireFlag = FALSE;
        OUTER: foreach my $number ($self->timerOrderList) {

            my ($thisTime, $thisClock, $obj, $class, $method, $count, $minDelay, $randDelay);

            $thisTime = $numHash{$number};
            $thisClock = $clockHash{$number};

            if (defined $thisTime && $thisTime < $sessionTime) {

                # Timer whose stimulus is numeric

                # The timer is due to fire. Check the number is valid
                if (! $self->ivExists('interfaceNumHash', $number)) {

                    $self->writeError(
                        'Invalid active timer interface #' . $number,
                        $self->_objClass . '->checkTimers',
                    );

                    # Avoid multiple error messages by stopping checking timers now
                    last OUTER;

                } else {

                    $obj = $self->ivShow('interfaceNumHash', $number);
                }

                # If the timer is disabled, or if the timer is waiting for the character to log in,
                #   do nothing
                if ($obj->ivShow('attribHash', 'wait_login') && ! $self->loginFlag) {

                    # Don't check again for at least another second
                    $self->ivAdd('timerNumHash', $number, $self->sessionTime + 1);

                    next OUTER;
                }

                if ($obj->enabledFlag) {

                    $fireFlag = TRUE;

                    # Store the times so they're available to a timer response that starts with '/'
                    #   (meaning it's a Perl mini-programme)
                    $self->ivAdd('perlCmdDataHash', '_timerExpect', $thisTime);
                    $self->ivAdd('perlCmdDataHash', '_timerTime', $sessionTime);
                    # (Also store the interface which fired)
                    $self->ivAdd('perlCmdDataHash', '_interface', $number);

                    # For an independent timer, perform the instruction in ->response
                    if ($obj->indepFlag) {

                        $self->doInstruct($obj->response);

                    # For a dependent timer, make a function call
                    } else {

                        $class = $obj->callClass;
                        $method = $obj->callMethod;

                        $class->$method($self, $obj->number, $sessionTime, $thisTime);
                    }

                    # Set the timer's repeat count
                    $count = $obj->ivShow('attribHash', 'count');
                    if ($count == 1) {

                        # If the 'temporary' attribute is TRUE, delete the timer
                        if ($obj->ivShow('attribHash', 'temporary')) {

                            $self->removeInterface($obj);

                        # Otherwise, just disable it
                        } else {

                            $obj->modifyAttribs(
                                $self,
                                'enabled'   => FALSE,
                                # When reenabled, the repeat count will be 'repeat forever'
                                'count'     => -1,
                            );

                            # Set the time at which the timer will fire again (if it is not now
                            #   disabled) by replacing the entry in the active timer registry
                            $self->ivAdd(
                                'timerNumHash',
                                $obj->number,
                                ($sessionTime + $obj->stimulus),
                            );
                        }

                    } else {

                        # If the repeat count is not -1 (which means repeat forever), the number of
                        #   times the timer must fire should be decreased by 1
                        if ($count > -1) {

                            $count--;
                            $obj->modifyAttribs(
                                $self,
                                'count', $count,
                            );
                        }

                        # Set the time at which the timer will fire again by replacing the entry in
                        #   the active timer registry
                        if ($obj->ivShow('attribHash', 'random_delay')) {

                            # Get the minimum delay (may be 0)
                            $minDelay = $obj->ivShow('attribHash', 'random_min');
                            # Check: if the minimum random delay is greater than $obj->stimulus,
                            #   ignore it
                            if ($minDelay >= $obj->stimulus) {

                                $minDelay = 0;
                            }

                            $randDelay = rand($obj->stimulus - $minDelay) + $minDelay;
                            $self->ivAdd(
                                'timerNumHash',
                                $obj->number,
                                ($sessionTime + int($randDelay)),
                            );

                        } else {

                            $self->ivAdd(
                                'timerNumHash',
                                $obj->number,
                                ($sessionTime + $obj->stimulus),
                            );
                        }
                    }

                    # The times are no longer needed
                    $self->ivAdd('perlCmdDataHash', '_timerExpect', undef);
                    $self->ivAdd('perlCmdDataHash', '_timerTime', undef);
                    $self->ivAdd('perlCmdDataHash', '_interface', undef);
                }

            } elsif (
                $clockFlag
                && defined $thisClock && ($thisClock eq $clock || $thisClock eq $clockMins)
            ) {
                # Timer whose stimulus is a clock time in the form HH::MM or 99::MM

                # The timer is due to fire. Check the number is valid
                if (! $self->ivExists('interfaceNumHash', $number)) {

                    $self->writeError(
                        'Invalid active timer interface #' . $number,
                        $self->_objClass . '->checkTimers',
                    );

                    # Avoid multiple error messages by stopping checking timers now
                    last OUTER;

                } else {

                    $obj = $self->ivShow('interfaceNumHash', $number);
                }

                # If the timer is disabled, or if the timer is waiting for the character to log in,
                #   do nothing
                if ($obj->ivShow('attribHash', 'wait_login') && ! $self->loginFlag) {

                    # Don't check again for at least another second
                    $self->ivAdd('timerNumHash', $number, $self->sessionTime + 1);

                    next OUTER;
                }

                if ($obj->enabledFlag) {

                    $fireFlag = TRUE;

                    # Store the times so they're available to a timer response that starts with '/'
                    #   (meaning it's a Perl mini-programme)
                    $self->ivAdd('perlCmdDataHash', '_timerExpect', $thisClock);
                    $self->ivAdd('perlCmdDataHash', '_timerTime', $sessionTime);
                    # (Also store the interface which fired)
                    $self->ivAdd('perlCmdDataHash', '_interface', $number);

                    # For an independent timer, perform the instruction in ->response
                    if ($obj->indepFlag) {

                        $self->doInstruct($obj->response);

                    # For a dependent timer, make a function call
                    } else {

                        $class = $obj->callClass;
                        $method = $obj->callMethod;

                        $class->$method($self, $obj->number, $sessionTime, $thisClock);
                    }

                    # If the 'temporary' attribute is TRUE, delete the timer
                    if ($obj->ivShow('attribHash', 'temporary')) {

                        $self->removeInterface($obj);
                    }

                    # The times are no longer needed
                    $self->ivAdd('perlCmdDataHash', '_timerExpect', undef);
                    $self->ivAdd('perlCmdDataHash', '_timerTime', undef);
                    $self->ivAdd('perlCmdDataHash', '_interface', undef);
                }
            }
        }

        return $fireFlag;
    }

    sub checkHooks {

        # Called by various functions for various hook events
        #
        # There are two types of hook event: standard hook events, and custom hook events. All
        #   standard hook events are listed below
        # Custom hook events are of limited use, but are provided in case the user needs them. Its
        #   name must begin with an underline, followed by an alphanumeric character, e.g.
        #   '_myevent' (to prevent clashes with new standard hook events in future versions of
        #   Axmud). A custom hook event has no hook data
        #
        # List of standard hook events
        # NB If the list below is modified, GA::InterfaceModel::Hook->new and
        #   GA::Cmd::SimulateHook->do must be updated, too
        #
        #   HOOK EVENT          FUNCTIONS                   HOOK DATA
        #   connect             $self->connectionComplete   (none)
        #   disconnect          $self->reactDisconnect      (none)
        #   login               $self->doLogin              (none)
        #   prompt              $self->processPrompt        Line, stripped of escape sequences
        #   receive_text        $self->processLineSegment   Line, stripped of escape sequences
        #   sending_cmd         $self->worldCmd, ->teleportCmd
        #                                                   The command being processed
        #   send_cmd            $self->dispatchCmd          The command to be sent
        #   system_text         GA::Obj::TextView->showSystemText
        #                                                   The system message
        #   system_error        GA::Obj::TextView->showError
        #                                                   The system error message
        #   system_warning      GA::Obj::TextView->showWarning
        #                                                   The system warning message
        #   system_debug        GA::Obj::TextView->showDebug
        #                                                   The system debug message
        #   system_improper     GA::Obj::TextView->showImproper
        #                                                   The system improper arguments message
        #   system_all          GA::Obj::TextView->showText
        #                       GA::Obj::TextView->showError
        #                       GA::Obj::TextView->showWarning
        #                       GA::Obj::TextView->showDebug
        #                       GA::Obj::TextView->showImproper
        #                                                   The system text/error/warning/debug/
        #                                                       improper arguments message
        #   system_all_error    GA::Obj::TextView->showError
        #                       GA::Obj::TextView->showWarning
        #                       GA::Obj::TextView->showDebug
        #                       GA::Obj::TextView->showImproper
        #                                                   The system error/warning/debug/improper
        #                                                       arguments message
        #   msdp                $self->processMsdpData      The variable/value pair received
        #   mssp                $self->processMsspData      The variable/value pair received
        #   zmp                 $self->processZmpData       The ZMP package/name command as a string
        #   aard102             $self->processAard102Data   The AARD102 status or the string 'tick'
        #   atcp                $self->processAtcpData      The ATCP package name
        #   gmcp                $self->processGmcpData      The GMCP package name
        #   mcp                 $self->processMcpMsg        The MCP message name as a string)
        #   current_session     GA::Client->setCurrentSession
        #                                                   (none)
        #   not_current         GA::Client->setCurrentSession
        #                                                   The new current session's ->number
        #   change_current      GA::Client->setCurrentSession
        #                                                   The new current session's ->number
        #   visible_session     GA::Win::Internal->setVisibleSession
        #                                                   (none)
        #   not_visible         GA::Win::Internal->setVisibleSession
        #                                                   The new visible session's ->number
        #   change_visible      GA::Win::Internal->setVisibleSession
        #                                                   The new visible session's ->number
        #   textview_resize     $self->textViewSizeUpdate   The resized textview object's ->number
        #   user_idle           $self->spinTimerLoop        $self->lastCmdTime
        #   world_idle          $self->spinTimerLoop        $self->lastDisplayTime
        #   get_focus           GA::Win::Internal->setFocusInEvent
        #                                                   (none)
        #   lose_focus          GA::Win::Internal->setFocusOutEvent
        #                                                   (none)
        #   close_disconnect    GA::Client->stop            (none)
        #   map_room            GA::Obj::Map->setCurrentRoom
        #                                                   The automapper's current room number
        #   map_no_room         GA::Obj::Map->setCurrentRoom
        #                                                   The number of the last current room, or
        #                                                       0 if last current room not set
        #                                                       (does fire when Locator resets)
        #   map_lost            GA::Obj::Map->setCurrentRoom
        #                                                   The number of the last current room, or
        #                                                       0 if last current room not set
        #                                                       (doesn't fire when Locator resets)
        #   map_rescue_on       GA::Obj::Map->setCurrentRoom
        #                                                   The temporary region name
        #   map_rescue_merge    GA::Obj::Map->reset_rescueCheckFlag
        #                                                   (none)
        #   map_rescue_off      GA::Obj::Map->reset_rescueRegion
        #                                                   (none)
        #
        # Checks every active hook interface. Calls $self->checkHooks for every hook that has
        #   fired in response to a specified hook event
        #
        # Expected arguments
        #   $event      - The hook event that has taken place (matches a key in
        #                   GA::InterfaceModel::Hook->hookEventHash)]
        #
        # Optional arguments
        #   $hookVar    - Some hook events (such as 'receive_text') need to pass on some data
        #                   concerning the event (in this example, the text received). If so,
        #                   it is stored in $hookVar; otherwise set to 'undef'
        #   $hookVal    - Some hook events ('msdp' and 'mssp') need to pass on a variable-value
        #                   pair. If so, they are stored in $hookVar and $hookVal; otherwise
        #                   $hookVal is set to 'undef'
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns FALSE if no hooks fire and TRUE if any hooks fire

        my ($self, $event, $hookVar, $hookVal, $check) = @_;

        # Local variables
        my (
            $fireFlag,
            @deleteList,
            %hash,
        );

        # Check for improper arguments
        if (! defined $event || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkHooks', @_);
        }

        # Import the active hook registry for quick lookup
        %hash = $self->hookHash;

        # Check every active hook interface, in the correct order, to see if responds to this hook
        #   $event
        $fireFlag = FALSE;
        OUTER: foreach my $number ($self->hookOrderList) {

            my ($obj, $class, $method);

            if ($hash{$number} eq $event) {

                # The hook event matches. Check the number is valid
                if (! $self->ivExists('interfaceNumHash', $number)) {

                    $self->writeError(
                        'Invalid active hook interface #' . $number,
                        $self->_objClass . '->checkHooks',
                    );

                    # Avoid multiple error messages by stopping checking macros now
                    last OUTER;

                } else {

                    $obj = $self->ivShow('interfaceNumHash', $number);
                }

                # If the hook is disabled, don't fire it
                # If the hook is in the middle of its cooldown period, don't fire it
                if ($obj->enabledFlag && ! $self->ivExists('hookCooldownHash', $number)) {

                    $fireFlag = TRUE;

                    # If hook data was specified, store it so that it's available to a hook response
                    #   that starts with '/' (meaning it's a Perl mini-programme)
                    if ($hookVar) {

                        $self->ivAdd('perlCmdDataHash', '_hookVar', $hookVar);
                    }

                    if ($hookVal) {

                        $self->ivAdd('perlCmdDataHash', '_hookVal', $hookVal);
                    }

                    # Also store the hook event
                    $self->ivAdd('perlCmdDataHash', '_hookEvent', $obj->stimulus);
                    # (Also store the interface which fired)
                    $self->ivAdd('perlCmdDataHash', '_interface', $number);

                    # For an independent hook, perform the instruction in ->response
                    if ($obj->indepFlag) {

                        $self->doInstruct($obj->response);

                    # For a dependent hook, make a function call
                    } else {

                        $class = $obj->callClass;
                        $method = $obj->callMethod;

                        $class->$method($self, $obj->number, $obj->stimulus, $hookVar, $hookVal);
                    }

                    # If hook data was specified, it's no longer needed
                    if ($hookVar) {

                        $self->ivAdd('perlCmdDataHash', '_hookVar', undef);
                    }

                    if ($hookVal) {

                        $self->ivAdd('perlCmdDataHash', '_hookVal', undef);
                    }

                    $self->ivAdd('perlCmdDataHash', '_hookEvent', undef);
                    $self->ivAdd('perlCmdDataHash', '_interface', undef);

                    if ($obj->ivShow('attribHash', 'temporary')) {

                        # Mark a temporary active hook to be deleted
                        push (@deleteList, $obj);

                    } elsif ($obj->ivShow('attribHash', 'cooldown')) {

                        # Apply the cooldown period
                        $self->ivAdd(
                            'hookCooldownHash',
                            $obj->number,
                            $self->sessionTime + $obj->ivShow('attribHash', 'cooldown'),
                        );
                    }
                }
            }
        }

        # Any temporary hooks which fired can now be deleted
        foreach my $obj (@deleteList) {

            $self->removeInterface($obj);
        }

        return $fireFlag;
    }

    # Cages

    sub createCages {

        # Called by $self->setupProfiles
        # Also called by GA::Cmd::SetupWorld->do (etc), AddGuild->do (etc), CloneGuild->do (etc),
        #   SetupCustomProfile->do, AddCustomProfile->do, CloneCustomProfile->do
        #
        # When a new profile is created, this function creates all the associated cage objects
        # If the new profile is a current profile, marks the new objects as the current cage
        #
        # Expected arguments
        #   $profObj        - blessed reference of the new profile
        #
        # Optional arguments
        #   $currentFlag    - TRUE if this profile will be set as the current profile, FALSE (or
        #                       'undef') if not
        #
        # Return values
        #   'undef' on improper arguments
        #   1 upon success

        my ($self, $profObj, $currentFlag, $check) = @_;

        # Local variables
        my ($profName, $profCategory);

        # Check for improper arguments
        if (! defined $profObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createCages', @_);
        }

        # Import IVs
        $profName = $profObj->name;
        $profCategory = $profObj->category;

        # Create one new cage for each cage type
        foreach my $type ($axmud::CLIENT->cageTypeList) {

            my ($package, $obj);

            if ($axmud::CLIENT->ivExists('pluginCagePackageHash', $type)) {

                # Cage added by a plugin
                $package = $axmud::CLIENT->ivShow('pluginCagePackageHash', $type);

            } else {

                # Built-in cage
                $package = 'Games::Axmud::Cage::' . ucfirst($type);
            }

            $obj = $package->new($self, $profName, $profCategory);
            if (! $obj) {

                $self->writeWarning(
                    'Failed to create the \'' . $package . '\' cage for the \'' . $profName
                    . '\' profile',
                    $self->_objClass . '->createCages',
                );

            } else {

                # Use the 'set' accessor rather than ->ivAdd so that other sessions using the same
                #   world profile are updated, too
                $self->add_cage($obj);
            }
        }

        # If this is a current profile, and there were no errors, mark the new objects as current
        #   cages and set the inferior cage for all cages (those not belonging to a current profile
        #   have their inferior cage set to 'undef')
        if ($currentFlag) {

            $self->setCurrentCages($profName, $profCategory);
            $self->setCageInferiors();

            # Create new interfaces for this profile
            $self->setProfileInterfaces($profObj->name)
        }

        return 1;
    }

    sub updateCages {

        # Called by $self->setupProfiles, GA::Client->addPluginCages are by code in any plugin
        # If the user writes a plugin which adds new cages, existing profiles each need to have one
        #   of these cages created for it
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $silenceFlag    - If set to TRUE, doesn't display a message for each cage created
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $silenceFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateCages', @_);
        }

        foreach my $profObj ($self->ivValues('profHash')) {

            # Check every type of cage. If a cage associated with the profile doesn't exist, create
            #   it
            foreach my $type ($axmud::CLIENT->cageTypeList) {

                my ($uniqueName, $package, $obj);

                $uniqueName = lc($type) . '_' . $profObj->category . '_' . $profObj->name;

                if (! $self->ivExists('cageHash', $uniqueName)) {

                    # Cage doesn't already exist, so create it
                    if ($axmud::CLIENT->ivExists('pluginCagePackageHash', $type)) {

                        # Cage added by a plugin
                        $package = $axmud::CLIENT->ivShow('pluginCagePackageHash', $type);

                    } else {

                        # Built-in cage
                        $package = 'Games::Axmud::Cage::' . ucfirst($type);
                    }

                    $obj = $package->new($self, $profObj->name, $profObj->category);
                    if (! $obj) {

                        # Show a warning message, even if $silenceFlag is TRUE
                        $self->writeWarning(
                            'Failed to create the \'' . $package . '\' cage for the \''
                            . $profObj->name . '\' profile',
                            $self->_objClass . '->updateCages',
                        );

                    } else {

                        # Add the new cage to this session's registries
                        # Use the 'set' accessor rathern than ->ivAdd so that other sessions using
                        #   the same world profile are updated, too
                        $self->add_cage($obj);

                        if (! $silenceFlag) {

                            $self->writeText('Created cage \'' . $uniqueName . '\'');
                        }
                    }
                }
            }
        }

        # Set current cages for current profiles
        foreach my $profObj ($self->ivValues('currentProfHash')) {

            $self->setCurrentCages($profObj->name, $profObj->category);
        }

        # Set all the cage inferiors
        $self->setCageInferiors();

        # Set correct active interfaces for every current profile
        $self->setProfileInterfaces();

        return 1;
    }

    sub deleteCages {

        # Called by GA::Cmd::DeleteCage->do
        # Users can writes plugins which add new types of cages. $self->updateCages is called to
        #   make sure all profiles have one of these new cages
        # If the user decides to stop using a plugin, or if the user wants to delete one of the
        #   types of cage specified by a plugin, this function is called
        # All existing profiles have their copies of the specified cage type destroyed. If no type
        #   is specified, all cages of unrecognised types are destroyed
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $type           - The cage type to destroy (matches a key in
        #                       GA::Client->pluginCageHash). If 'undef', all cages of unrecognised
        #                       types are destroyed
        #   $silenceFlag    - If set to TRUE, doesn't display a message for each cage destroyed
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the number of cages destroyed (may be 0)

        my ($self, $type, $silenceFlag, $check) = @_;

        # Local variables
        my $count;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->deleteCages', @_);
        }

        # Now go through the entire cage registry, removing any cages of an unrecognised type (i.e.
        #   remove all 'mycage' cages, if the user has removed 'mycage' from
        #   GA::Client->pluginCageHash)
        $count = 0;
        foreach my $cageName ($self->ivKeys('cageHash')) {

            my (
                $thisType,
                @list,
            );

            # The cage name is in the form cageType_profCategory_profName. We're only interested in
            #   the cage type
            @list = split(m/_/, $cageName);

            if (
                (defined $type && $list[0] eq $type)
                || (
                    ! defined $type
                    && ! defined $axmud::CLIENT->ivFind('cageTypeList', $list[0])
                )
            ) {
                # Use the 'set' accessor rathern than ->ivDelete so that other sessions using the
                #   same world profile are updated, too
                $self->del_cage($cageName);
                $count++;

                if (! $silenceFlag) {

                    $self->writeText('Deleted cage \'' . $cageName . '\'');
                }
            }
        }

        # Set current cages for current profiles
        foreach my $profObj ($self->ivValues('currentProfHash')) {

            $self->setCurrentCages($profObj->name, $profObj->category);
        }

        # Set all the cage inferiors
        $self->setCageInferiors();

        # Set correct active interfaces for every current profile
        $self->setProfileInterfaces();

        return $count;
    }

    sub setCurrentCages {

        # Called by $self->createCages
        # Also called by GA::SetRace->do, SetGuild->do, SetChar->do and SetCustomProfile->do
        # Marks a set of cages associated with a profile as the current cages for this category of
        #   profile
        #
        # Expected arguments
        #   $profName       - Profile's unique name
        #   $profCategory   - Profile's category, e.g. 'world', 'race', 'faction'
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (! defined $profName || ! defined $profCategory || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setCurrentCages', @_);
        }

        foreach my $type ($axmud::CLIENT->cageTypeList) {

            my $uniqueName = lc($type) . '_' . $profCategory . '_' . $profName;
            if ($self->ivExists('cageHash', $uniqueName)) {

                $self->ivAdd(
                    'currentCageHash',
                    $uniqueName,
                    $self->ivShow('cageHash', $uniqueName),
                );
            }
        }

        return 1;
    }

    sub unsetCurrentCages {

        # Called by GA::Cmd::UnsetGuild->do (etc) and UnsetCustomProfile->do
        # When a current profile (that's not a world profile) is unset - as opposed to being
        #   deleted - its cages are not destroyed (as they are by $self->destroyCages), but merely
        #   marked as being no longer current
        #
        # Expected arguments
        #   $profName       - Profile's unique name
        #   $profCategory   - Profile's category, e.g. 'guild', 'race', 'faction' (but not
        #                       'world')
        #
        # Return values
        #   'undef' on improper arguments, or if the specified profile is a world profile
        #   1 otherwise

        my ($self, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (! defined $profName || ! defined $profCategory || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->unsetCurrentCages', @_);
        }

        # World profiles not allowed in this function
        if ($profCategory eq 'world') {

            return $self->writeError(
                'World profile specified, but can\'t be processed',
                $self->_objClass . '->unsetCurrentCages',
            );
        }

        # Unset current cages
        foreach my $type ($axmud::CLIENT->cageTypeList) {

            my $uniqueName = lc($type) . '_' . $profCategory . '_' . $profName;
            if ($self->ivExists('currentCageHash', $uniqueName)) {

                $self->ivDelete('currentCageHash', $uniqueName);
            }
        }

        return 1;
    }

    sub cloneCages {

        # Called by GA::Cmd::CloneWorld->do (etc) and GA::Generic::Cmd->cloneProfile
        #
        # When a profile is cloned with one of the above commands, this function clones all the
        #   related cage objects
        #
        # Expected arguments
        #   $profObj        - blessed reference to the original profile
        #   $clonedProfObj  - blessed reference to the cloned profile
        #
        # Optional arguments
        #   $cageHashRef   - When called by GA::Cmd::CloneWorld->do, the contents of this session's
        #                       ->cageHash IV, which has temporarily been emptied. Otherwise set to
        #                       'undef'
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of blessed references to cloned cages

        my ($self, $profObj, $clonedProfObj, $cageHashRef, $check) = @_;

        # Local variables
        my (
            $profCategory,
            @returnArray,
            %cageHash,
        );

        # Check for improper arguments
        if (! defined $profObj || ! defined $clonedProfObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->cloneCages', @_);
        }

        # Import IVs
        $profCategory = $profObj->category;
        if (defined $cageHashRef) {
            %cageHash = %$cageHashRef;
        } else {
            %cageHash = $self->cageHash;
        }

        # Clone cages
        foreach my $type ($axmud::CLIENT->cageTypeList) {

            my ($uniqueName, $obj, $clonedObj);

            $uniqueName = lc($type) . '_' . $profCategory . '_' . $profObj->name;

            $obj = $cageHash{$uniqueName};
            if ($obj) {

                $clonedObj = $obj->clone($self, $clonedProfObj->name, $clonedProfObj->category);
            }

            if (! $obj || ! $clonedObj) {

                $self->writeWarning(
                    'Failed to clone the \'' . $uniqueName . '\' cage',
                    $self->_objClass . '->cloneCages',
                );

            } else {

                push (@returnArray, $clonedObj);
            }
        }

        return @returnArray;
    }

    sub destroyCages {

        # Called by $self->setupProfiles
        # Called by GA::Cmd::SetGuild->do (etc) and AddGuild->do (etc)
        # Called by GA::Cmd::SetCustomProfile->do and AddCustomProfile->do
        #
        # Deletes all cages associated with a particular profile (but not the profile itself)
        #
        # Expected arguments
        #   $profObj        - blessed reference to the profile
        #
        # Optional arguments
        #   $currentFlag    - TRUE if this profile is a current profile, FALSE (or 'undef') if not
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $profObj, $currentFlag, $check) = @_;

        # Local variables
        my ($profName, $profCategory);

        # Check for improper arguments
        if (! defined $profObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->destroyCages', @_);
        }

        # Import IVs
        $profName = $profObj->name;
        $profCategory = $profObj->category;

        # Destroy cages
        foreach my $type ($axmud::CLIENT->cageTypeList) {

            my $uniqueName = lc($type) . '_' . $profCategory . '_' . $profName;

            if ($self->ivExists('cageHash', $uniqueName)) {

                # Use the 'set' accessor rathern than ->ivDelete so that other sessions using the
                #   same world profile are updated, too
                $self->del_cage($uniqueName, $self->ivShow('cageHash', $uniqueName));
            }
        }

        if ($currentFlag) {

            # All remaining cages must have their inferior cages re-set
            $self->setCageInferiors();
        }

        return 1;
    }

    sub setCageInferiors {

        # Called by $self->createCages and $self->destroyCages
        # Also called by GA::Cmd::SetupRace->do, SetupGuild->do, SetupChar->do and
        #   SetupCustomProfile->do
        # Every time a profile is set to the current profile, every time a current profile is
        #   destroyed, and every time the profile priority list changes, all cages must have their
        #   inferior cages reset (which involves re-building $self->inferiorCageHash from scratch)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my %newHash;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setCageInferiors', @_);
        }

        # Empty this session's hash of inferior cages
        $self->ivEmpty('inferiorCageHash');

        # Rebuild the hash
        OUTER: foreach my $category ($self->profPriorityList) {

            # Check each possible inferior in turn...
            MIDDLE: foreach my $inferiorCategory ($self->findInferiorList($category)) {

                if (
                    $self->ivExists('currentProfHash', $category)
                    && $self->ivExists('currentProfHash', $inferiorCategory)
                ) {
                    INNER: foreach my $cageType ($axmud::CLIENT->cageTypeList) {

                        my ($cageName, $inferiorCageName);

                        $cageName = lc($cageType) . '_' . $category . '_'
                            . $self->ivShow('currentProfHash', $category)->name;
                        $inferiorCageName = lc($cageType) . '_' . $inferiorCategory
                            . '_' . $self->ivShow('currentProfHash', $inferiorCategory)->name;

                        # If the inferior has been created, mark it as inferior
                        if (
                            $self->ivExists('cageHash', $cageName)
                            && $self->ivExists('cageHash', $inferiorCageName)
                        ) {
                            $newHash{$cageName} = $self->ivShow('cageHash', $inferiorCageName);
                        }
                    }

                    next OUTER;
                }
            }
        }

        $self->ivPoke('inferiorCageHash', %newHash);

        return 1;
    }

    sub findCage {

        # Can be called by anything
        # Finds the cage of a specified type, associated with a specified profile
        #
        # Expected arguments
        #   $type       - one of the cage types in GA::Client->cageTypeList (e.g. 'Cmd'); $type is
        #                   treated as case-insensitive (so 'cmd' is ok)
        #   $profName   - the name of the associate profile
        #
        # Return values
        #   'undef' on improper arguments, if the cage $type doesn't exist, if the profile $profName
        #       doesn't exist, or if (for some reason) a cage associated with it doesn't exist
        #   Otherwise returns the blessed reference of the cage

        my ($self, $type, $profName, $check) = @_;

        # Local variables
        my ($match, $profile, $uniqueName);

        # Check for improper arguments
        if (! defined $type || ! defined $profName || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findCage', @_);
        }

        # Check that $type is a recognised cage type; at the same time, if $type is in lower-case,
        #   change it (e.g. convert 'cmd' to 'Cmd')
        OUTER: foreach my $item ($axmud::CLIENT->cageTypeList) {

            if (lc ($item) eq lc ($type)) {

                $match = $item;
                last OUTER;
            }
        }

        if (! $match) {

            # $type isn't a recognised cage type
            return undef;

        } else {

            $type = $match;     # Used the capitalised form
        }

        # Check that the profile $profName exists
        if (! $self->ivExists('profHash', $profName)) {

            # Doesn't exist
            return undef;

        } else {

            $profile = $self->ivShow('profHash', $profName);
        }

        # Check that the cage exists
        $uniqueName = lc($type) . '_' . $profile->category . '_' . $profName;
        if (! $self->ivExists('cageHash', $uniqueName)) {

            # Cage matching $type and $profName doesn't exist
            return undef;

        } else {

            # Matching cage exists
            return $self->ivShow('cageHash', $uniqueName)
        }
    }

    sub findCurrentCage {

        # Can be called by anything
        # Finds the cage of a specified type, associated with the current profile of a specified
        #   category
        #
        # Expected arguments
        #   $type       - one of the cage types in GA::Client->cageTypeList (e.g. 'Cmd'); $type is
        #                   treated as case-insensitive (so 'cmd' is ok)
        #   $category   - A category of profile, e.g. 'world', 'guild'
        #
        # Return values
        #   'undef' on improper arguments, if the cage $type doesn't exist, if the profile $category
        #       doesn't exist or if (for some reason) there isn't a current profile of that
        #       category, or if there isn't a current cage matching them
        #   Otherwise returns the blessed reference of the current cage

        my ($self, $type, $category, $check) = @_;

        # Local variables
        my ($match, $profile, $uniqueName);

        # Check for improper arguments
        if (! defined $type || ! defined $category || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findCurrentCage', @_);
        }

        # Check that $type is a recognised cage type; at the same time, if $type is in lower-case,
        #   change it (e.g. convert 'cmd' to 'Cmd')
        OUTER: foreach my $item ($axmud::CLIENT->cageTypeList) {

            if (lc ($item) eq lc ($type)) {

                $match = $item;
                last OUTER;
            }
        }

        if (! $match) {

            # $type isn't a recognised cage type
            return undef;

        } else {

            $type = $match;     # Used the capitalised form
        }

        # Check that the profile $category exists and that there's a current profile of that
        #   category
        if (! $self->ivExists('currentProfHash', $category)) {

            # Doesn't exist
            return undef;

        } else {

            $profile = $self->ivShow('currentProfHash', $category);
        }

        # Check that the cage exists
        $uniqueName = lc($type) . '_' . $category . '_' . $profile->name;
        if (! $self->ivExists('cageHash', $uniqueName)) {

            # Cage matching $type and $profName doesn't exist
            return undef;

        } else {

            # Matching cage exists
            return $self->ivShow('cageHash', $uniqueName)
        }
    }

    sub findHighestCage {

        # Can be called by anything
        # Finds the highest-priority existing cage of a given type
        #
        # Expected arguments
        #   $type       - One of the cage types in GA::Client->cageTypeList (e.g. 'Cmd'); $type is
        #                   treated as case-insensitive (so 'cmd' is ok)
        #
        # Return values
        #   'undef' on improper arguments, if $type is an unrecognised cage type or if no cage of
        #       that type is found matching current profiles in the profile priority list
        #   Otherwise returns the highest-priority cage of that type

        my ($self, $type, $check) = @_;

        # Local variables
        my $match;

        # Check for improper arguments
        if (! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findHighestCage', @_);
        }

        # Check that $type is a recognised cage type; at the same time, if $type is in lower-case,
        #   change it (e.g. convert 'cmd' to 'Cmd')
        OUTER: foreach my $item ($axmud::CLIENT->cageTypeList) {

            if (lc ($item) eq lc ($type)) {

                $match = $item;
                last OUTER;
            }
        }

        if (! $match) {

            # Unrecognised cage type
            return undef;
        }

        # Get the highest priority cage of this type
        OUTER: foreach my $category ($self->profPriorityList) {

            my ($profile, $uniqueName, $cage);

            # Check that the profile $category exists and that there's a current profile of that
            #   category
            if (! $self->ivExists('currentProfHash', $category)) {

                # Doesn't exist
                next OUTER;

            } else {

                $profile = $self->ivShow('currentProfHash', $category);
            }

            # Check that the cage exists
            $uniqueName = lc($type) . '_' . $category . '_' . $profile->name;
            if (! $self->ivExists('cageHash', $uniqueName)) {

                # Cage matching $type and $profName doesn't exist
                next OUTER;

            } else {

                # Matching cage exists - this is the highest-priority cage for which we're looking
                return $self->ivShow('cageHash', $uniqueName)
            }
        }

        # No cage found
        return undef;
    }

    sub findCmd {

        # Can be called by anything
        # Given a standard command (a key in GA::Cage::Cmd->cmdHash), finds the replacement command
        #   from the highest-priority cage
        # e.g. If there's a replacement command defined for the standard command 'kill' in the cages
        #   associated with guild and race profiles, returns the replacement command from the race
        #   profile's cage (assuming that races still have priority over guilds)
        #
        # Expected arguments
        #   $cmd        - A standard command matching a key in $self->cmdHash
        #
        # Return values
        #   'undef' on improper arguments, if $cmd doesn't seem to exist in $self->cmdHash, if no
        #       command cage can be found or if no replacement command can be found
        #   Otherwise, returns the replacement command from the highest-priority cage

        my ($self, $cmd, $check) = @_;

        # Local variables
        my $cage;

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findCmd', @_);
        }

        # Get the highest priority command cage
        OUTER: foreach my $category ($self->profPriorityList) {

            $cage = $self->findCurrentCage('cmd', $category);
            if ($cage) {

                # Use the first available cage
                last OUTER;
            }
        }

        if (! $cage) {

            # No command cage available
            return undef;
        }

        # Check that the specified command exists in this cage's command hash
        if (! $cage->ivExists('cmdHash', $cmd)) {

            return undef;

        } else {

            # Return the highest-priority replacement command (consult inferior cage, if
            #   necessary)
            return $cage->ivShow('cmdHash', $cmd, $self);
        }
    }

    sub findWord {

        # Can be called by anything, using any command cage (e.g. make a direct call to
        #   GA::Cage::Cmd->findWord)
        # Given a standard word (a key in $self->wordHash), finds the replacement word from
        #   the highest-priority cage
        # e.g. If there's a replacement word defined for the standard word 'all' in the cages
        #   associated with guild and race profiles, returns the replacement word from the race
        #   profile's cage (assuming that races still have priority over guilds)
        #
        # Expected arguments
        #   $word       - A standard command matching a key in $self->wordHash
        #
        # Return values
        #   'undef' on improper arguments, if $word doesn't seem to exist in $self->wordHash, if no
        #       command cage can be found or if no replacement word can be found
        #   Otherwise, returns the replacement command from the highest-priority cage

        my ($self, $word, $check) = @_;

        # Local variables
        my $cage;

        # Check for improper arguments
        if (! defined $word || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findWord', @_);
        }

        # Get the highest priority command cage
        OUTER: foreach my $category ($self->profPriorityList) {

            $cage = $self->findCurrentCage('cmd', $category);
            if ($cage) {

                # Use the first available cage
                last OUTER;
            }
        }

        if (! $cage) {

            # No command cages available
            return undef;
        }

        # Check that the specified word exists in this cage's word hash
        if (! $cage->ivExists('wordHash', $word)) {

            return undef;

        } else {

            # Return the highest-priority replacement command (consult inferior cage, if necessary)
            return $cage->ivShow('wordHash', $word, $self);
        }
    }

    # Windows

    sub getHostLabelText {

        # Called by $self->start, ->doConnect, ->reactDisconnect and ->connectionComplete (and by
        #   GA::Win::Interal->setVisibleSession, when the window's visible session changes)
        # Prepares the text displayed in the 'main' window's connection info host label (it's the
        #   calling function's duty to tell the 'main' window to actually display it)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the text to display

        my ($self, $check) = @_;

        # Local variables
        my $labelText;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getHostLabelText', @_);
        }

        # Default label text is empty - in case something calls this function while the
        #   current connection status is 'waiting' or 'connecting'
        $labelText = '';

        if ($self->status eq 'connecting') {

            if ($self->mxpRelocateMode ne 'none') {

                $labelText = 'Relocating (via ' . $self->protocol . ') to \''
                                . $self->mxpRelocateHost . ' ' . $self->mxpRelocatePort . '\'...';

            } else {

                $labelText = 'Connecting (via ' . $self->protocol . ') to \'' . $self->initHost
                                . ' ' . $self->initPort . '\'...';
            }

        } elsif ($self->status eq 'connected') {

            $labelText = 'Connected (via ' . $self->protocol . ') to \'' . $self->initHost
                            . ' ' . $self->initPort . '\'';

        } elsif ($self->status eq 'offline') {

            $labelText = 'Connected (in offline mode) to \'' . $self->initHost . ' '
                            . $self->initPort . '\'';

        } elsif ($self->status eq 'disconnected') {

            $labelText = 'Disconnected at ' . $self->disconnectTime;
        }

        if ($labelText) {

            # Not too near the edge of the window, please
            $labelText .= ' ';
        }

        return $labelText;
    }

    sub getTimeLabelText {

        # Called by GA::Client->spinClientLoop (regularly), GA::Win::Internal->setVisibleSession
        #   and several other functions when required
        # Prepares the text displayed in a GA::Strip::ConnectInfo object's time label (it's the
        #   calling function's duty to tell the strip object to actually display the text)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the text to display

        my ($self, $check) = @_;

        # Local variables
        my $labelText;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getTimeLabelText', @_);
        }

        # For the benefit of the client loop, set an IV every time this function is called
        $self->ivPoke('connectInfoCheckTime', int($self->sessionTime));

        # Default label text is empty - in case something calls this function while the
        #   current connection status is 'waiting' or 'connecting'
        $labelText = '';

        if ($self->status eq 'connected') {

            # The call to ->getCounter converts a fractional number into a nice string like
            #   '4h 53m 07s'
            $labelText .= 'C ' . $axmud::CLIENT->getCounter($self->sessionTime);

            if (defined $self->delayedQuitTime) {

                $labelText .= ' | Q '
                        . $axmud::CLIENT->getCounter($self->delayedQuitTime - $self->sessionTime);
            }

            if (defined $self->lastDisplayTime) {

                $labelText .= ' | W '
                        . $axmud::CLIENT->getCounter($self->sessionTime - $self->lastDisplayTime);
            }

            if (defined $self->lastCmdTime) {

                $labelText .= ' | U '
                        . $axmud::CLIENT->getCounter($self->sessionTime - $self->lastCmdTime);
            }

        } elsif ($self->status eq 'offline') {

            $labelText = 'Session: ' . $axmud::CLIENT->getCounter($self->sessionTime);

        } elsif ($self->status eq 'disconnected') {

            # (If disconnected immediately, $self->sessionTime might be 'undef')
            if ($self->sessionTime) {
                $labelText = 'Connection length: ' . $axmud::CLIENT->getCounter($self->sessionTime);
            } else {
                $labelText = 'Connection length: n/a';
            }
        }

        if ($labelText) {

            # Not too near the strip object's blinkers, please
            $labelText .= '   ';
        }

        return $labelText;
    }

    # Session loop

    sub startSessionLoop {

        # Called by $self->start
        # Starts the session loop, which calls $self->spinSessionLoop whenever the loop spins
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the loop can't be started
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $loopObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startSessionLoop', @_);
        }

        # Create the object that handles the loop
        $loopObj = Games::Axmud::Obj::Loop->new(
            $self,
            'spinSessionLoop',
            'session',
        );

        if (! $loopObj) {

            return undef;

        } else {

            $self->ivPoke('sessionLoopObj', $loopObj);
        }

        # Start the loop
        if (! $loopObj->startLoop($self->sessionLoopDelay)) {

            return undef;
        }

        # Start some subservient loops (the replay loop is started by GA::Cmd::ReplayBuffer->do,
        #   when required)
        $self->startMaintainLoop();
        $self->startTimerLoop();
        $self->startIncomingLoop();
        $self->startTaskLoop();

        return 1;
    }

    sub stopSessionLoop {

        # Called by $self->stop
        # Stops the session loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the loop isn't running or if it can't be stopped
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $result;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopSessionLoop', @_);
        }

        if (! $self->sessionLoopObj) {

            return undef;
        }

        # Stop subservient loops
        $self->stopMaintainLoop();
        $self->stopTimerLoop();
        $self->stopIncomingLoop();
        $self->stopTaskLoop();
        $self->stopReplayLoop();

        # Stop the session loop
        $result = $self->sessionLoopObj->stopLoop();

        # Update IVs
        $self->ivUndef('sessionLoopObj');
        $self->ivPoke('sessionLoopSpinFlag', FALSE);
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return $result;
    }

    sub spinSessionLoop {

        # Called by $self->sessionLoopObj->spinLoop when the session loop spins
        # Sets the session time and spins all subservient loops
        #
        # Expected arguments
        #   $loopObj    - The GA::Obj::Loop object handling the session loop
        #
        # Optional arguments
        #   $spinType   - Any calling function which wants an extra spin of a single subservient
        #                   loop should call this function, not $self->spinMaintainLoop, etc (for
        #                   example, $self->doLogin spins the task loop just before sending world
        #                   commands). If defined, should be one of the strings 'maintain', 'timer',
        #                   'incoming', 'task' or 'replay'
        #
        # Return values
        #   'undef' on improper arguments, if the session loop isn't running or if $loopObj is the
        #       wrong loop object
        #   1 otherwise

        my ($self, $loopObj, $spinType, $check) = @_;

        # Check for improper arguments
        if (! defined $loopObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinSessionLoop', @_);
        }

        if (
            ! $self->sessionLoopObj
            || $self->sessionLoopObj ne $loopObj
            # ($self->sessionLoopSpinFlag has already been checked by the calling function)
            || $self->childLoopSpinFlag
            || $axmud::CLIENT->suspendSessionLoopFlag
        ) {
            return undef;
        }

        # Update IVs
        $self->ivPoke('sessionTime', $loopObj->spinTime);

        # Spin subservient loops, if they are running
        if (
            defined $self->maintainLoopCheckTime
            && (
                (! $spinType && $self->maintainLoopCheckTime < $self->sessionTime)
                || ($spinType && $spinType eq 'maintain')
            )
        ) {
            if ($self->spinMaintainLoop()) {

                $self->ivPoke(
                    'maintainLoopCheckTime',
                    $self->sessionTime + $self->maintainLoopDelay,
                )
            }
        }

        if (
            defined $self->timerLoopCheckTime
            && (
                (! $spinType && $self->timerLoopCheckTime < $self->sessionTime)
                || ($spinType && $spinType eq 'timer')
            )
        ) {
            if ($self->spinTimerLoop()) {

                $self->ivPoke(
                    'timerLoopCheckTime',
                    $self->sessionTime + $self->timerLoopDelay,
                )
            }
        }

        if (
            defined $self->incomingLoopCheckTime
            && (
                (! $spinType && $self->incomingLoopCheckTime < $self->sessionTime)
                || ($spinType && $spinType eq 'incoming')
            )
        ) {
            if ($self->spinIncomingLoop()) {

                $self->ivPoke(
                    'incomingLoopCheckTime',
                    $self->sessionTime + $self->incomingLoopDelay,
                )
            }
        }

        if (
            defined $self->taskLoopCheckTime
            && (
                (! $spinType && $self->taskLoopCheckTime < $self->sessionTime)
                || ($spinType && $spinType eq 'task')
            )
        ) {
            if ($self->spinTaskLoop()) {

                $self->ivPoke(
                    'taskLoopCheckTime',
                    $self->sessionTime + $self->taskLoopDelay,
                )
            }
        }

        if (
            defined $self->replayLoopCheckTime
            && (! $spinType && $self->replayLoopCheckTime < $self->sessionTime)
            || ($spinType && $spinType eq 'replay')
        ) {
            if ($self->spinReplayLoop()) {

                $self->ivPoke(
                    'replayLoopCheckTime',
                    $self->sessionTime + $self->replayLoopDelay,
                )
            }
        }

        return 1;
    }

    # Maintenance loop

    sub startMaintainLoop {

        # Called by $self->startSessionLoop
        # Starts this session's maintenance loop (which is subservient to the session loop)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the maintenance loop is already running or if the
        #       parent session loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startMaintainLoop', @_);
        }

        if (defined $self->maintainLoopCheckTime || ! defined $self->sessionLoopObj) {

            # The maintenance loop is already running or the parent session loop is not running
            return undef;

        } else {

            # Do the first spin of the maintenance loop as soon as possible
            $self->ivPoke('maintainLoopCheckTime', 0);

            # Maintenance loop initialisation

            # Set the time for the first autosave (if any)
            $self->resetAutoSave();

            return 1;
        }
    }

    sub stopMaintainLoop {

        # Called by $self->stopSessionLoop
        # Stops this session's maintenance loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the maintenance loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopMaintainLoop', @_);
        }

        if (! defined $self->maintainLoopCheckTime) {

            # The maintenance loop isn't running
            return undef;

        } else {

            # Stop the maintenance loop
            $self->ivUndef('maintainLoopCheckTime');

            # Maintenance loop shutdown
            #   (nothing to do)

            return 1;
        }
    }

    sub spinMaintainLoop {

        # Called by $self->spinSessionLoop periodically, or by any other code which needs to spin
        #   this maintenance loop immediately
        # Spins the session's maintenance loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if another of the session's subservient loops is
        #       currently spinning or if the maintenance loop is not running
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my (
            $nickname, $cage, $exitFlag, $cmdLimit, $cmdDelay,
            @repeatObjList, @excessCmdList, @soundObjList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinMaintainLoop', @_);
        }

        # If another of the session's subservient loops is currently spinning, or if the maintenance
        #   loop is not running, do nothing
        if ($self->childLoopSpinFlag || ! defined $self->maintainLoopCheckTime) {

            return undef;

        } else {

            # Don't let other subservient loops spin, until this loop is finished
            $self->ivPoke('childLoopSpinFlag', TRUE);
        }

        # Update the world's connection history object, if one was created for this session
        if ($self->connectHistoryObj) {

            $self->connectHistoryObj->set_currentTime();
        }

        # If MCP is enabled and we're waiting for an 'mcp-negotiate-end' message, but we've been
        #   waiting for too long, insert an artificial one
        if (
            $self->mcpMode eq 'client_agree'
            && defined $self->mcpCheckTime
            && $self->mcpCheckTime < $self->sessionTime
        ) {
            $self->processMcpMsg('#$#mcp-negotiate-end ' . $self->mcpAuthKey);
        }

        # In login mode 3/4, if $self->processLineSegment has set the flag, we can complete the
        #   login
        if ($self->loginConnectFoundFlag) {

            # Only respond once
            $self->ivPoke('loginConnectFoundFlag', FALSE);
            $self->ivEmpty('loginConnectPatternList');

            if ($self->loginPromptsMode eq 'tiny') {

                # Get the character nickname, for use in automatic logins (using the character name
                #   as a backup)
                if ($self->currentChar) {
                    $nickname = $self->currentChar->nickname;
                } else {
                    $nickname = $self->initChar;
                }

                # In mode 3, send a standard 'connect' world command, using the current command
                #   cages
                $self->sendModCmd('connect', 'name', $nickname, 'password', $self->initPass);

                # Wait for login success patterns (if there are any), otherwise mark the character
                #   as logged in
                $self->setLoginPatterns();

            } elsif ($self->loginPromptsMode eq 'world_cmd') {

                # In mode 4, send a list of world commands, substituting any that contain @name@,
                #   @account@ or @password@
                $self->processCmdLoginMode();
            }

        # If the character hasn't been marked as logged in yet, check whether it's time to show a
        #   warning message
        } elsif ($self->loginWarningTime && ! $self->mxpRelocateQuietFlag) {

            if ($self->status eq 'disconnected') {

                # Don't bother showing a warning message after a disconnection
                $self->ivUndef('loginWarningTime');

            } elsif ($self->loginWarningTime < $self->sessionTime) {

                if ($axmud::BLIND_MODE_FLAG) {

                    $self->writeText(
                        'Please tell me when you have finished logging in. Type a semicolon'
                        . ' followed by the word login, without any spaces. Then press return.',
                    );

                } else {

                    $self->writeText(
                        'The character is not marked as \'logged in\' yet (use the \';login\''
                        . ' command to override the automatic login process)',
                    );
                }

                # Only show the warning once
                $self->ivUndef('loginWarningTime');

                # $self->displayRawIncomingData will now stop storing raw text/raw tokens in the
                #   buffer IVs. Empty those IVs of any raw text/raw tokens collected so far
                $self->ivEmpty('rawTextBufferList');
                $self->ivEmpty('rawTokenBufferList');
            }
        }

        # If some text has been received that might be a command prompt, see if the waiting time has
        #   expired
        if (defined $self->promptCheckTime && $self->promptCheckTime < $self->sessionTime) {

            # The time has expired. Process the incomplete line as a command prompt
            $self->processPrompt();
        }

        # If there are any GA::Obj::Repeat objects, ask them to send their commands to the world
        #   (if it's time to do so)
        if ($self->mxpRelocateMode eq 'none') {

            OUTER: foreach my $obj ($self->repeatObjList) {

                if ($obj->nextCheckTime < $self->sessionTime) {

                    if (! $obj->sendCmd($self)) {

                        # Object has finished sending commands
                        next OUTER;
                    }
                }

                # Object not finished sending commands
                push (@repeatObjList, $obj);
            }

            # Reset the IV, having removed any repeat objects which have finished sending commands
            $self->ivPoke('repeatObjList', @repeatObjList);

            # Deal with excess commands
            if ($self->crawlModeFlag) {

                # Crawl mode overrules the world profile's slowwalking settings, temporarily
                $cmdLimit = $self->crawlModeCmdLimit;
                $cmdDelay = 1;

            } else {

                $cmdLimit = $self->currentWorld->excessCmdLimit;
                $cmdDelay = $self->currentWorld->excessCmdDelay;
            }

            if (
                ! $self->lastExcessCmdTime
                || ($self->lastExcessCmdTime + $cmdDelay) < $self->sessionTime
            ) {
                # The minimum delay has passed
                $self->ivPoke('lastExcessCmdTime', $self->sessionTime);
                $self->ivPoke('excessCmdCount', 0);

                # If any excess commands are waiting to be sent, send them
                @excessCmdList = $self->excessCmdList;
                if (@excessCmdList) {

                    # We'll need the highest-priority command cage when we call ->dispatchCmd
                    $cage = $self->findHighestCage('cmd');

                    do {

                        my ($cmd, $time);

                        $cmd = shift @excessCmdList;
                        $time = shift @excessCmdList;

                        if ($time < $self->sessionTime) {

                            # The command is about to be sent to the world
                            $self->processWorldCmd($cmd, $cage);

                            # Don't send too many commands per second
                            if ($cmdLimit && $self->excessCmdCount >= $cmdLimit) {

                                $exitFlag = TRUE;
                            }

                        } else {

                            # Wait for another time period before trying to send the command again
                            unshift(@excessCmdList, $cmd, $time);
                            $exitFlag = TRUE;
                        }

                    } until ($exitFlag || $self->overruleMoveFlag || ! @excessCmdList);

                    if ($self->overruleMoveFlag) {

                        # A movement command has been overruled in protected moves mode, and the
                        #   world model flag instructs us to stop processing world commands, now. In
                        #   this case, all remaining excess commands are cancelled
                        $self->ivPoke('overruleMoveFlag', FALSE);
                        @excessCmdList = ();
                    }

                    $self->ivPoke('excessCmdList', @excessCmdList);      # May be an empty list

                    if ($self->crawlModeFlag && ! $self->excessCmdList) {

                        # Stored excess commands have been sent, and there are none left, so crawl
                        #   mode must disable itself
                        $self->resetCrawlMode();
                    }
                }
            }

            # In crawl mode, if no excess commands have been stored within two minutes of crawl
            #   mode being enabled, then disable it
            if (
                $self->crawlModeFlag
                && ! $self->excessCmdList
                && $self->crawlModeCheckTime < $self->sessionTime
            ) {
                $self->resetCrawlMode();
            }
        }

        # If any exits in the exit model have been modified, we may need to check and re-calculate
        #   regions paths
        if ($self->worldModelObj->updatePathHash || $self->worldModelObj->updateBoundaryHash) {

            $self->worldModelObj->updateRegionPaths($self);
        }

        # If any world model objects or exit model objects have been deleted since the last spin of
        #   the timer loop, make them available for re-use
        if ($self->worldModelObj->modelBufferList || $self->worldModelObj->exitBufferList) {

            $self->worldModelObj->updateModelBuffers();
        }

        # If room model objects and/or map labels have been added, moved or deleted since the last
        #   spin of the timer loop, check the regionmap IVs which recorded the highest and lowest
        #   occupied levels
        if ($self->worldModelObj->checkLevelsHash) {

            $self->worldModelObj->updateRegionLevels();
        }

        # If the Automapper window is open, allow it to update itself
        if ($self->mapWin) {

            $self->mapWin->winUpdate();
        }

        # DEBUG
        if ($axmud::TEST_MODEL_FLAG && $self->mapWin && $self->status eq 'connected') {

            # Run a model test every 60 seconds (when the automapper window is open)
            my $time = (int($self->sessionTime / 60)) * 60;
            if (
                $time > $axmud::TEST_MODEL_TIME
                && ! $self->worldModelObj->testModel($self)
            ) {
                $self->writeDebug(
                    'AUTOMATIC MODEL TEST: World model test failed at '
                    . $axmud::CLIENT->localTime(),
                );

                $axmud::CLIENT->playSound('alarm');

                # Test failed. Don't keep running tests
                $axmud::TEST_MODEL_FLAG = FALSE;

            } else {

                # Test succeeded
                $axmud::TEST_MODEL_TIME = $self->sessionTime;
            }
        }
        # DEBUG

        # Check if sounds played by GA::Client->playSoundFile have finished and, if so, prune the
        #   registry accordingly
        @soundObjList = $self->ivValues('soundHarnessHash');
        foreach my $soundObj (@soundObjList) {

            my $result;

            if (! $soundObj->harness->pumpable()) {

                # Sound has finished playing. Should it repeat?
                if ($soundObj->repeat > 1) {

                    # Repeat at least once more
                    $soundObj->ivDecrement('repeat');
                    $result = $axmud::CLIENT->repeatSoundFile($soundObj);

                } elsif ($soundObj->repeat == -1) {

                    # Repeat indefinitely
                    $result = $axmud::CLIENT->repeatSoundFile($soundObj);
                }

                # If the call to ->repeatSoundFile failed, if the sound should only be played once
                #   or has finished repeating, delete the GA::Obj::Sound object
                if (! $result) {

                    $soundObj->stop();
                    $self->ivDelete('soundHarnessHash', $soundObj->number);
                }
            }
        }

        # If auto-saves are turned on, see if it's time for an auto-save (but not during an MXP
        #   crosslinking operation)
        if (
            $axmud::CLIENT->autoSaveFlag
            && $self->status eq 'connected'
            && $self->autoSaveCheckTime
            && $self->autoSaveCheckTime < $self->sessionTime
            && $self->mxpRelocateMode eq 'none'
        ) {
            # Perform the auto-save. In blind mode, don't read out the completion message
            if ($axmud::BLIND_MODE_FLAG) {
                $self->pseudoCmd('save', 'hide_complete');
            } else {
                $self->pseudoCmd('save');
            }

            $self->ivPoke('autoSaveLastTime', $self->sessionTime);
            # Set the time at which the next auto-save will occur
            $self->resetAutoSave();
        }

        # Handle changes to this session's tab label (if visible)
        $self->checkTabLabels();

        # Handle any Gtk3::TextView scrolling problems (see the comments in $self->new)
        $self->forceScrollTextViews();

        # Update any MXP gauges whose entities have been modified
        $self->updateMxpGauges();

        # If the GA::Strip::Entry strip object's console button is in flashing mode, check whether
        #   it's time to stop flashing
        if ($self->systemMsgCheckTime && $self->systemMsgCheckTime < $self->sessionTime) {

            $self->ivUndef('systemMsgCheckTime');

            # Update strip objects for any 'internal' windows used by this session
            foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

                my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
                if (
                    $stripObj
                    && $winObj->visibleSession
                    && $winObj->visibleSession eq $self
                ) {
                    $stripObj->reset_consoleIconFlash();
                }
            }
        }

        # Perform a delayed quit, if one has been set
        if (defined $self->delayedQuitTime && $self->delayedQuitTime < $self->sessionTime) {

            $self->clientCmd($self->delayedQuitCmd);
            # (Only quit once)
            $self->ivUndef('delayedQuitTime');
            $self->ivUndef('delayedQuitCmd');
        }

        # Allow other loops to spin
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return 1;
    }

    sub resetAutoSave {

        # Called by $self->startMaintainLoop, ->spinMaintainLoop or GA::Cmd::AutoSave->do
        # If auto-saves are turned on, sets the time (matches $self->sessionTime) at which the
        #   next auto-save will occur. If auto-saves are turned off, sets the IV to 0
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetAutoSave', @_);
        }

        if (! $axmud::CLIENT->autoSaveFlag) {

            # Auto-saves turned off
            $self->ivPoke('autoSaveCheckTime', 0);
            $self->ivPoke('autoSaveLastTime', 0);

        } else {

            # Auto-saves turned on
            $self->ivPoke(
                'autoSaveCheckTime',
                $self->sessionTime + ($axmud::CLIENT->autoSaveWaitTime * 60),
            );
        }

        return 1;
    }

    sub checkTabLabels {

        # Called by $self->spinMaintainLoop, $self->reactDisconnect, and also by $self->start in
        #   'connect offline' mode
        # This session's tab label (if visible) needs to be changed from time to time. This function
        #   checks whether it's necessary to change it and performs the operation, if so
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $changeTabFlag  - Set to TRUE when called by $self->start or ->reactDisconnect, in which
        #                       case the session's tab label (if visible) is definitely updated. Set
        #                       to FALSE (or 'undef') otherwise
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $changeTabFlag, $check) = @_;

        # Local variables
        my $exitFlag;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkTabLabels', @_);
        }

        # Decide whether this session's tab label colour needs to be changed from red to black (or
        #   vice-versa), or whether an xterm title needs to be updated
        if (
            ($self->showNewTextFlag && $self->showTabColourMode eq 'normal')
            || (! $self->showNewTextFlag && $self->showTabColourMode eq 'active')
            || ($self->showXTermTitleFlag)
        ) {
            # Need to change the tab label shortly
            $changeTabFlag = TRUE;
            # Update IVs
            $self->ivPoke('showXTermTitleFlag', FALSE);
        }

        # Check file objects for this session
        if ($self->showModFlag) {

            # The tab label contains an asterisk, meaning that some files need to be saved. If they
            #   have all been saved, we change the tab label
            OUTER: foreach my $fileObj ($self->ivValues('sessionFileObjHash')) {

                if ($fileObj->modifyFlag) {

                    $exitFlag = TRUE;
                    last OUTER;
                }
            }

            if (! $exitFlag) {

                # All files already saved, need to change the tab label shortly
                $changeTabFlag = TRUE;
                # Update IVs
                $self->ivPoke('showModFlag', FALSE);
            }

        } else {

            # The tab label doesn't contain an asterisk, meaning that no files need to be saved.
            #   If any of them now need to be saved, we change the tab label
            OUTER: foreach my $fileObj ($self->ivValues('sessionFileObjHash')) {

                if ($fileObj->modifyFlag) {

                    $exitFlag = TRUE;
                    last OUTER;
                }
            }

            if ($exitFlag) {

                # At least one file needs to be saved, need to change the tab label shortly
                $changeTabFlag = TRUE;
                # Update IVs
                $self->ivPoke('showModFlag', TRUE);
            }
        }

        # Change the tab label, if we need to
        if ($changeTabFlag) {

            $self->defaultTabObj->paneObj->setSessionTabLabel(
                $self,
                $self->getTabLabelText(),
                $self->showModFlag,      # Show an asterisk, or not
            );
        }

        return 1;
    }

    sub getTabLabelText {

        # Called by $self->checkTabLabels
        # Gets the text to display in this session's tab label
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the text to display

        my ($self, $check) = @_;

        # Local variables
        my ($labelText, $worldName, $charName);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getTabLabelText', @_);
        }

        # Use the world profile name, or the world's long name, depending on the GA::Client flag
        #   setting (but if the long name isn't set, don't use it)
        if ($axmud::CLIENT->longTabLabelFlag && $self->currentWorld->longName) {

            $worldName = $self->currentWorld->longName;
            # The long name is likely to be capitalised, so capitalise the character name, too (but
            #   not if the name contains ANY capital letters - we don't want to capitalise a
            #   character profile whose ->name is 'kIlLeR', for example
            if ($self->currentChar) {

                $charName = $self->currentChar->name;
                if (! ($charName =~ m/[[:upper:]]/)) {

                    $charName = ucfirst($charName);
                }
            }

        } else {

            $worldName = $self->currentWorld->name;
            if ($self->currentChar) {

                $charName = $self->currentChar->name;
            }
        }

        # The text to display depends on GA::Client->sessionTabMode. If there is no current
        #   character profile, in most cases we have to use a fallback format
        # However, if $self->xTermTitle is set and $axmud::CLIENT->xTermTitleFlag is TRUE, we
        #   display the xterm title, instead
        if ($axmud::CLIENT->xTermTitleFlag && $self->xTermTitle) {

            $labelText = $self->xTermTitle;

        } elsif ($axmud::CLIENT->sessionTabMode eq 'bracket') {

            # 'Deathmud (Gandalf)'
            if ($charName) {

                $labelText = $worldName . ' (' . $charName . ')';

            } else {

                # Fallback format
                $labelText = $worldName;
            }

        } elsif ($axmud::CLIENT->sessionTabMode eq 'hyphen') {

            # 'Deathmud - Gandalf'
            if ($charName) {

                $labelText = $worldName . ' - ' . $charName;

            } else {

                # Fallback format
                $labelText = $worldName;
            }

        } elsif ($axmud::CLIENT->sessionTabMode eq 'world') {

            # 'Deathmud'
            $labelText = $worldName;

        } elsif ($axmud::CLIENT->sessionTabMode eq 'char') {

            # 'Gandalf'
            if ($charName) {

                $labelText = $charName;

            } else {

                # Fallback format
                $labelText = '(' . $worldName . ')';
            }
        }

        # Change the colour, if necessary
        if ($self->status eq 'offline') {

            $labelText = '<span foreground="magenta">' . $labelText . '</span>';
            $self->ivPoke('showTabColourMode', 'offline');

        } elsif ($self->status eq 'disconnected') {

            $labelText = '<span foreground="blue">' . $labelText . '</span>';
            $self->ivPoke('showTabColourMode', 'disconnected');

        } elsif ($self->showNewTextFlag) {

            $labelText = '<span foreground="red">' . $labelText . '</span>';
            $self->ivPoke('showTabColourMode', 'active');

        } else {

            # Use the usual colour
            $self->ivPoke('showTabColourMode', 'normal');
        }

        return $labelText;
    }

    sub forceScrollTextViews {

        # Called by $self->spinMaintainLoop and GA::Obj::Desktop->updateWidgets
        # Handle any Gtk3::TextView scrolling problems. Since the update to Gtk3, Gtk3::Textviews
        #   sometimes fail to scroll to the top/bottom when required
        # This is corrected by compiling hashes of any textviews which were told to scroll to the
        #   top/bottom. Periodically (once per Axmud maintain loop, and after every Gtk3 main loop
        #   iteration) we forcibly scroll the marked textviews to the bottom, which takes care of
        #   any problems (while preserving the smooth scrolling effect)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $gtkFlag    - Set to TRUE when called by GA::Obj::Desktop->updateWidgets
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $gtkFlag, $check) = @_;

        # Local variables
        my (%upHash, %downHash);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->forceScrollTextViews', @_);
        }

        # For any textviews which had been scrolled to the top, forcibly scroll them to the top
        %upHash = $self->textViewScrollUpHash;
        $self->ivEmpty('textViewScrollUpHash');
        foreach my $textView (values %upHash) {

            $textView->get_vadjustment->set_value(0);
        }

        # For any textviews which had been scrolled to the bottom, forcibly scroll them to the
        #   bottom
        %downHash = $self->textViewScrollDownHash;
        $self->ivEmpty('textViewScrollDownHash');
        foreach my $textView (values %downHash) {

            my $adjust = $textView->get_vadjustment();

            $adjust->set_value($adjust->get_upper() - $adjust->get_page_size());
        }

        if (%upHash || %downHash) {

            # (Avoid infinite recursion)
            if (! $gtkFlag) {

                $axmud::CLIENT->desktopObj->updateWidgets();
            }

            # Check the textviews have actually scrolled to the correct position
            foreach my $textView (values %upHash) {

                if ($textView->get_vadjustment->get_value()) {

                    # Forced scrolling wasn't successful, so try again on the next maintain loop
                    $self->ivAdd('textViewScrollUpHash', $textView, $textView);
                }
            }

            foreach my $textView (values %downHash) {

                my $adjust = $textView->get_vadjustment();

                if ($adjust->get_value() < ($adjust->get_upper() - $adjust->get_page_size())) {

                    # Forced scrolling wasn't successful, so try again on the next maintain loop
                    $self->ivAdd('textViewScrollDownHash', $textView, $textView);
                }
            }
        }

        return 1;
    }

    sub setCrawlMode {

        # Called by GA::Cmd::Crawl->do
        # Enables crawl mode, setting a temporary limit on the number of world commands that can be
        #   sent per second
        #
        # Expected arguments
        #   $num    - The command limit per second
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $num, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setCrawlMode', @_);
        }

        $self->ivPoke('crawlModeFlag', TRUE);
        $self->ivPoke('crawlModeCmdLimit', $num);
        $self->ivPoke('crawlModeCheckTime', $self->sessionTime + $self->crawlModeWaitTime);

        return 1;
    }

    sub resetCrawlMode {

        # Called by $self->spinMaintainLoop and GA::Cmd::Crawl->do
        # Disables crawl mode, removing a temporary limit on the number of world commands that can
        #   be sent per second. (If the current world profile specifies a limit, that limit then
        #   applies instead; otherwise there is no limit)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $noMsgFlag  - If TRUE, a system message is not displayed (because the calling function
        #                   wants to display its own one). If FALSE (or 'undef'), a short system
        #                   message is displayed
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $noMsgFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setCrawlMode', @_);
        }

        $self->ivPoke('crawlModeFlag', FALSE);
        $self->ivPoke('crawlModeCmdLimit', undef);
        $self->ivPoke('crawlModeCheckTime', undef);

        if (! $noMsgFlag) {

            $self->writeText('Crawl mode disabled.');
        }

        return 1;
    }

    # Timer loop

    sub startTimerLoop {

        # Called by $self->startSessionLoop
        # Starts this session's timer loop (which is subservient to the session loop)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the timer loop is already running or if the parent
        #       session loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startTimerLoop', @_);
        }

        if (defined $self->timerLoopCheckTime || ! defined $self->sessionLoopObj) {

            # The timer loop is already running or the parent session loop is not running
            return undef;

        } else {

            # Do the first spin of the timer loop as soon as possible
            $self->ivPoke('timerLoopCheckTime', 0);

            # Timer loop initialisation
            #   (nothing to do)

            return 1;
        }
    }

    sub stopTimerLoop {

        # Called by $self->stopSessionLoop
        # Stops this session's timer loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the timer loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopTimerLoop', @_);
        }

        if (! defined $self->timerLoopCheckTime) {

            # The timer loop isn't running
            return undef;

        } else {

            # Stop the timer loop
            $self->ivUndef('timerLoopCheckTime');

            # Timer loop shutdown
            #   (nothing to do)

            return 1;
        }
    }

    sub spinTimerLoop {

        # Called by $self->spinSessionLoop periodically, or by any other code which needs to spin
        #   this timer loop immediately
        # Spins the session's timer loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if another of the session's subservient loops is
        #       currently spinning or if the timer loop is not running
        #   1 on success

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinTimerLoop', @_);
        }

        # If another of the session's subservient loops is currently spinning, or if the timer
        #   loop is not running, do nothing
        if ($self->childLoopSpinFlag || ! defined $self->timerLoopCheckTime) {

            return undef;

        } else {

            # Don't let other subservient loops spin, until this loop is finished
            $self->ivPoke('childLoopSpinFlag', TRUE);
        }

        # Check every active timer interface, to see if it is ready to fire (but not during an
        #   MXP crosslinking operation)
        if ($self->mxpRelocateMode eq 'none') {

            $self->checkTimers();
        }

        # Check to see whether it's time to fire a hook in response to the hook events 'user_idle'
        #   (user has been idle for $self->constHookIdleTime seconds) and 'world_idle' (world has
        #   sent no text for $self->constHookIdleTime seconds)
        # (Don't do it during an MXP crosslinking operation, obviously)
        if ($self->constHookIdleTime && $self->mxpRelocateMode eq 'none') {

            if (
                $self->lastCmdTime
                && ! $self->disableUserIdleFlag
                && $self->sessionTime > ($self->lastCmdTime + $self->constHookIdleTime)
            ) {
                # Fire any hooks that are using the 'user_idle' hook event
                $self->checkHooks('user_idle', $self->lastCmdTime);
                # This hook event will not fire again until $self->lastCmdTime is next set (i.e.
                #   when the next world command is sent)
                $self->ivPoke('disableUserIdleFlag', TRUE);
            }

            if (
                $self->lastDisplayTime
                && ! $self->disableWorldIdleFlag
                && $self->sessionTime > ($self->lastDisplayTime + $self->constHookIdleTime)
            ) {
                # Fire any hooks that are using the 'world_idle' hook event
                $self->checkHooks('world_idle', $self->lastDisplayTime);
                # This hook event will not fire again until $self->lastDisplayTime is next set (i.e.
                #   when text is next received from the world)
                $self->ivPoke('disableWorldIdleFlag', TRUE);
            }
        }

        # Tell GA::Client to update its registry of active keycodes, if any active macro interfaces
        #   have been deleted by this session since the loop last spun
        if ($axmud::CLIENT->resetKeycodesFlag) {

            $axmud::CLIENT->reset_activeKeycodes();
        }

        # For any triggers, aliases, macros and hooks which are in the middle of a cooldown period,
        #   check whether the period has expired
        foreach my $iv (
            qw (triggerCooldownHash aliasCooldownHash macroCooldownHash hookCooldownHash)
        ) {
            foreach my $interfaceNum ($self->ivKeys($iv)) {

                my $time = $self->ivShow($iv, $interfaceNum);

                if ($time < $self->sessionTime) {

                    $self->ivDelete($iv, $interfaceNum);
                }
            }
        }

        # Allow other loops to spin
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return 1;
    }

    # Task loop

    sub startTaskLoop {

        # Called by $self->startSessionLoop
        # Starts this session's task loop (which is subservient to the session loop)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the task loop is already running or if the parent
        #       session loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startTaskLoop', @_);
        }

        if (defined $self->taskLoopCheckTime || ! defined $self->sessionLoopObj) {

            # The task loop is already running or the parent session loop is not running
            return undef;

        } else {

            # Do the first spin of the task loop as soon as possible
            $self->ivPoke('taskLoopCheckTime', 0);

            # Task loop initialisation
            #   (nothing to do)

            return 1;
        }
    }

    sub stopTaskLoop {

        # Called by $self->stopSessionLoop
        # Stops this session's task loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the task loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopTaskLoop', @_);
        }

        if (! defined $self->taskLoopCheckTime) {

            # The task loop isn't running
            return undef;

        } else {

            # Stop the task loop
            $self->ivUndef('taskLoopCheckTime');

            # Task loop shutdown

            # Shut down any current tasks which are running
            foreach my $taskObj ($self->ivValues('currentTaskHash')) {

                $taskObj->set_shutdownFlag(TRUE);

                if ($taskObj->category eq 'process') {
                    $taskObj->main();
                } else {
                    $taskObj->shutdown();
                }
            }

            $self->ivEmpty('currentTaskHash');

            return 1;
        }
    }

    sub spinTaskLoop {

        # Called by $self->spinSessionLoop periodically, or by any other code which needs to spin
        #   this task loop immediately
        # Spins the session's task loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if another of the session's subservient loops is
        #       currently spinning or if the task loop is not running
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my (
            $resetWinFlag, $benchFlag, $currentTab,
            @taskList, @selectedList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinTaskLoop', @_);
        }

        # If another of the session's subservient loops is currently spinning, if the task
        #   loop is not running, if an MXP crosslinking operation is in progress or if the
        #   'freeze tasks' flag was set (by ';freezetask'), do nothing
        if (
            $self->childLoopSpinFlag
            || ! defined $self->taskLoopCheckTime
            || $self->mxpRelocateMode ne 'none'
            || $self->freezeTaskLoopFlag
        ) {
            return undef;

        } else {

            # Don't let other subservient loops spin, until this loop is finished
            $self->ivPoke('childLoopSpinFlag', TRUE);
        }

        # Delete any active interfaces (which aren't based on an inactive interface) that have
        #   marked for deletion by $self->deleteInterface
        foreach my $obj ($self->deleteInterfaceList) {

            $self->removeInterface($obj);
        }

        $self->ivEmpty('deleteInterfaceList');

        # Remove any tasks that have finished by deleting them from the current tasklist
        OUTER: foreach my $taskObj ($self->ivValues('currentTaskHash')) {

            if ($taskObj->status eq 'finished') {

                # Remove all interfaces (triggers, aliases, macros, timers and hooks) associated
                #   with this task
                $self->tidyInterfaces($taskObj);

                # Remove the task from the current tasklist
                $self->ivDelete('currentTaskHash', $taskObj->uniqueName);
                # Don't call the task again
                if ($self->ivExists('taskCallHash', $taskObj->uniqueName)) {

                    $self->ivDelete('taskCallHash', $taskObj->uniqueName);
                }

                # $self->currentTaskNameHash contains a single copy of each type of task running
                #   (e.g. the most recently created Social task). Remove this task from the registry
                #   and, if there are any other copies of the same type of task running, find the
                #   most recent one and add it to the registry
                $self->ivDelete('currentTaskNameHash', $taskObj->name);
                INNER: foreach my $otherObj ($self->ivValues('currentTaskHash')) {

                    if ($otherObj->name eq $taskObj->name) {

                        if (
                            # The first task of the same type found
                            ! $self->ivExists('currentTaskNameHash', $taskObj->name)
                            # $otherObj was created more recently than a task of the same type we
                            #   added earlier in this INNER loop
                            || $otherObj->startTime
                                < $self->ivShow('currentTaskNameHash', $taskObj->name)->startTime
                        ) {
                            $self->ivAdd('currentTaskNameHash', $taskObj->name, $otherObj);
                        }
                    }
                }

                # If the Status task is running, tell it to update its display of active tasks
                if ($self->statusTask) {

                    $self->update_statusTask();
                }

                # Every time a task stops, the 'main' window's menu items must be sensitised /
                #   desensitised, depending on current conditions (but only if this session is the
                #   current one)
                $resetWinFlag = TRUE;
            }
        }

        # Maintain tasks by checking their settings, and starting/pausing/stopping them as necessary
        @taskList = $self->compileTasks();
        OUTER: foreach my $taskObj (@taskList) {

            my $taskName = $taskObj->uniqueName;

            # Task is waiting to start, and is due to start
            if (
                $taskObj->status eq 'wait_init'
                && $taskObj->startTime < $self->sessionTime
            ) {
                $self->startTask($taskName, $taskObj);
                # Every time a task stops, the 'main' window's menu items must be sensitised /
                #   desensitised, depending on current conditions (but only if this session is the
                #   current one)
                $resetWinFlag = TRUE;

            # Task is waiting for another task to exist, before starting
            } elsif ($taskObj->status eq 'wait_task_exist') {

                # If it's time to check (which takes place once a minute)
                if ($taskObj->checkTime <= $self->sessionTime) {

                    # See if the matching task exists. First check task unique names
                    my $waitForTask = $taskObj->waitForTask;
                    if (
                        $self->ivExists('currentTaskHash', $waitForTask)
                        || $self->ivExists('currentTaskNameHash', $waitForTask)
                    ) {
                        # Match found, so initialise $taskObj
                        $self->startTask($taskName, $taskObj);
                        $resetWinFlag = TRUE;

                    # No match found, so now check task generic names
                    } else {

                        INNER: foreach my $otherTask ($self->ivValues('currentTaskHash')) {

                            if ($otherTask->name eq $waitForTask) {

                                # Match found, so initialise $taskObj
                                $self->startTask($taskName, $taskObj);
                                $resetWinFlag = TRUE;

                                last INNER;
                            }
                        }
                    }
                }

            # Task is waiting for another task to not exist, before starting
            } elsif ($taskObj->status eq 'wait_task_no_exist') {

                # If it's time to check (which takes place once a minute)
                if ($taskObj->checkTime <= $self->sessionTime) {

                    # See if the matching task exists. First check task unique names
                    my $waitForTask = $taskObj->waitForTask;
                    if (
                        ! $self->ivExists('currentTaskHash', $waitForTask)
                        && ! $self->ivExists('currentTaskNameHash', $waitForTask)
                    ) {
                        # No match found, so now task check generic names
                        my $match = 0;

                        INNER: foreach my $otherTask ($self->ivValues('currentTaskHash')) {

                            if ($otherTask->name eq $waitForTask) {

                                $match = 1;
                                last INNER;
                            }
                        }

                        if (! $match) {

                            # No match found, so initialise $taskObj
                            $self->startTask($taskName, $taskObj);
                            $resetWinFlag = TRUE;
                        }
                    }
                }

            # Task is waiting for another task to start and then stop, before starting
            } elsif ($taskObj->status eq 'wait_task_start_stop') {

                # If it's time to check (which takes place once a minute)
                if ($taskObj->checkTime <= $self->sessionTime) {

                    # See if the matching task exists. First check task unique names
                    my $waitForTask = $taskObj->waitForTask;
                    if (
                        $self->ivExists('currentTaskHash', $waitForTask)
                        || $self->ivExists('currentTaskNameHash', $waitForTask)
                    ) {
                        # Match found, so change $taskObj's status to waiting for the matched task
                        #   to stop
                        $taskObj->set_status('wait_task_no_exist');

                    # No match found, so now check task generic names
                    } else {

                        INNER: foreach my $otherTask ($self->ivValues('currentTaskHash')) {

                            if ($otherTask->name eq $waitForTask) {

                                # Match found, so change $taskObj's status to waiting for the
                                #   matched task to stop
                                $taskObj->set_status('wait_task_no_exist');
                                last INNER;
                            }
                        }
                    }
                }

            # Task is active or paused, and is due to stop
            } elsif (
                ($taskObj->status eq 'paused' || $taskObj->status eq 'running')
                && $taskObj->endTime
                && $taskObj->endTime <= $self->sessionTime
            ) {
                # Send the task a shutdown message, so it can shut down gracefully (when the
                #   process is complete, the task should set its own status to 'finished')
                $taskObj->set_shutdownFlag(TRUE);

            # Task is currently paused
            } elsif ($taskObj->status eq 'paused') {

                # Unpause the task if it's time
                if ($taskObj->checkTime && $taskObj->checkTime <= $self->sessionTime) {

                    $taskObj->set_status('running');
                }
            }
        }

        # If a current profile has been changed, some tasks need to be told to reset
        if ($self->currentProfChangeFlag) {

            # Tell the task that is should reset, if it is either active or paused, but if the task
            #   stage is 1 (meaning the task has not yet been executed for the first time), there's
            #   no need to reset it
            foreach my $taskObj ($self->ivValues('currentTaskHash')) {

                if (
                    $taskObj->profSensitivityFlag
                    && ($taskObj->status eq 'running' || $taskObj->status eq 'paused')
                    && (
                        ($taskObj->category eq 'process' && $taskObj->stage != 1)
                        || ($taskObj->category eq 'activity')
                    )
                ) {
                    $taskObj->set_status('reset');
                }
            }

            # In addition, every session's tab label must be updated (as they contain names of
            #   current profiles)
            foreach my $session ($axmud::CLIENT->listSessions()) {

                if ($session->defaultTabObj) {

                    $session->defaultTabObj->paneObj->setSessionTabLabel(
                        $session,
                        $session->getTabLabelText(),
                        $session->showModFlag,              # Show an asterisk, or not
                    );
                }
            }

            # Resets complete
            $self->ivPoke('currentProfChangeFlag', FALSE);
        }

        # Before calling active tasks, we must resume any Axbasic scripts which have been paused
        #   as the result of an INPUT statement, and which aren't running with a parent task
        # (All other kinds of Axbasic pauses are handled by the parent task)
        # The list can contain duplicate entries, so if any script isn't paused, just ignore its
        #   entry (or entries) on the list
        do {

            my $scriptObj = $self->ivShift('scriptResumeList');

            if ($scriptObj && $scriptObj->scriptStatus eq 'paused') {

                $scriptObj->implement();
                # 'Internal' window menus must be reset when a script resumes
                $resetWinFlag = TRUE;
            }

        } until (! $self->scriptResumeList);

        # Now, call each running process task in turn. Don't call activity tasks unless their
        #   ->shutdownFlag has been set, or they must be reset; don't call process tasks if their
        #   ->delayTime hasn't expired yet
        @taskList = $self->compileTasks();
        foreach my $taskObj (@taskList) {

            # Process tasks
            if (
                $taskObj->category eq 'process'
                && ($taskObj->status eq 'running' || $taskObj->status eq 'reset')
                && (
                    ! $self->ivExists('taskCallHash', $taskObj->uniqueName)
                    || ! $self->ivShow('taskCallHash', $taskObj->uniqueName)
                    || $self->ivShow('taskCallHash', $taskObj->uniqueName) < $self->sessionTime
                )
            ) {
                # Call the task
                $taskObj->main();

                # Set the time at which the task will next be called (if it is still running)
                if ($taskObj->status eq 'running') {

                    if ($taskObj->delayTime && $taskObj->delayTime > $self->taskLoopDelay) {

                        # Don't call the task on the next task loop, but after a delay
                        $self->ivAdd(
                            'taskCallHash',
                            $taskObj->uniqueName,
                            $self->sessionTime + $taskObj->delayTime,
                        );

                    } else {

                        # Call the task on the next task loop
                        $self->ivAdd('taskCallHash', $taskObj->uniqueName, 0);
                    }
                }

            # Activity tasks
            } elsif ($taskObj->category eq 'activity') {

                if ($taskObj->shutdownFlag) {

                    # This an activity task whose shutdown flag has been set
                    $taskObj->shutdown();

                } elsif ($taskObj->status eq 'reset') {

                    # This an activity task which must be reset
                    $taskObj->reset();
                }
            }
        }

        # If the data viewer window is open at the tab containing the list of current tasks,
        #   re-draw the list
        if ($self->viewerWin) {

            $currentTab = $self->viewerWin->notebookGetTab();

            if (defined $currentTab && $currentTab eq 'Current tasklist') {

                # If there are currently any selected lines in the tab's GA::Obj::SimpleList,
                #   remember them, so we can select them again as soon as the list is redrawn
                @selectedList = $self->viewerWin->notebookGetSelectedLines();

                # Redraw the list
                $self->viewerWin->currentTaskHeader();

                if (@selectedList) {

                    # Re-select each selected line
                    $self->viewerWin->notebookSetSelectedLines(@selectedList);
                }
            }
        }

        # At least one task loop has completed
        $self->ivPoke('firstTaskLoopCompleteFlag', TRUE);

        # Sensitise/desensitise menu bar/toolbar items, depending on current conditions (if a task
        #   has started or stopped during this task loop or if a Axbasic script has resumed)
        if ($resetWinFlag) {

            $axmud::CLIENT->desktopObj->restrictWidgets();
        }

        # Allow other loops to spin
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return 1;
    }

    sub compileTasks {

        # Called by $self->spinTaskLoop at various points during its spin
        # Extracts a list of tasks from the current tasklist
        # Moves those tasks which must be processed first to the front of the list, and those tasks
        #    which must be processed last to the end of the list
        # Returns the modified list
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns the modified list

        my ($self, $check) = @_;

        # Local variables
        my (@emptyList, @taskList, @initialList, @firstList, @lastList);

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->compileTasks', @_);
            return @emptyList;
        }

        # Compile a list of active process tasks that must be called. Activity tasks must also be
        #   called if their shutdown flag has been set, or if they must be reset
        @taskList = $self->ivValues('currentTaskHash');

        # Go through the list, removing any tasks on the 'first' runlist (tasks that must be run
        #   first, before any others)
        if ($axmud::CLIENT->taskRunFirstList) {

            OUTER: foreach my $string ($axmud::CLIENT->taskRunFirstList) {

                @initialList = @taskList;
                @taskList = ();

                INNER: foreach my $taskObj (@initialList) {

                    if ($taskObj->name eq $string) {
                        push (@firstList, $taskObj);
                    } else {
                        push (@taskList, $taskObj);
                    }
                }
            }
        }

        # Now remove tasks on the 'last' runlist (tasks that must be run last, before any others)
        if ($axmud::CLIENT->taskRunLastList) {

            OUTER: foreach my $string ($axmud::CLIENT->taskRunLastList) {

                @initialList = @taskList;
                @taskList = ();

                INNER: foreach my $taskObj (@initialList) {

                    if ($taskObj->name eq $string) {
                        push (@lastList, $taskObj);
                    } else {
                        push (@taskList, $taskObj);
                    }
                }
            }
        }

        # Now put tasks at the beginning, and at the end. (@lastList contains tasks in reverse
        #   order)
        @taskList = (@firstList, @taskList, (reverse @lastList));

        return @taskList;
    }

    sub startTask {

        # Called by $self->spinTaskLoop to start a new task
        # Performs some final checks, marks the task as 'running', and sets the conditions for the
        #   task to stop
        #
        # Expected arguments
        #   $taskName   - A key in the hash $self->currentTaskHash (also matches $taskObj->name)
        #   $taskObj    - The corresponding value, set to the task object's blessed reference
        #
        # Return values
        #   'undef' if improper arguments supplied
        #   'undef' if the task can't be started because its jealousy flag is 1, and another copy of
        #       the task is already running
        #   'undef' if the task requires the Locator task, and the Locator task doesn't exist (or
        #       isn't active)
        #   'undef' if the task was due to finish before it started
        #   1 if the task is successfully started

        my ($self, $taskName, $taskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $taskName || ! defined $taskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startTask', @_);
        }

        # Check the task's jealousy flag
        if ($taskObj->jealousyFlag) {

            foreach my $otherTask ($self->ivValues('currentTaskHash')) {

                if ($otherTask ne $taskObj && $otherTask->name eq $taskObj->name) {

                    # Another copy of the $taskObj has been found; mark the task as 'finished' (it
                    #   will never be executed)
                    $taskObj->set_status('finished');
                    return $self->writeError(
                        'Cannot run more than one copy of the \'' . $taskObj->prettyName
                        . '\' task concurrently ' . '- the most recent copy has been aborted',
                        $self->_objClass . '->startTask',
                    );
                }
            }
        }

        # Check the task's dependency on the Locator task
        if (
            $taskObj->requireLocatorFlag == 1
            && $taskObj->status eq 'wait_init'
            && ! defined $self->locatorTask
        ) {
            $taskObj->set_status('wait_task_exist');
            $taskObj->set_waitForTask('locator_task');

            return undef;
        }

        # Check the size limits on the task's name variables
        if (! $axmud::CLIENT->nameCheck($taskObj->name, 16)) {

            $taskObj->set_status('finished');
            return $self->writeError(
                'Illegal task standard name \'' . $taskObj->name . '\' - task aborted',
                $self->_objClass . '->startTask',
            );

        } elsif (length ($taskObj->prettyName) > 32) {

            $taskObj->set_status('finished');
            return $self->writeError(
                'Illegal task pretty name \'' . $taskObj->prettyName . '\' - task aborted',
                $self->_objClass . '->startTask',
            );

        } elsif (! $axmud::CLIENT->nameCheck($taskObj->uniqueName, 24)) {

            $taskObj->set_status('finished');
            return $self->writeError(
                'Illegal task unique name \'' . $taskObj->uniqueName . '\' - task aborted',
                $self->_objClass . '->startTask',
            );
        }

        # Start the task
        $taskObj->set_status('running');
        $taskObj->set_startTime($self->sessionTime);

        # If it's a process task, set the task stage to 1. If it's an activity task, call the task's
        #   ->init() function to initialise it
        if ($taskObj->category eq 'process') {

            $taskObj->set_stage(1);

        } elsif ($taskObj->category eq 'activity') {

            $taskObj->init();
        }

        # Set the task's end time (if appropriate)
        if ($taskObj->endStatus eq 'run_for') {

            # Task runs for a certain number of minutes after this moment
            $taskObj->set_endTime($self->sessionTime + ($taskObj->endTime * 60));

        } elsif ($taskObj->endStatus eq 'run_until') {

            # Task runs until this time specified by $taskObj->endTime. If that time has already
            #   passed, mark the task as 'finished' (it will never be executed, and will be deleted
            #   on the next task loop)
            if ($taskObj->endTime <= $self->sessionTime) {

                $taskObj->set_status('finished');
                return undef;
            }

        } else {

            # Mark the task as running indefinitely, by setting ->endTime to zero
            $taskObj->set_endTime(0);
        }

        return 1;
    }

    sub haltProfileTasks {

        # Called by GA::Generic::Cmd->setProfile and $self->setupProfiles
        # When a current profile is unset as a current profile, all of the tasks in the current
        #   tasklist that were created from the profile's initial tasklist or initial scriptlist
        #   must be shut down
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $name     - The name of the current profile whose associated tasks must be shut down.
        #                   If 'undef', the tasks associated with all current profiles are shut down
        #
        # Return values
        #   'undef' on improper arguments, if a specified profile doesn't exist or if the specified
        #       profile's category isn't in the profile priority list ($self->profPriorityList)
        #   Otherwise returns the number of associated tasks that were shut down (may be 0)

        my ($self, $name, $check) = @_;

        # Local variables
        my (
            $count,
            @profList,
            %profHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->haltProfileTasks', @_);
        }

        # Compile a list of current profiles to process
        if (defined $name) {

            # Check the specified profile exists
            if (! $self->ivExists('profHash', $name)) {

                return $self->writeError(
                    'Unrecognised profile \'' . $name . '\'',
                    $self->_objClass . '->haltProfileTasks',
                );

            } else {

                push (@profList, $self->ivShow('profHash', $name));
            }

        } else {

            # Use all current profiles
            @profList = $self->ivValues('currentProfHash');
        }

        # Convert the list of profiles into a hash, for quick lookup
        foreach my $profObj (@profList) {

            $profHash{$profObj->name} = undef;
        }

        # Check each task in the current tasklist
        $count = 0;
        foreach my $taskObj ($self->ivValues('currentTaskHash')) {

            if ($taskObj->profName && exists ($profHash{$taskObj->profName})) {

                # This task must be shut down
                $taskObj->set_shutdownFlag(TRUE);
                $count++;
            }
        }

        # Return the number of shutting down tasks (may be 0)
        return $count;
    }

    # Replay loop

    sub startReplayLoop {

        # Called by GA::Cmd::ReplayBuffer->do
        # Starts this session's replay loop (which is subservient to the session loop)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $textFlag   - If TRUE, replay information from the replay text buffer. If FALSE (or
        #                   'undef'), don't use the replay text buffer
        #   $cmdFlag    - If TRUE, replay information from the replay command buffer. If FALSE (or
        #                   'undef'), don't use the replay command buffer. (Both $textFlag and
        #                   $cmdFlag can be TRUE, but if both are FALSE, the replay loop doesn't
        #                   start)
        #   $beginTime  - The time at which to begin (matches GA::Buffer::Display->time and
        #                   GA::Buffer::Cmd->time). If 'undef', the loop starts at the time of the
        #                   first buffer object
        #   $endTime    - The time at which to end (matches GA::Buffer::Display->time and
        #                   GA::Buffer::Cmd->time). If 'undef', the loop stops at the time of the
        #                   last buffer object
        #
        # Return values
        #   'undef' on improper arguments, if the replay loop is already running or if the parent
        #       session loop is not running
        #   1 otherwise

        my ($self, $textFlag, $cmdFlag, $beginTime, $endTime, $check) = @_;

        my ($firstText, $firstCmd);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startReplayLoop', @_);
        }

        if (
            defined $self->replayLoopCheckTime
            || ! defined $self->sessionLoopObj
            # Also don't start the replay loop if both flag arguments are FALSE...
            || (! $textFlag && ! $cmdFlag)
            # ...or when connected to a world
            || ($self->status ne 'disconnected' && $self->status ne 'offline')
        ) {
            # The replay loop is already running or the parent session loop is not running
            return undef;

        } else {

            # Do the first spin of the replay loop as soon as possible
            $self->ivPoke('replayLoopCheckTime', 0);

            # Replay loop initialisation

            # The two flag must be either TRUE or FALSE
            if ($textFlag) {
                $textFlag = TRUE;
            } else {
                $textFlag = FALSE;
            }

            if ($cmdFlag) {
                $cmdFlag = TRUE;
            } else {
                $cmdFlag = FALSE;
            }

            # Decide the time (matching the $self->sessionTime when objects were added to the
            #   buffers) at which the replay should start
            if (! defined $beginTime) {

                $beginTime = 0;
            }

            # Find the first line in each buffer to check
            if ($textFlag) {

                OUTER: for (
                    my $num = $self->replayDisplayBufferFirst;
                    $num <= $self->replayDisplayBufferLast;
                    $num++
                ) {
                    my $obj = $self->ivShow('replayDisplayBufferHash', $num);
                    if ($obj->time >= $beginTime) {

                        # This buffer line is the first one that can be used
                        $firstText = $num;
                        last OUTER;
                    }
                }

                if (! defined $firstText) {

                    # There is no buffer line to replay
                    $textFlag = FALSE;
                }
            }

            if ($cmdFlag) {

                OUTER: for (
                    my $num = $self->replayCmdBufferFirst;
                    $num <= $self->replayCmdBufferLast;
                    $num++
                ) {
                    my $obj = $self->ivShow('replayCmdBufferHash', $num);
                    if ($obj->time >= $beginTime) {

                        # This buffer line is the first one that can be used
                        $firstCmd = $num;
                        last OUTER;
                    }
                }

                if (! defined $firstCmd) {

                    # There is no buffer line to replay
                    $cmdFlag = FALSE;
                }
            }

            if (! $textFlag && ! $cmdFlag) {

                # Nothing to replay; don't allow the replay loop to start
                $self->ivUndef('replayLoopCheckTime');

                return undef;

            } else {

                # Update IVs
                $self->ivPoke('replayLoopStartTime', $axmud::CLIENT->getTime() - $beginTime);
                $self->ivPoke('replayLoopTime', $beginTime);
                $self->ivUndef('replayLoopStopTime', $endTime);         # May be 'undef'
                $self->ivPoke('replayLoopTextFlag', $textFlag);
                $self->ivPoke('replayLoopCmdFlag', $cmdFlag);
                $self->ivPoke('replayLoopNextText', $firstText);
                $self->ivPoke('replayLoopNextCmd', $firstCmd);

                return 1;
            }
        }
    }

    sub stopReplayLoop {

        # Called by $self->stopSessionLoop
        # Stops this session's replay loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the replay loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopReplayLoop', @_);
        }

        if (! defined $self->replayLoopCheckTime) {

            # The replay loop isn't running
            return undef;

        } else {

            # Stop the replay loop
            $self->ivUndef('replayLoopCheckTime');

            # Replay loop shutdown

            # Reset IVs
            $self->ivUndef('replayLoopStartTime');
            $self->ivUndef('replayLoopTime');
            $self->ivUndef('replayLoopStopTime');
            $self->ivUndef('replayLoopTextFlag');
            $self->ivUndef('replayLoopCmdFlag');
            $self->ivUndef('replayLoopNextText');
            $self->ivUndef('replayLoopNextCmd');

            return 1;
        }
    }

    sub spinReplayLoop {

        # Called by $self->spinSessionLoop periodically, or by any other code which needs to spin
        #   this replay loop immediately
        # Spins the session's replay loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if another of the session's subservient loops is
        #       currently spinning or if the replay loop is not running
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my $exitFlag;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinReplayLoop', @_);
        }

        # If another of the session's subservient loops is currently spinning, if the replay
        #   loop is not running or if an MXP crosslinking operation is in progress, do nothing
        if (
            $self->childLoopSpinFlag
            || ! defined $self->replayLoopCheckTime
            || $self->mxpRelocateMode ne 'none'
        ) {
            return undef;

        } else {

            # Don't let other subservient loops spin, until this loop is finished
            $self->ivPoke('childLoopSpinFlag', TRUE);
        }

        # NB $self->replayLoopTime is updated at the end of the function, so that if there are
        #   buffer objects whose ->time is set to 0, those buffer objects can still be used
        #   (assuming that, on the first call to this function, $self->replayLoopTime is still 0)
        do {

            my ($textNum, $cmdNum, $textObj, $cmdObj);

            # Import IVs for convenience
            $textNum = $self->replayLoopNextText;
            $cmdNum = $self->replayLoopNextCmd;

            # Exit this loop after the first spin in which no buffer object is used
            $exitFlag = TRUE;

            # Check the buffer line actually exists. If not, we stop replaying the buffer here
            if ($self->replayLoopTextFlag) {

                if (! $self->ivExists('replayDisplayBufferHash', $textNum)) {

                    $self->ivPoke('replayLoopTextFlag', FALSE);
                    $self->ivUndef('replayLoopNextText');
                }
            }

            if ($self->replayLoopCmdFlag) {

                if (! $self->ivExists('replayCmdBufferHash', $cmdNum)) {

                    $self->ivPoke('replayLoopCmdFlag', FALSE);
                    $self->ivUndef('replayLoopNextCmd');
                }
            }

            if ($self->replayLoopTextFlag || $self->replayLoopCmdFlag) {

                # Get the GA::Buffer::Display and GA::Buffer::Cmd objects
                if ($self->replayLoopTextFlag) {

                    $textObj = $self->ivShow('replayDisplayBufferHash', $textNum);

                    # Is it time to display this object?
                    if ($textObj->time > $self->replayLoopTime) {

                        # Not time yet
                        $textObj = undef;

                    # Is it time to stop replaying text buffer objects?
                    } elsif (
                        defined $self->replayLoopStopTime
                        && $textObj->time < $self->replayLoopStopTime
                    ) {
                        # Time to stop
                        $self->ivPoke('replayLoopTextFlag', FALSE);
                        $self->ivUndef('replayLoopNextText');
                        $textObj = undef;
                    }
                }

                if ($self->replayLoopCmdFlag) {

                    $cmdObj = $self->ivShow('replayCmdBufferHash', $cmdNum);
                    # Is it time to display this object?
                    if ($cmdObj->time > $self->replayLoopTime) {

                        # Not time yet
                        $cmdObj = undef;

                    # Is it time to stop replaying command buffer objects?
                    } elsif (
                        defined $self->replayLoopStopTime
                        && $cmdObj->time < $self->replayLoopStopTime
                    ) {
                        # Time to stop
                        $self->ivPoke('replayLoopCmdFlag', FALSE);
                        $self->ivUndef('replayLoopNextCmd');
                        $cmdObj = undef;
                    }
                }

                # If $textObj and $cmdObj are both still defined, display information from the
                #   earlier one
                if ($textObj && $cmdObj) {

                    if ($textObj->time < $cmdObj->time) {

                        $cmdObj = undef;

                    } elsif ($textObj->time > $cmdObj->time) {

                        $textObj = undef;

                    } else {

                        # Default - display text before command
                        $cmdObj = undef;
                    }
                }

                # Display information from the buffer
                if ($textObj) {

                    # Show the text in ->stripLine, so that interfaces can interact with the line
                    #   again. End the string with " \n" so that an empty line is displayed, and so
                    #   that each buffer line is shown on a separate line in the 'main' window
                    $self->processIncomingData($textObj->stripLine . " \n");
                    $self->ivIncrement('replayLoopNextText');
                    # Repeat this loop, so the next buffer line can be used
                    $exitFlag = FALSE;

                } elsif ($cmdObj) {

                    # Process the world command
                    $self->worldCmd($cmdObj->cmd);
                    $self->ivIncrement('replayLoopNextCmd');
                    # Repeat this loop, so the next buffer line can be used
                    $exitFlag = FALSE;
                }
            }

        } until ($exitFlag);

        if (! $self->replayLoopTextFlag && ! $self->replayLoopCmdFlag) {

            # We've finished replaying the buffer(s)
            $self->stopReplayLoop();
            $self->writeText('Buffer replay complete');

        } else {

            # Update replay loop time. The subtraction produces a system rounding error, so we need
            #   to round the value to 3dp
#            $self->ivPoke(
#                'replayLoopTime',
#                sprintf('%.3f', $axmud::CLIENT->getTime() - $self->replayLoopStartTime),
#            );
            $self->ivPoke(
                'replayLoopTime',
                Math::Round::nearest(0.001, $axmud::CLIENT->getTime() - $self->replayLoopStartTime),
            );
        }

        # Allow other loops to spin
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return 1;
    }

    # Incoming data loop

    sub startIncomingLoop {

        # Called by $self->startSessionLoop
        # Starts this session's incoming data loop (which is subservient to the session loop)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the incoming data loop is already running or if the
        #       parent session loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startIncomingLoop', @_);
        }

        if (defined $self->incomingLoopCheckTime || ! defined $self->sessionLoopObj) {

            # The incoming data loop is already running or the parent session loop is not running
            return undef;

        } else {

            # Do the first spin of the incoming data loop as soon as possible
            $self->ivPoke('incomingLoopCheckTime', 0);

            # Incoming data loop initialisation
            #   (nothing to do)

            return 1;
        }
    }

    sub stopIncomingLoop {

        # Called by $self->stopSessionLoop
        # Stops this session's incoming data loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the incoming data loop is not running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopIncomingLoop', @_);
        }

        if (! defined $self->incomingLoopCheckTime) {

            # The incoming data loop isn't running
            return undef;

        } else {

            # Stop the incoming data loop
            $self->ivUndef('incomingLoopCheckTime');

            # Incoming data loop shutdown
            #   (nothing to do)

            return 1;
        }
    }

    sub spinIncomingLoop {

        # Called by $self->spinSessionLoop periodically, or by any other code which needs to spin
        #   this incoming data loop immediately
        # Spins the session's incoming data loop
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if another of the session's subservient loops is
        #       currently spinning, if the incoming data loop is not running or if the loop spin is
        #       halted prematurely
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my ($haltFlag, $text, $result);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->spinIncomingLoop', @_);
        }

        # If another of the session's subservient loops is currently spinning, or if the incoming
        #   data loop is not running, do nothing
        if ($self->childLoopSpinFlag || ! defined $self->incomingLoopCheckTime) {

            return undef;

        } else {

            # Don't let other subservient loops spin, until this loop is finished
            $self->ivPoke('childLoopSpinFlag', TRUE);
        }

        # If an MXP crosslinking operation is ready to start, start it (before continuing to
        #   process incoming data, as usual)
        # NB Crosslinking operations are only permitted when there is a real connection ('offline'
        #   connections don't count)
        if ($self->mxpRelocateMode eq 'wait_start' && $self->status eq 'connected') {

            # Check there's actually a current connection to terminate
            if (! $self->connectObj) {

                # Abandon the operation
                $self->ivPoke('mxpRelocateMode', 'none');
                $self->ivUndef('mxpRelocateHost');
                $self->ivUndef('mxpRelocatePort');
                $self->ivPoke('mxpRelocateQuietFlag', FALSE);
                $self->ivPoke('mxpRelocateQuietLineFlag', FALSE);

            } else {

                # Initiate the operation
                $self->mxpDoRelocate();
                if ($self->status eq 'disconnected') {

                    # Connection to new server failed; the incoming data loop has already halted
                    $haltFlag = TRUE;
                }
            }
        }

        # When a crosslinking operation is not in progress, check the connection is still valid
        if ($self->mxpRelocateMode eq 'none') {

            # Check connection is still valid
            if (! $self->connectObj) {

                # Connection already closed, or session is connected in offline mode
                $self->ivPoke('childLoopSpinFlag', FALSE);
                return undef;

            } elsif ($self->protocol eq 'telnet' && $self->connectObj->eof()) {

                # Telnet session has disconnected
                $self->reactDisconnect();
                $haltFlag = TRUE;
            }
        }

        if ($haltFlag) {

            # Halt this loop spin prematurely
            $self->ivPoke('childLoopSpinFlag', FALSE);
            return undef;
        }

        # Read incoming data

        # v1.0.242 - Surprisingly, ->get doesn't return all the data that has been received; this
        #   can lead to ->processIncomingData being called to process half a line, when the rest of
        #   the line has actually been received by GA::Obj::Telnet (bad news for any triggers that
        #   might match the whole line). Therefore we need to continue polling GA::Obj::Telnet until
        #   it returns 'undef'
        $text = '';
        do {

            $result = $self->connectObj->get(
                Errmode => sub { },                 # Do nothing on error
                Timeout => 0,
            );

            if (defined $result) {

                $text .= $result;
            }

        } until (! defined $result);

        if ($text) {

            # Check our status and amend it, if need be
            if ($self->status eq 'connecting') {

                $self->connectionComplete();
            }

            # Decode $text from the world's character set into standard Perl utf-8
            if ($self->sessionCharSet ne 'null') {

                $text = Encode::decode($self->sessionCharSet, $text);
            }

            if ($self->startCompleteFlag) {

                # Display the text in the 'main' window, if $self->start has finished its jobs...
                $self->processIncomingData($text);

            } else {

                # ...otherwise store the incoming text and wait for $self->start to finish
                $self->ivPoke('initialTextBuffer', $self->initialTextBuffer . $text);
            }

            # If this session isn't the 'main' window's visible session, set the flag which tells
            #   $self->getTabLabelText (and ->checkTabLabels) that the tab's label colour should be
            #   changed
            if ($self->mainWin->visibleSession && $self->mainWin->visibleSession ne $self) {

                $self->ivPoke('showNewTextFlag', TRUE);
            }
        }

        # Convert text to speech, if required
        if ($axmud::CLIENT->systemAllowTTSFlag && $self->ttsBuffer) {

            # Make sure the received text is visible in the 'main' window...
            $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->spinIncomingLoop');
            # ...before converting text to speech
            if (
                $axmud::CLIENT->ttsVerboseFlag
                && defined $self->ttsLastType
                && $self->ttsLastType ne 'receive'
            ) {
                # Last TTS conversion was something other than received text
                $axmud::CLIENT->tts(
                    'Received text: ' . $self->ttsBuffer,
                    'receive',
                    'receive',
                    $self,
                );

            } else {

                # (Don't read out 'received text' again and again and again!
                $axmud::CLIENT->tts($self->ttsBuffer, 'receive', 'receive', $self);
            }
        }

        # Always empty the buffer, in case ->systemAllowTTSFlag has been set in the last microsecond
        #   (or something)
        $self->ivPoke('ttsBuffer', '');

        # We can now display an automatic login confirmation message, if one has been prepared
        if ($self->loginConfirmText) {

            $self->writeText($self->loginConfirmText);
            $self->ivUndef('loginConfirmText');
        }

        # Allow other loops to spin
        $self->ivPoke('childLoopSpinFlag', FALSE);

        return 1;
    }

    sub doConnect {

        # Called by $self->start, and also by $self->mxpDoRelocate
        # Attempts to connect to the world specified by $self->host and $self->port
        #
        # Expected arguments
        #   $host       - The world's host address (default 127.0.0.1)
        #   $port       - The world's port (default 23)
        #
        # Optional arguments
        #   $protocol   - When called by $self->mxpDoRelocate, the protocol to use ('telnet', 'ssh'
        #                   or 'ssl')
        #
        # Return values
        #   'undef' on improper arguments or if the attempted connection fails
        #   1 otherwise

        my ($self, $host, $port, $protocol, $check) = @_;

        # Local variables
        my (
            $user, $pass, $capProtocol, $connectObj, $longHost, $sshObj, $ptyObj, $pid, $sslObj,
            $historyObj,
        );

        # Check for improper arguments
        if (! defined $host || ! defined $port || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doConnect', @_);
        }

        # Make sure any 'Connecting...' messages are visible immediately
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->doConnect');

        # Decide which protocol to use, if one was not specified by the calling function
        if (! $protocol) {

            if ($self->initProtocol) {

                $protocol = $self->initProtocol;

            } elsif ($self->currentWorld->protocol) {

                $protocol = $self->currentWorld->protocol;
            }
        }

        # Failsafe - default protocol is 'telnet'. If SSL is not available on this system, any SSL
        #   connection reverts to a telnet connection
        if (
            ! $protocol
            || ($protocol ne 'telnet' && $protocol ne 'ssh' && $protocol ne 'ssl')
            || ($axmud::NO_SSL_FLAG && $protocol eq 'ssl')
        ) {
            $protocol = 'telnet';
        }

        # If using a temporary profile and the 'ssh' protocol, prompt the user for an SSH username/
        #   password. If the user declines to provide one, revert to the 'telnet' protocol
        # The same thing happens if the current world profile doesn't provide at least a
        #   ->sshUserName
        if ($protocol eq 'ssh') {

            if (
                ($self->initTempFlag && $self->initSshFlag)
                || ! $self->currentWorld->sshUserName
            ) {
                # Prompt the user for an SSH username/password
                ($user, $pass) = $self->mainWin->showDoubleEntryDialogue(
                    'SSH login',
                    'Enter the SSH username',
                    'Enter the SSH password',
                );

                if ($user && $pass) {

                    # Update the world profile's IVs
                    $self->currentWorld->ivPoke('protocol', 'ssh');
                    $self->currentWorld->ivPoke('sshUserName', $user);
                    if ($pass) {

                        $self->currentWorld->ivPoke('sshPassword', $pass);
                    }

                } else {

                    # Default back to telnet
                    $protocol = 'telnet';
                    $self->writeText(
                        'SSH username/password not set; reverting to a telnet connection...',
                    );

                    $self->writeText(' ');      # Blank line
                }

            } else {

                $user = $self->currentWorld->sshUserName;
                $pass = $self->currentWorld->sshPassword;
            }
        }

        # (Make sure any system messages so far are actually visible, in case the connection hangs,
        #   by calling GA::Obj::Desktop->updateWidgets
        if ($self->mxpRelocateMode eq 'none') {

            if ($protocol eq 'telnet') {
                $capProtocol = $protocol;
            } else {
                $capProtocol = uc($protocol);
            }

            $self->writeText(
                'Connecting (via ' . $capProtocol . ') to \'' . $host . ' ' . $port . '\'...',
            );
        }

        # Update some initial IVs, so that we can call $self->getHostLabelText
        $self->ivPoke('protocol', $protocol);
        $self->ivPoke('status', 'connecting');
        # Create a new connection history object, if allowed
        if ($axmud::CLIENT->connectHistoryFlag) {

            $historyObj = Games::Axmud::Obj::ConnectHistory->new($self);
            if ($historyObj) {

                # Update session IVs
                $self->ivPoke('connectHistoryObj', $historyObj);
                # Update the object's ->currentTime every second
                $self->ivPoke('historyCheckTIme', $self->sessionTime + 1);

                # Update world profile IVs
                $self->currentWorld->ivPush('connectHistoryList', $historyObj);
            }
        }

        # Update the connection info strip object for any 'internal' windows used by this
        #   session (should only be one, at this point)
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            $winObj->setHostLabel($self->getHostLabelText());
        }

        # Make sure any system messages so far are actually visible, in case the connection hangs
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->doConnect');

        # Connect to the world using the specified protocol
        if ($protocol eq 'telnet') {

            # Connect using GA::Obj::Telnet
            $connectObj = Games::Axmud::Obj::Telnet->new(
                Axmud_session   => $self,
                Errmode         => 'return',
                Timeout         => $self->connectTimeOut,
            );

            if (! $connectObj) {

                $self->writeError(
                    'System telnet error',
                    $self->_objClass . '->doConnect',
                );

                # React to the disconnection
                $self->reactDisconnect();

                # Return 'undef' to show failure
                return undef;
            }

        } elsif ($protocol eq 'ssh') {

            # The first argument in the call to Net::OpenSSH->new is in the form
            #   'jack@foo.bar.com'
            #   'jack:secret@foo.bar.com:10022');
            #   'jsmith@2001:db8::1428:57ab');      # IPv6
            # In addition, IPv6 addresses can be enclosed in brackets (which we will do)
            #   'jsmith@[::1]:1022'

            # Compose the first argument
            $longHost = $user;
            if ($pass) {

                $longHost .= ':' . $pass;
            }

            if ($self->currentWorld->ipv6 && $self->currentWorld->ipv6 eq $host) {
                $longHost .= '@[' . $host . ']';
            } else {
                $longHost .= '@' . $host;
            }

            if ($self->currentWorld->sshPortFlag) {

                $longHost .= ':' . $port;
            }

            # Connect using Net::OpenSSH
            $sshObj = Net::OpenSSH->new(
                $longHost,
                timeout     => $self->connectTimeOut,
                master_opts => [ -o => "StrictHostKeyChecking=no" ],
            );

            if ($sshObj) {

                ($ptyObj, $pid) = $sshObj->open2pty();

                if ($ptyObj) {

                    $connectObj = Games::Axmud::Obj::Telnet->new(
                        -fhopen                     => $ptyObj,
                        Axmud_session               => $self,
                        Errmode                     => 'return',
                        Timeout                     => $self->connectTimeOut,
                    );
                }
            }

            if (! $connectObj) {

                $self->writeError(
                    'System SSH error',
                    $self->_objClass . '->doConnect',
                );

                # React to the disconnection
                $self->reactDisconnect();

                # Return 'undef' to show failure
                return undef;
            }

        } elsif ($protocol eq 'ssl') {

            # Connect using IO::Socket::SSL and GA::Obj::Telnet
            $sslObj = IO::Socket::SSL->new(
                PeerAddr        => $host,
                PeerPort        => $port,
                SSL_verify_mode => 0x00,
            );

            if ($sslObj) {

                $connectObj = Games::Axmud::Obj::Telnet->new(
                    -fhopen         => $sslObj,
                    Axmud_session   => $self,
                    Errmode         => 'return',
                    Timeout         => $self->connectTimeOut,
                );
            }

            if (! $connectObj) {

                $self->writeError(
                    'System SSL error',
                    $self->_objClass . '->doConnect',
                );

                # React to the disconnection
                $self->reactDisconnect();

                # Return 'undef' to show failure
                return undef;
            }
        }

        # Telnet option / sub-option negotiation
        $connectObj->option_callback(sub {

            my ($obj, $option, $isRemote, $isEnabled, $wasEnabled, $bufPosn) = @_;

            return $self->optCallback(
                $obj,
                $option,
                $isRemote,
                $isEnabled,
                $wasEnabled,
                $bufPosn,
            );
        });

        $connectObj->suboption_callback(sub {

            my ($obj, $option, $parameters) = @_;

            return $self->subOptCallback($obj, $option, $parameters);
        });

        # Use GA::Obj::Telnet's option negotiation ability to write logfiles, if the GA::Client's
        #   flag is set
        if ($axmud::CLIENT->debugTelnetLogFlag) {

            $connectObj->option_log($axmud::TOP_DIR . '/telopt.log');
        }

        # Prepare telnet options
        $self->prepareTelnetOptions($connectObj);
        # Prepare MUD protocols
        $self->prepareMudProtocols($connectObj);

        if ($protocol eq 'telnet') {

            $connectObj->open(
                Host        => $host,
                Port        => $port,
                Family      => 'any',       # Permit ipv4 or ipv6
                Errmode     => sub { return $self->connectionError(shift); },
            );

        } else {

            # For SSH, ivp4/ipv6 is already supported by the code above
            # For SSL, ipv4 and ipv6 are already enabled, due to IO::Socket::SSL being able to call
            #   on IO::Socket::INET6
            $connectObj->errmode( sub { return $self->connectionError(shift); } );
        }

        # If the connection is refused (e.g. an invalid host is specified),
        #   $self->connectionError will be called before the following lines of code can be
        #   executed.
        if ($self->status ne 'disconnected' && $self->status ne 'offline') {

            # Update IVs
            $self->ivPoke('connectObj', $connectObj);
            $self->ivPoke('sshObj', $sshObj);
            $self->ivPoke('ptyObj', $ptyObj);
            $self->ivPoke('sslObj', $sslObj);
            $self->ivPoke('host', $host);
            $self->ivPoke('port', $port);
        }

        return 1;
    }

    sub doDisconnect {

        # Called by $self->stop and also by GA::Cmd::Exit->do, XXit->do, etc
        # Terminates the connection immediately (if $self->status is 'connecting' or 'connected')
        # (Hooks using the 'disconnect' event do not fire)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $flag   - If TRUE, don't update IVs, because the calling function is about to call
        #               ->reactDisconnect to handle that. If FALSE (or 'undef'), IVs are updated
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doDisconnect', @_);
        }

        # On disconnection, $self->reactDisconnect might be called before $self->doDisconnect has
        #   finished (such as during blind mode, when the 'Disconnected' message is still being read
        #   aloud). Use a flag to prevent ->reactDisconnect doing anything until this function is
        #   finished
        $self->ivPoke('doDisconnectFlag', TRUE);

        # Turn off overwrite mode in the session's default textview object (if on), allowing
        #   disconnection messages to be visible
        # Also turn off the visible cursor
        if ($self->defaultTabObj) {

            $self->defaultTabObj->textViewObj->disableOverwrite();
            $self->defaultTabObj->textViewObj->set_cursorEnableFlag(FALSE);
        }

        if ($self->status eq 'connecting' || $self->status eq 'connected') {

            # Terminate the connection
            $self->connectObj->close();

            # Halt any MSP sound files that are currently playing
            foreach my $soundObj ($self->ivValues('soundHarnessHash')) {

                $soundObj->stop();
            }

            $self->ivEmpty('soundHarnessHash');

            # Show confirmation. If sessions don't share a 'main' window and one of the windows is
            #   suddenly closed, this message would be diverted to one of the other 'main' windows,
            #   which we definitely don't want, so check for that
            if ($self->defaultTabObj) {

                $self->defaultTabObj->textViewObj->showSystemText('Disconnected from host');
            }

            # Update IVs (if allowed)
            if (! $flag) {

                $self->ivUndef('protocol');
                $self->ivUndef('connectObj');
                $self->ivUndef('sshObj');
                $self->ivUndef('ptyObj');
                $self->ivUndef('sslObj');

                if (! $axmud::CLIENT->offlineOnDisconnectFlag) {
                    $self->ivPoke('status', 'disconnected');
                } else {
                    $self->ivPoke('status', 'offline');
                }

                $self->ivPoke('mxpRelocateMode', 'none');

                $self->ivUndef('delayedQuitTime');
                $self->ivUndef('delayedQuitCmd');

                if (! $axmud::CLIENT->offlineOnDisconnectFlag) {
                    $self->ivPoke('disconnectTime', $axmud::CLIENT->localClock);
                } else {
                    $self->ivPoke('disconnectTime', undef);
                }

                if (! $axmud::CLIENT->offlineOnDisconnectFlag) {

                    $self->ivEmpty('interfaceHash');
                    $self->ivEmpty('interfaceNumHash');
                    $self->ivPoke('interfaceCount', 0);
                    $self->ivEmpty('deleteInterfaceList');

                    $self->ivEmpty('triggerHash');
                    $self->ivEmpty('triggerOrderList');
                    $self->ivEmpty('aliasHash');
                    $self->ivEmpty('aliasOrderList');
                    $self->ivEmpty('macroHash');
                    $self->ivEmpty('macroOrderList');
                    $self->ivEmpty('timerNumHash');
                    $self->ivEmpty('timerClockHash');
                    $self->ivUndef('timerLastClock');
                    $self->ivEmpty('timerOrderList');
                    $self->ivEmpty('hookHash');
                    $self->ivEmpty('hookOrderList');
                }

                # Update the world's connection history object, if one was created for this session
                if ($self->connectHistoryObj) {

                    $self->connectHistoryObj->set_disconnectedTime();
                }
            }

            # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
            $axmud::CLIENT->desktopObj->restrictWidgets();

            # Make sure the command entry box isn't obscured in any 'internal' windows used by this
            #   session
            foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

                my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
                if ($stripObj) {

                    $stripObj->obscureEntry(FALSE);
                }
            }
        }

        # Operation complete
        $self->ivPoke('doDisconnectFlag', FALSE);

        return 1;
    }

    sub doTempDisconnect {

        # Alternative to ->doDisconnect, called by $self->mxpDoRelocate
        # Disconnects the current connection, but doesn't reset all IVs, in the expectation that
        #   some of them apply to the new server, once the connection to it is completed
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doTempDisconnect', @_);
        }

        # Terminate the connection
        $self->connectObj->close();

        # (Allow MSP sound files, if any, to continue playing)

        # Show confirmation
        if (! $self->mxpRelocateQuietFlag) {

            $self->writeText(
                'Relocating (via ' . $self->protocol . ') to new server, \'' . $self->initHost
                . ' ' . $self->initPort . '\'...',
            );
        }

        # Update (some) IVs
        $self->ivUndef('connectObj');
        $self->ivUndef('sshObj');
        $self->ivUndef('ptyObj');
        $self->ivUndef('sslObj');
        $self->ivPoke('status', 'disconnected');
        $self->ivPoke('loginFlag', FALSE);

        # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
        $axmud::CLIENT->desktopObj->restrictWidgets();

        # Make sure the command entry box isn't obscured in any 'internal' windows used by this
        #   session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if ($stripObj) {

                $stripObj->obscureEntry(FALSE);
            }
        }

        return 1;
    }

    sub reactDisconnect {

        # Called by $self->connectionError when the GA::Obj::Telnet object reports an error (usually
        #   due to the host disconnecting us)
        # Also called by ->incomingDataLoop when it reads an end-of-file (usually due to the host
        #   disconnecting us)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $flag   - If TRUE, a confirmation message has already been displayed (by a call to
        #               $self->doDisconnect). If FALSE (or 'undef'), this function must display a
        #               confirmation message
        #
        # Return values
        #   'undef' on improper arguments, if a call to $self->doDisconnect hasn't finished yet or
        #       if this function has already been called
        #   1 otherwise

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reactDisconnect', @_);
        }

        # On disconnection, this function might be called before $self->doDisconnect has finished
        #   (such as during blind mode, when the 'Disconnected' message is still being read aloud).
        #   Use a flag to prevent this
        if ($self->doDisconnectFlag) {

            return undef;

        # On disconnection, this function is called from several places in the session code. In
        #   rare circumstances (such as the GA::Obj::Telnet object returning TRUE to an ->eof()
        #   call), it might be called more than once. Use a flag to ignore subsequent calls
        } elsif ($self->reactDisconnectFlag) {

            return undef;

        } else {

            # Ignore subsequent calls to this function
            $self->ivPoke('reactDisconnectFlag', TRUE);
        }

        # Turn off overwrite mode in the session's default textview object (if on), allowing
        #   disconnection messages to be visible
        # Also turn off the visible cursor
        if ($self->defaultTabObj) {

            $self->defaultTabObj->textViewObj->disableOverwrite();
            $self->defaultTabObj->textViewObj->set_cursorEnableFlag(FALSE);
        }

        # Update the world's connection history object, if one was created for this session
        if ($self->connectHistoryObj) {

            $self->connectHistoryObj->set_disconnectedTime();
        }

        # If MXP is enabled, a link loss causes any open tags to be closed
        if ($self->mxpMode eq 'client_agree' && defined $self->mxpLineMode) {

            $self->emptyMxpStack();
        }

        # If MCP is enabled, close any MCP cords that are open
        foreach my $id ($self->ivKeys('mcpCordCloseHash')) {

            my $funcRef = $self->ivShow('mcpCordCloseHash', $id);

            # Notify the code that MCP cord has been closed, as usual
            if (defined $funcRef) {

                &$funcRef($self, $id);

                # Update session IVs
                $self->ivAdd('mcpCordIDHash', $id, undef);
                $self->ivDelete('mcpCordCloseHash', $id);
            }
        }

        # Display confirmation messages, if necessary
        if (! $flag) {

            $self->writeText('Connection terminated by host');
            # If TTS is enabled, provide an audio confirmation too, interrupting any other text
            #   which is currently being converted to speech
            if ($axmud::CLIENT->systemAllowTTSFlag) {

                $axmud::CLIENT->ttsAddUrgentJob('You have been disconnected');
            }
        }

        if ($axmud::CLIENT->offlineOnDisconnectFlag) {

            $self->writeText('Switching to \'connect offline\' mode');
        }

        # Fire any hooks that are using the 'disconnect' hook event (but only while connected, and
        #   if allowed)
        if ($self->status eq 'connected') {

            $self->checkHooks('disconnect');
        }

        # Empty the repeat object and excess command lists - we don't want to continue sending
        #   commands after a disconnection
        $self->ivEmpty('repeatObjList');
        $self->ivPoke('excessCmdCount', 0);
        $self->ivEmpty('excessCmdList');
        $self->ivPoke('crawlModeFlag', FALSE);
        $self->ivPoke('crawlModeCmdLimit', undef);
        $self->ivPoke('crawlModeCheckTime', undef);

        # Save files (but only while connected, or while disconnecting; and only if allowed)
        if (
            (
                $self->status eq 'connected'
                || $self->status eq 'disconnected'
                || $self->status eq 'offline'
            ) && ! $self->disconnectNoSaveFlag
        ) {
            $self->pseudoCmd('save');
        }

        # Update IVs
        $self->ivUndef('protocol');
        $self->ivUndef('connectObj');
        $self->ivUndef('sshObj');
        $self->ivUndef('ptyObj');
        $self->ivUndef('sslObj');

        if (! $axmud::CLIENT->offlineOnDisconnectFlag) {
            $self->ivPoke('status', 'disconnected');
        } else {
            $self->ivPoke('status', 'offline');
        }

        $self->ivPoke('mxpRelocateMode', 'none');

        $self->ivUndef('delayedQuitTime');
        $self->ivUndef('delayedQuitCmd');

        if (! $axmud::CLIENT->offlineOnDisconnectFlag) {
            $self->ivPoke('disconnectTime', $axmud::CLIENT->localClock);
        } else {
            $self->ivPoke('disconnectTime', undef);
        }

        if (! $flag) {

            # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
            $axmud::CLIENT->desktopObj->restrictWidgets();
        }

        if (! $axmud::CLIENT->offlineOnDisconnectFlag) {

            # Remove all active interfaces
            $self->ivEmpty('interfaceHash');
            $self->ivEmpty('interfaceNumHash');
            $self->ivPoke('interfaceCount', 0);
            $self->ivEmpty('deleteInterfaceList');

            $self->ivEmpty('triggerHash');
            $self->ivEmpty('triggerOrderList');
            $self->ivEmpty('aliasHash');
            $self->ivEmpty('aliasOrderList');
            $self->ivEmpty('macroHash');
            $self->ivEmpty('macroOrderList');
            $self->ivEmpty('timerNumHash');
            $self->ivEmpty('timerClockHash');
            $self->ivUndef('timerLastClock');
            $self->ivEmpty('timerOrderList');
            $self->ivEmpty('hookHash');
            $self->ivEmpty('hookOrderList');


            # Stop the session loop for this session (if running; not a fatal error if the loop
            #   can't be stopped, as we still need to terminate the connection itself)
            if ($self->sessionLoopObj && ! $self->stopSessionLoop()) {

                $self->writeError(
                    'Could not stop the session loop',
                    $self->_objClass . '->reactDisconnect',
                );
            }

            # Close any 'free' windows produced by this session
            foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionFreeWins($self)) {

                # As one 'free' window is closed, its child 'free' windows are also closed, so we
                #   have to check the window still exists, before destroying it
                if ($axmud::CLIENT->desktopObj->ivExists('freeWinHash', $winObj->number)) {

                    $winObj->winDestroy();
                }
            }

            # Check if there are any remaining 'grid' windows associated with this session and, if
            #   so, close them (but still don't close the 'main' window)
            $axmud::CLIENT->desktopObj->removeSessionWindows($self);

            # If this session has any 'external' windows on this session's workspace grid, and if
            #   this wasn't the current session, those 'external' windows may be invisible/
            #   minimised. Make them visible
            $axmud::CLIENT->desktopObj->revealGridWins($self);

        } else {

            # After switching to 'offline' mode, reset all running tasks (as if the session had just
            #   started in 'offline' mode
            foreach my $taskObj ($self->ivValues('currentTaskHash')) {

                if ($taskObj->status eq 'running' || $taskObj->status eq 'paused') {

                    $taskObj->set_status('reset');
                }
            }

            # Spin the task loop so that current tasks are reset immediately
            $self->spinTaskLoop();
        }

        # Update this session's tab label to mark the session as disconnected (or in 'offline'
        #   mode). The TRUE flag forces the function to update the tab label
        $self->checkTabLabels(TRUE);

        # Update strip objects for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj;

            # Update information stored in each 'internal' window's connection info strip, if
            #   visible
            $winObj->setHostLabel($self->getHostLabelText());
            $winObj->setTimeLabel($self->getTimeLabelText());

            # Reset the 'internal' window's entry box
            $winObj->resetEntry();
            $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if (
                $stripObj
                && $winObj->visibleSession
                && $winObj->visibleSession eq $self
            ) {
                $stripObj->obscureEntry(FALSE);
                # Must stop the console button from flashing, because this session's maintain loop
                #   is also halting if we're now in 'disconnected' mode
                $stripObj->reset_consoleIconFlash();
            }

            # Reset the 'internal' window's blinkers, if any
            $self->turnOffBlinker(-1);      # Turn them all off
            $winObj->resetBlinkers();

            if (! $axmud::CLIENT->offlineOnDisconnectFlag) {

                # Remove all gauges for this session, and redraw the gauge box
                # The TRUE flag means that the gauge box should be removed immediately if there are
                #   no gauges left (belonging to other sessions), rather than waiting a few seconds,
                #   as we normally would
                $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::GaugeBox');
                if ($stripObj) {

                    $stripObj->removeSessionGauges($self, TRUE);
                }
            }
        }

        # Make sure all changes are visible immediately
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->reactDisconnect');

        # Update gauge IVs stored by MXP
        $self->ivUndef('mxpGaugeLevel');
        $self->ivEmpty('mxpGaugeHash');

        return 1;
    }

    sub connectionError {

        # Callback, called by $self->doConnect when the GA::Obj::Telnet object reports an error
        #   (usually due to a disconnection)
        #
        # Expected arguments
        #   $errorMsg   - The error message passed by GA::Obj::Telnet
        #
        # Return values
        #   'undef'

        my ($self, $errorMsg, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->connectionError', @_);
        }

        # NB If attempting a connection to a host, where both the host address and host port are
        #   invalid (c.f. 'telnet deathmud'), this function is called twice. If we are already
        #   disconnected, don't display a second error
        if ($self->status eq 'disconnected' || $self->status eq 'offline') {

            return undef;
        }

        # If GA::Obj::Telnet's error message is one we recognise, use our own error message
        if (
            $errorMsg =~ m/Name or service not known/i
            || $errorMsg =~ m/Unknown (remote|local) host/i
        ) {
            if ($self->mxpRelocateMode eq 'none') {

                $self->writeText(
                    'Unrecognised host \'' . $self->initHost . '\'',
                    $self->_objClass . '->connectionError',
                );

            } else {

                # During an MXP crosslinking operation, show a longer message so the user isn't
                #   left bewildered by a sudden disconnection message when the world specified a
                #   <QUIET> relocation
                $self->writeText(
                    'Relocation to new server failed, unrecognised host \''
                    . $self->mxpRelocateHost . '\'',
                    $self->_objClass . '->connectionError',
                );
            }

            # React to the disconnection. The TRUE flag means that we've already displayed a message
            $self->reactDisconnect(TRUE);

        } elsif ($errorMsg =~ m/problem connecting.*connection refused/i) {

            if ($self->mxpRelocateMode eq 'none') {

                $self->writeText(
                    'Connection to \'' . $self->initHost . '\' refused',
                    $self->_objClass . '->connectionError',
                );

            } else {

                $self->writeText(
                    'Relocation to new server failed, connection to \'' . $self->mxpRelocateHost
                    . '\' refused',
                    $self->_objClass . '->connectionError',
                );
            }

            # React to the disconnection
            $self->reactDisconnect(TRUE);

        } elsif ($errorMsg =~ m/problem connecting.*connect timed\-out/i) {

            if ($self->mxpRelocateMode eq 'none') {

                $self->writeText(
                    'Connection to \'' . $self->initHost . '\' timed out',
                    $self->_objClass . '->connectionError',
                );

            } else {

                $self->writeText(
                    'Relocation to new server failed, connection to \'' . $self->initHost
                    . '\' timed out',
                    $self->_objClass . '->connectionError',
                );
            }

            # React to the disconnection
            $self->reactDisconnect(TRUE);

        } else {

            # Otherwise, use the error message GA::Obj::Telnet gave us
            $self->writeError(
                ucfirst($errorMsg),
                $self->_objClass . '->connectionError',
            );

            # React to the disconnection. Let $self->reactDisconnect display the standard
            #   'Connection terminated by host' message
            $self->reactDisconnect(FALSE);
        }

        # GA::Obj::Telnet requires us to return 'undef'
        return undef;
    }

    sub connectionComplete {

        # Called by $self->spinIncomingLoop when the first text is received by the world, which
        #   signals that the connection is complete
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->connectionComplete', @_);
        }

        # We are now connected
        $self->ivPoke('status', 'connected');
        if (! $self->mxpRelocateQuietFlag) {

            $self->writeText('Connected');
        }

        # Store the time at which the connection was actually achieved, so GA::Strip::ConnectInfo
        #   can use it as a tooltip
        # (After an MXP crosslinking operation, the time connected to the new server is displayed)
        $self->ivPoke('connectedTimeString', $axmud::CLIENT->localTime());
        # Update the world's connection history object, if one was created for this session
        if ($self->connectHistoryObj) {

            $self->connectHistoryObj->set_connectedTime();
        }

        # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
        $axmud::CLIENT->desktopObj->restrictWidgets();

        # Update the connection info strip object for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            $winObj->setHostLabel(
                $self->getHostLabelText(),
                'Connected since ' . $self->connectedTimeString,
            );
        }

        # (Make sure that message is visible immediately)
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->connectionComplete');

        if ($self->currentWorld->loginMode eq 'immediate') {

            # Automatic login mode 'immediate' - immediate login (character marked as 'logged in' as
            #   soon as the connection is established)
            $self->doLogin();

        } else {

            # Set the time at which $self->spinMaintainLoop should show a warning that the character
            #   hasn't logged in yet
            $self->ivPoke(
                'loginWarningTime',
                ($self->sessionTime + $axmud::CLIENT->loginWarningTime),
            );
        }

        # Fire any hooks that are using the 'connect' hook event
        $self->checkHooks('connect');

        return 1;
    }

    # (Process incoming data)

    sub processIncomingData {

        # Called by $self->spinIncomingLoop when text is received from the world
        # Also called by $self->start when it's finished its setup jobs, and needs to display any
        #   text received from the world in the meantime
        # Called by GA::Cmd::SimulateWorld->do to simulate text received from the world
        #
        # Processes the received text. Calls $self->tokeniseIncomingData to convert into a series
        #   of tokens, then processes the tokens, before calling $self->respondIncomingData to
        #   display a complete or partial line
        #
        # Expected arguments
        #   $text           - The received text to process
        #
        # Optional arguments
        #   $noBlinkFlag    - If set to TRUE, a blinker in 'internal' windows for this session is
        #                       not turned on. If set to FALSE (or 'undef'), the blinker is turned
        #                       on as normal. This flag can be set to TRUE if this function is
        #                       called to display text that wasn't actually received from the world
        #                       (e.g. when called by the ';simulateworld' client command)
        #
        # Return values
        #   'undef' on improper arguments or if $text is an empty string
        #   1 otherwise

        my ($self, $text, $noBlinkFlag, $check) = @_;

        # Local variables
        my $enableFlag;

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processIncomingData', @_);
        }

        # Don't process an empty string
        if ($text eq '') {

            return undef;

        } else {

            $self->ivIncrement('packetCount');
        }

        # Turn on the window blinker (but only if this is the current session), and update blinker
        #   IVs (unless $noBlinkFlag is set, in which case we're not displaying text that was
        #   actually received from the world)
        if (! $noBlinkFlag) {

            $self->turnOnBlinker(0);
        }

        # If the last text received from the world was an incomplete line that might have been #
        #   either a command prompt or just an incomplete line, we can now say that it was
        #   definitely an incomplete line
        if (defined $self->promptCheckTime) {

            $self->ivUndef('promptCheckTime');
        }

        # If the emergency buffer was set on the previous call to this function, add its contents
        #   to the start of the received text
        if ($self->emergencyBuffer) {

            $text = $self->emergencyBuffer . $text;
            $self->ivUndef('emergencyBuffer');
        }

        # Insert an initial newline character in certain circumstances
        if (
            # There needs to be a newline character
            ($self->promptInsertFlag || $self->nlEchoFlag)
            # ...and the world hasn't supplied one
            && ! ($text =~ m/^[\n\r]/)
            # And the current textview object hasn't just inserted one (e.g. after a system message)
            && ! $self->currentTabObj->textViewObj->insertNewLineFlag
            && $self->currentTabObj->textViewObj->bufferTextFlag
        ) {
            # Don't actually insert a newline character if in special echo mode...
            if ($self->specialEchoMode ne 'enabled') {

                $text = "\n" . $text;
            }

            # ...but reset the IVs regardless
            $self->ivPoke('promptFlag', FALSE);
            $self->ivPoke('promptInsertFlag', FALSE);
            $self->ivUndef('promptCheckTime');
            $self->ivPoke('nlEchoFlag', FALSE);
        }

        # On the first line, detect Pueblo (if allowed)
        $self->detectPueblo($text);

        # Detect MCP (if allowed; not necessarily on the first line)
        $self->detectMCP($text);

        # Tokenise the incoming text, storing it in $self->currentTokenList rather than one of this
        #   function's local variables (so various parts of the code, called from this function, can
        #   artificially insert tokens if they need to)
        $self->tokeniseIncomingData($text);

        # @tokenList might be empty if only an incomplete token (such as an incomplete MXP tag) was
        #   found, in which case it's been stored in the emergency buffer, waiting for the next
        #   packet
        if ($self->currentTokenList) {

            # Display the raw $text, if we're required to - in the terminal, in the RawText task
            #   window and/or in the RawToken task window
            $self->displayRawIncomingData($text);

            # Proces the tokens, one a time. Processing the token involves:
            #   1. Updating GA::Session IVs
            #   2. Converting non-text tokens into a list of Axmud colour/style tags (or, in some
            #       cases, some other kind of data)
            #   3. Displaying a complete or partial line. We wait as long as possible before
            #       applying triggers and displaying the text: the end of each line, at the latest.
            #       Some operations require as to display part of a line which we do, if necessary)
            #
            # When we come across newline tokens, we try to handle any of the following situations:
            #       1. CR
            #       2. LF
            #       3. CR, LF
            #       4. LF, CR
            #       5. CR, <one or more non-text tokens>, LF
            #       6. LF, <one or more non-text tokens>, CR
            # In addition, it's sometimes necessary to ignore a newline token
            # In general, we use the first newline token, ignoring the second (opposite) newline
            #   token (so CRLF is treated as if it were a single token). Any non-text tokens which
            #   have been sloppily inserted between them are treated as if they appeared on the next
            #   line

            do {

                my (
                    $type, $arg, $origLine, $stripLine, $tempMode, $listRef, $backup, $respondFlag,
                    $tokenFlag, $value, $reduceToken, $wholeText, $stripLength,
                    @tagList, @newTagList, @emptyList,
                    %lineTagHash,
                );

                # $self->currentTokenList is in the form (type, argument, type, argument...)
                #   - 'type' is one of the strings 'nl', 'ga', 'esc', 'inv', 'ctrl', 'seq', 'bsp',
                #       'msp', 'mxp', 'ent', 'pueblo', 'mcp', 'nomcp', 'text' or 'go'
                #   - (NB 'part' is used in calls to $self->respondIncomingData to show that an
                #       incomplete line is to be shown, but it's never added to this IV)
                #   - 'argument' is usually the token itself. For type 'ctrl' and 'seq', it's a list
                #       reference and for 'go', the argument (if defined) is ignored
                $type = $self->ivShift('currentTokenList');
                $arg = $self->ivShift('currentTokenList');

                # At the beginning of every loop, we retrieve these variables. They are set at the
                #   end of the loop, ready for the next one, but are reset by every call to
                #   $self->respondIncomingData
                $origLine = $self->processOrigLine;
                $stripLine = $self->processStripLine;
                %lineTagHash = $self->processTagHash;

                # Remember the current setting of $self->mxpTempMode after the previous iteration of
                #   this do.. loop
                $tempMode = $self->mxpTempMode;

                # A text or newline token cancels a prompt in all circumstances
                if ($type eq 'nl' || $type eq 'text') {

                    $self->ivUndef('promptCheckTime');
                    $self->ivPoke('promptFlag', FALSE);
                    $self->ivPoke('promptInsertFlag', FALSE);
                    $self->ivUndef('promptCheckTime');
                }

                # If Pueblo had been waiting for a new line which has since been processed, we can
                #   insert the Axmud style tag 'justify_default' on this loop
                if ($self->puebloJustifyMode eq 'wait_loop') {

                    if (exists $lineTagHash{0}) {

                        # There are already tags stored at this offset
                        $listRef = $lineTagHash{0};
                        push (@$listRef, 'justify_default');

                    } else {

                        # There are no tags stored at this offset yet
                        $lineTagHash{0} = ['justify_default'];
                    }

                    $self->ivPoke('puebloJustifyMode', 'normal');
                }

                # 1. Tokens consisting of a single newline character, "\n" or "\r"
                if ($type eq 'nl') {

                    $origLine .= $arg;
                    # This function calls various functions to process tokens. If one of those
                    #   called functions needs to call $self->respondIncomingData before processing
                    #   the token, then the value store in $origLine will be out of date, by the
                    #   time it's stored in $self->processOrigLine (later in this function)
                    # Make a backup copy of the token that's being processed on this loop, so
                    #   we can create an $origLine containing just this token, if we need to
                    $backup = $arg;

                    if (
                        ($arg eq "\n" && $self->crlfMode eq 'cr')
                        || ($arg eq "\r" && $self->crlfMode eq 'lf')
                    ) {
                        # This is the final part of situations 3-6. One newline character has
                        #   already been added, so we don't need to add the second
                        $self->ivPoke('crlfMode', '');

                    } else {

                        # Situations 1-2, or the first part of situations 3-6
                        if ($arg eq "\n") {
                            $self->ivPoke('crlfMode', 'lf');
                        } else {
                            $self->ivPoke('crlfMode', 'cr');
                        }

                        if ($self->mxpIgnoreNewLineFlag || $self->mxpRelocateQuietLineFlag) {

                            # Ignore this newline character (but not the next one)
                            $self->ivPoke('mxpIgnoreNewLineFlag', FALSE);
                            $self->ivPoke('mxpRelocateQuietLineFlag', FALSE);

                        } elsif (
                            ! $self->mxpParagraphFlag
                            && ! $self->mxpRelocateQuietFlag
                            && (! $self->puebloActiveFlag || $self->puebloLiteralFlag)
                        ) {
                            # Don't ignore the newline character. Tell the code below to call
                            #   ->respondIncomingData
                            $respondFlag = TRUE;
                        }
                    }

                # 2. Tokens consisting of an IAC GA 'go ahead' sequence
                } elsif ($type eq 'ga') {

                    $origLine .= $arg;
                    $backup = $arg;

                # 3. Tokens consisting of a single escape character "\e" immediately followed by a
                #       character that doesn't start a valid escape sequence (e.g. "\e", "\n" or
                #       "\r", or the IAC character chr(255))
                } elsif ($type eq 'esc') {

                    @tagList = $self->processEscChar($arg);
                    $origLine .= $arg;
                    $backup = $arg;

                # 4. Tokens consisting of an invalid escape sequence, starting with the escape
                #   character "\e"
                } elsif ($type eq 'inv') {

                    # Invalid escape sequence are ignored
                    $origLine .= $arg;
                    $backup = $arg;

                # 5. Tokens consisting of a valid escape sequence, starting with the escape
                #   character "\e" (any undisplayed text must be displayed, before processing this
                #   sequence)
                } elsif ($type eq 'ctrl') {

                    # In this case $arg is a reference to a list in the form
                    #   (token, escape_sequence_data, escape_sequence_type)

                    # If there are some text tokens to display (i.e. an incomplete line, not ending
                    #   with a newline character, was received from the world), then display them
                    if ($stripLine ne '') {

                        # Apply triggers to partial line, and display it
                        $self->respondIncomingData(
                            'part',
                            $origLine,
                            $stripLine,
                            %lineTagHash,
                        );

                    } else {

                        # Otherwise, %lineTagHash might contain colour/style tags that will apply to
                        #   the next token. If so, they must be applied to the current textview
                        #   object's ->colourStyleHash
                        $self->processEmptySegment(%lineTagHash);
                    }

                    # Process the escape sequence
                    $self->processCtrlSequence(@$arg);

                    # Tokens containing these types of escape sequence are not added to $origLine
                    #   (this makes the code much simpler)
                    @tagList = ();
                    $origLine = '';
                    $backup = '';

                    %lineTagHash = (0, \@emptyList);

                # 6. Tokens consisting of a valid escape sequence, starting with the escape
                #   character "\e" (any undisplayed text doesn't need to be displayed yet)
                } elsif ($type eq 'seq') {

                    # In this case, $arg is a reference to a list in the form
                    #   (token, escape_sequence_data, escape_sequence_type)
                    @tagList = $self->processEscSequence(@$arg);
                    $origLine .= $$arg[0];
                    $backup = $$arg[0];

                # 7. Backspace tokens in the form "\b \b" or just "\b"
                } elsif ($type eq 'bsp') {

                    if ($stripLine ne '') {

                        # Apply triggers to partial line, and display it
                        $self->respondIncomingData(
                            'part',
                            $origLine,
                            $stripLine,
                            %lineTagHash,
                        );

                    } else {

                        # Otherwise, %lineTagHash might contain colour/style tags that will apply to
                        #   the next token. If so, they must be applied to the current textview
                        #   object's ->colourStyleHash
                        $self->processEmptySegment(%lineTagHash);
                    }

                    # Process the token
                    $self->currentTabObj->textViewObj->insertBackspace($arg);

                    # Tokens containing these tokens are not added to $origLine (this makes the
                    #   code much simpler)
                    @tagList = ();
                    $origLine = '';
                    $backup = '';

                    %lineTagHash = (0, \@emptyList);

                # 8. MSP tokens, starting '!!SOUND' or '!!MUSIC' (if MSP is enabled)
                } elsif ($type eq 'msp') {

                    # Process the MSP sound token's parameters
                    if (! $self->processMspSoundTrigger($arg)) {

                        # Parameter processing failed; treat the token as normal text, instead
                        $type = 'text';

                    } else {

                        $origLine .= $arg;
                        $backup = $arg;
                    }

                # 9. MXP/Pueblo elements (if MXP or Pueblo are enabled)
                } elsif ($type eq 'mxp' || $type eq 'pueblo') {

                    # Line spacing tags <NOBR>, <P>, </P>, <BR>, <SBR>, as well as the HTML element
                    #   <HR>, are processed by a dedicated function
                    # Heading tags <H1>...<H6> are likewise sent to their own function
                    # Everything else is processed by ->processMxpElement or ->processPuebloElement
                    if (
                        $self->mxpMode eq 'client_agree'
                        && $axmud::CLIENT->ivExists(
                            'constMxpLineSpacingHash',
                            uc($arg),
                        )
                    ) {
                        $self->processMxpSpacingTag($arg);
                        $origLine .= $arg;
                        $backup = $arg;

                    } elsif (
                        $self->puebloMode eq 'client_agree'
                        && uc($arg) =~ m/\<\/?(BODY|BR|P|HR)\s?.*\>/
                    ) {
                        $self->processPuebloSpacingTag($arg);
                        $origLine .= $arg;
                        $backup = $arg;

                    } elsif (uc($arg) =~ m/\<\/?H[1-6]\s?.*\>/) {

                        @tagList = $self->processMxpHeadingTag($origLine, $arg);
                        $origLine .= $arg;
                        $backup = $arg;

                    } else {

                        # Process the tag
                        if ($self->mxpMode eq 'client_agree') {
                            ($tokenFlag, @tagList) = $self->processMxpElement($arg, $origLine);
                        } else {
                            ($tokenFlag, @tagList) = $self->processPuebloElement($arg, $origLine);
                        }

                        if (! $tokenFlag) {

                            # Tag processing failed; treat the token as normal text, instead
                            $type = 'text';

                        } else {

                            $origLine .= $arg;
                            $backup = $arg;
                        }
                    }

                # 10. MXP entities (if MXP is enabled)
                } elsif ($type eq 'ent') {

                    $value = $self->processMxpEntity($arg, $text);
                    if (! defined $value) {

                        # The entity was not substituted for its value. Treat the entity as a normal
                        #   text token
                        $type = 'text';

                    } else {

                        # The entity has been substituted for its value. Treat the value as a normal
                        #   text token
                        $type = 'text';
                        $arg = $value;
                    }

                # 11. MCP tokens, starting '#$#', and non-MCP tokens, starting '#$"' (if MCP is
                #   enabled)
                } elsif ($type eq 'mcp') {

                    $self->processMcpMsg($arg);
                    $origLine .= $arg;
                    $backup = $arg;

                } elsif ($type eq 'nomcp') {

                    $origLine .= $arg;
                    $backup = $arg;
                }

                # 12. An ordinary text token (possibly converted from some other token in the code
                #   above)
                if ($type eq 'text') {

                    # The newline character situations 3-6, detailed above, do not apply
                    $self->ivPoke('crlfMode', '');

                    # When Pueblo is active, reduce multiple whitespace characters to a single space
                    $reduceToken = $arg;
                    $wholeText = $self->getPartialLine() . $stripLine;
                    if ($self->puebloActiveFlag && ! $self->puebloLiteralSampFlag) {

                        $reduceToken =~ s/\s+/ /g;
                        if ($wholeText =~ m/\s$/) {

                            $reduceToken =~ s/^\s+//;
                        }
                    }

                    # Update the IV used to write logs showing images files
                    $self->ivPoke('processImageLine', $self->processImageLine . $reduceToken);

                    # Process the text token (unless an MXP crosslinking operation is in progress,
                    #   in quiet mode, in which case the token is simply ignored)
                    if (! $self->mxpRelocateQuietFlag && ! $self->mxpRelocateQuietLineFlag) {

                        $self->updateTextToken($reduceToken);

                        $origLine .= $reduceToken;
                        $stripLine .= $reduceToken;

                        $backup = $reduceToken;
                    }
                }

                # Handle MXP temp secure mode, if it is marked as 'on'
                if (defined $self->mxpTempMode) {

                    push (@tagList, $self->checkMxpSecureMode($tempMode));
                }

                # $stripLine contains only the combined text tags. Note the positions (offsets) in
                #   that string at which extracted Axmud colour/style tags must be applied
                if (@tagList) {

                    $stripLength = length($stripLine);
                    $listRef = $lineTagHash{$stripLength};
                    if (! defined $listRef) {

                        # There are no Axmud colour/style tags to be applied at this offset, yet
                        $lineTagHash{$stripLength} = \@tagList;

                    } else {

                        # There are already tags to be applied at this offset; add the new tag(s) to
                        #   the existing ones
                        push (@$listRef, @tagList);
                    }
                }

                # If a newline tag has just been processed (and not ignored), or if some other part
                #   of the code (e.g. the MXP code) has just signalled that it wants a newline by
                #   artificially inserting the token type 'go'...
                if (($type eq 'nl' && $respondFlag) || $type eq 'go') {

                    # Apply triggers to the complete or partial line, and display it
                    $self->respondIncomingData(
                        $type,
                        $origLine,
                        $stripLine,
                        %lineTagHash,
                    );

                    # ($self->processOrigLine, ->processStripLine and ->processTagHash have all been
                    #   reset, ready for the next line)

                    # Update IVs, and generate a list of any Axmud colour/style tags that apply to
                    #   the beginning of the next line (because of MXP stuff)
                    @newTagList = $self->updateEndLine($type);
                    if (@newTagList) {

                        $listRef = $self->ivShow('processTagHash', 0);
                        if (! defined $listRef) {

                            # $self->respondIncomingData should have created an empty list of tags
                            #   at the offset 0, representing the beginning of the line; but just in
                            #   case it doesn't exist, create it
                            $self->ivAdd('processTagHash', 0, \@newTagList);

                        } else {

                            # Add the new tags to the beginning of the line; $listRef should be
                            #   empty, but just in case it already contains tags, use a push
                            #   operation
                            push (@$listRef, @newTagList);
                        }
                    }

                    # $self->processOrigLine, ->processStripLine and ->processTagHash have been
                    #   reset after the newline token, so we can now discard the contents of
                    #   $origLine, $stripLine and %lineTagHash
                    $self->ivPoke('processRetainFlag', FALSE);

                # If an IAC GA 'go ahead' sequence has just been processed...
                } elsif ($type eq 'ga') {

                    # Don't allow $self->processLinePortion to check for command prompts matching
                    #   the world profile's ->cmdPromptPatternList (for the rest of the session)
                    $self->ivPoke('eorgaFlag', TRUE);

                    # Apply triggers to the complete or partial line, and display it
                    $self->respondIncomingData(
                        $type,
                        $origLine,
                        $stripLine,
                        %lineTagHash,
                    );

                    # The TRUE value tells ->processPrompt to set ->promptInsertFlag, which in turn
                    #   instructs ->processIncomingData to insert an artificial newline character,
                    #   if necessary
                    $self->processPrompt(TRUE);

                } else {

                    # Store the variables in an IV, so that any code that needs to can force the
                    #   display of a partial line (e.g. $self->processMxpFrameElement, which needs
                    #   to display all undisplayed tokens before it can switch to a new frame)
                    # Exception: if the IVs have just been reset after a call to
                    #   $self->respondIncomingData, leave them in their reset state
                    if (! $self->processRetainFlag) {

                        $self->ivPoke('processOrigLine', $origLine);
                        $self->ivPoke('processStripLine', $stripLine);
                        $self->ivPoke('processTagHash', %lineTagHash);

                    } else {

                        # As suggested above, $self->respondIncomingData was called by one of the
                        #   function processing this token, so $origLine, $stripLine and
                        #   %lineTagHash are out of date
                        # Set their values for the token this loop is processing
                        $self->ivPoke('processRetainFlag', FALSE);

                        if (defined $backup) {

                            $self->ivPoke('processOrigLine', $self->processOrigLine . $backup);

                            if ($type eq 'text') {

                                $self->ivPoke(
                                    'processStripLine',
                                    $self->processStripLine . $backup,
                                );
                            }
                        }

                        if (@tagList) {

                            $listRef = $self->ivShow('processTagHash', 0);
                            if (! defined $listRef) {

                                $self->ivAdd('processTagHash', 0, \@tagList);

                            } else {

                                push (@$listRef, @tagList);
                            }
                        }
                    }
                }

                # If any MXP/Pueblo/MCP debug messages have been generated, display them, then
                #   display a summary of the token that caused the problem
                $self->displayProtocolDebug();

            } until (! $self->currentTokenList);

            # If there are some text tokens to display (i.e. an incomplete line, not ending with a
            #   newline character, was received from the world), then display them
            if ($self->processStripLine ne '') {

                # Apply triggers to partial line, and display it
                $self->respondIncomingData(
                    'part',
                    $self->processOrigLine,
                    $self->processStripLine,
                    $self->processTagHash,
                );
            }
        }

        # Apply any links created by MXP <A> and <SEND> tags to the current textview (if the current
        #   textview was changed during the call to this function, any links for other textviews
        #   have already been applied)
        foreach my $linkObj ($self->mxpTempLinkList) {

            $self->currentTabObj->textViewObj->add_incompleteLink($linkObj);
        }

        $self->ivEmpty('mxpTempLinkList');

        # Turn on special echo mode, if necessary
        if (
            $self->specialEchoMode eq 'waiting'
            && $self->defaultTabObj->textViewObj->bufferTextFlag
        ) {
            if ($self->echoMode ne 'client_agree') {

                # The world has turned off echo mode, so special echo mode is not required during
                #   this session
                $self->ivPoke('specialEchoMode', 'normal');

            } else {

                # The world has not turned off echo mode, so we need to take action, depending on
                #   whether direct keys are enabled in this session, or not
                if ($self->currentWorld->ivExists('termOverrideHash', 'useDirectKeysFlag')) {

                    $enableFlag = $self->currentWorld->ivShow(
                        'termOverrideHash',
                        'useDirectKeysFlag',
                    );

                } else {

                    $enableFlag = $axmud::CLIENT->useDirectKeysFlag;
                }

                if (! $enableFlag) {

                    # Special echo mode is currently disabled (but might be re-enabled at any time,
                    #   if direct keys are re-enabled in this session)
                    $self->ivPoke('specialEchoMode', 'disabled');

                } else {

                    $self->ivPoke('specialEchoMode', 'enabled');
                }

                # Inform all strip entry objects (GA::Strip::Entry) of the change
                $self->updateSpecialEcho();
            }
        }

        # Update the connection info strip object for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            # Update information stored in each 'internal' window's connection info strip,
            #   if visible
            $winObj->setTimeLabel($self->getTimeLabelText());
        }

        # Set the 'main' window's urgency hint, if allowed
        if ($axmud::CLIENT->mainWinUrgencyFlag || $axmud::CLIENT->tempUrgencyFlag) {

            # The TRUE argument means only set the hint, if it's not already set
            $self->mainWin->setUrgent(TRUE);

            # If ->tempUrgencyFlag is set (and assuming ->mainWinUrgencyFlag is not set), the 'main'
            #   window's urgency hint should only be set once
            if ($axmud::CLIENT->tempUrgencyFlag) {

                $axmud::CLIENT->set_tempUrgencyFlag(FALSE);
            }
        }

        # Play a sound effect to signal that some text has been received, if allowed
        if ($axmud::CLIENT->tempSoundFlag) {

            $axmud::CLIENT->playSound('afk');

            # The sound should only be played once
            $axmud::CLIENT->set_tempSoundFlag(FALSE);
        }

        return 1;
    }

    sub tokeniseIncomingData {

        # Called by $self->processIncomingData, and also by $self->processPuebloPaneElement
        # Tokenises the text received from the world, producing a list in groups of two, in the form
        #   (type, argument, type, argument...)
        # Where 'type' is one of the strings 'nl', 'ga', 'esc', 'inv', 'ctrl', 'seq', 'bsp', 'msp',
        #   'mxp', 'ent', 'pueblo', 'mcp', 'nomcp' or 'text', and 'argument' is usually the token,
        #   but in the case of an escape sequence is a reference to a list of values. In that list,
        #   the first value is the token, the second is an argument, and the third is one of the
        #   sequence types 'osc', 'mxp', 'sqr', 'vt', 'xterm', 'trcol_fg' or 'trcol_bg'
        #
        # The list is stored in $self->currentTokenList, rather than in a local variable in the
        #   calling function
        #
        # Expected arguments
        #   $text               - The incoming text to tokenise
        #
        # Optional arguments
        #   $insertAtStartFlag  - TRUE when called by $self->processPuebloPaneElement, in which case
        #       the tokens are inserted at the beginning of $self->currentTokenList, so they are
        #       processed before any existing tokens (in other words, process the file we just
        #       downloaded from a URL, before continuing to process data received over the telnet/
        #       SSH/SSL connection). FALSE (or 'undef') otherwise
        #
        # Return values
        #   'undef' on improper arguments or if $text is an empty string
        #   1 otherwise

        my ($self, $text, $insertAtStartFlag, $check) = @_;

        # Local variables
        my (
            $mspFlag, $mxpPuebloFlag, $gaString, $nextChar,
            @tokenList,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->tokeniseIncomingData', @_);
        }

        # The calling function has already checked that $text isn't an empty string, but we will
        #   check, too
        if ($text eq '') {

            return undef;
        }

        # Check in advance whether MSP is enabled, or not
        if ($self->mspMode eq 'client_agree' || $self->mspMode eq 'client_simulate') {

            $mspFlag = TRUE;
        }

        # Check in advance whether MXP/Pueblo are enabled, or not
        if ($self->mxpMode eq 'client_agree' || $self->puebloMode eq 'client_agree') {

            $mxpPuebloFlag = TRUE;
        }

        # Save a bit of time by putting the 'IAC GA' sequence in a string
        $gaString = chr(255) . chr(249);

        # Tokenise $text, removing each token one by one until $text an empty string
        do {

            my (
                $firstChar, $firstPair, $mcpString, $mspString, $token, $data, $seqType, $offset,
                $mspPosn, $someText,
            );

            $firstChar = substr($text, 0, 1);
            $firstPair = substr($text, 0, 2);
            $mcpString = substr($text, 0, 3);
            $mspString = substr($text, 0, 7);

            # 1. Tokens consisting of a single newline character, "\n" or "\r"
            if ($firstChar eq "\n" || $firstChar eq "\r") {

                push (@tokenList, 'nl', $firstChar);
                $text = substr($text, 1);
                $self->ivPoke('lastTokenType', 'nl');

            # 2. Tokens consisting of an IAC GA 'go ahead' sequence
            } elsif ($firstPair eq $gaString) {

                push (@tokenList, 'ga', $firstPair);
                $text = substr($text, 2);
                $self->ivPoke('lastTokenType', 'ga');

            # 3. Tokens consisting of a single escape character "\e" immediately followed by a
            #       character that doesn't start a valid escape sequence (e.g. "\e", "\n" or "\r",
            #       or the IAC character chr(255))
            # 4. Tokens consisting of an invalid escape sequence, starting with the escape
            #       character "\e"
            # 5. Tokens consisting of a valid escape sequence, starting with the escape character
            #       "\e"
            } elsif ($firstChar eq "\e") {

                # Test the first two characters of the escape sequence, looking for anything that
                #   begins ESC [, or that begins a valid VT100 escape sequence
                # $self->extractEscSequence will decide whether it's a valid or invalid escape
                #   sequence
                if ($text =~ m/^\e[^\[\]\(\)\=\<\>\#\/\\\^\_\@A-Zc0-9]/) {

                    # Definitely not a valid escape sequence, so use the "\e" as a separate token
                    push (@tokenList, 'esc', $firstChar);
                    $text = substr($text, 1);
                    $self->ivPoke('lastTokenType', 'esc');

                } else {

                    # Attempt to extract a valid escape sequence
                    ($token, $data, $seqType) = $self->extractEscSequence($text);
                    if (! defined $token) {

                        # Not a valid escape sequence

                        # Find the offset of the first newline or escape character in $text (besides
                        #   the first character, which we already know is an escape character)
                        $offset = $self->findNewLineEscape($text);

                        if (! defined $offset) {

                            # No newline/escape characters found
                            # Assume this is an incomplete escape sequence. Store it in an emergency
                            #   buffer, and wait until the next packet arrives
                            if (defined $self->emergencyBuffer) {
                                $self->ivPoke('emergencyBuffer', $self->emergencyBuffer . $text);
                            } else {
                                $self->ivPoke('emergencyBuffer', $text);
                            }

                            # Tokenisation is complete
                            if ($insertAtStartFlag) {
                                $self->ivUnshift('currentTokenList', @tokenList);
                            } else {
                                $self->ivPoke('currentTokenList', @tokenList);
                            }

                            return 1;

                        } else {

                            # Assume this is an invalid escape sequence. Create an invalid token
                            #   that subsequent code can ignore
                            push (@tokenList, 'inv', substr($text, 0, $offset));
                            $text = substr($text, $offset);
                            $self->ivPoke('lastTokenType', 'inv');
                        }

                    } else {

                        # Valid escape sequence, the token type is either 'ctrl' or 'seq'
                        if ($seqType eq 'vt') {

                            # Escape sequence that requires undisplayed text to be displayed, before
                            #   the sequence can be processed
                            push (@tokenList, 'ctrl', [$token, $data, $seqType]);
                            $text = substr($text, length($token));
                            $self->ivPoke('lastTokenType', 'ctrl');

                        } else {

                            # Escape sequence that doesn't require undisplayed text to be displayed
                            #   immediately
                            # ($seqType is 'osc', 'mxp', 'sgr', 'vt', 'xterm', 'trcol_fg' or
                            #   'trcol_bg')
                            push (@tokenList, 'seq', [$token, $data, $seqType]);
                            $text = substr($text, length($token));
                            $self->ivPoke('lastTokenType', 'seq');
                        }
                    }
                }

            # 6. Backspace tokens, in the form "\b \b" or just "\b"
            } elsif ($firstChar eq "\b") {

                if ($mcpString eq "\b \b") {
                    $token = $mcpString;
                } else {
                    $token = $firstChar;
                }

                push (@tokenList, 'bsp', $token);
                $text = substr($text, length($token));
                $self->ivPoke('lastTokenType', 'bsp');

            # 7. MSP tokens, starting '!!SOUND' or '!!MUSIC' (if MSP is enabled). If the user has
            #   just sent a world command, the current textview object's insertion position may be
            #   at the beginning of a line, yet $self->lastTokenType won't necessarily be 'nl' or
            #   'ga'. Therefore, if we require MSP tags to be at the start of a line, we have to
            #   check the textview object
            } elsif (
                ($mspString eq '!!SOUND' || $mspString eq '!!MUSIC')
                && $mspFlag
                && (
                    $axmud::CLIENT->allowMspFlexibleFlag
                    || $self->lastTokenType eq 'nl'
                    || $self->lastTokenType eq 'ga'
                    || $self->currentTabObj->textViewObj->insertNewLineFlag
                )
            ) {
                # Attempt to extract a valid MSP sound trigger
                $token = $self->extractMspSoundTrigger($text);
                if (! defined $token) {

                    # If the sound trigger is invalid, it's treated as normal text
                    push (@tokenList, 'text', $mspString);
                    $text = substr($text, length($mspString));
                    $self->ivPoke('lastTokenType', 'text');

                } elsif ($token eq '') {

                    # Assume this is an incomplete MSP sound trigger. Store it in an emergency
                    #   buffer, and wait until the next packet arrives
                    if (defined $self->emergencyBuffer) {
                        $self->ivPoke('emergencyBuffer', $self->emergencyBuffer . $text);
                    } else {
                        $self->ivPoke('emergencyBuffer', $text);
                    }

                    # Tokenisation is complete
                    if ($insertAtStartFlag) {
                        $self->ivUnshift('currentTokenList', @tokenList);
                    } else {
                        $self->ivPoke('currentTokenList', @tokenList);
                    }

                    return 1;

                } else {

                    push (@tokenList, 'msp', $token);
                    $text = substr($text, length($token));
                    $self->ivPoke('lastTokenType', 'msp');
                }

            # 8. MXP/Pueblo elements (if MXP or Pueblo are enabled)
            } elsif ($firstChar eq '<' && $mxpPuebloFlag) {

                # Attempt to extract a valid MXP/Pueblo element/tag
                $token = $self->extractMxpPuebloElement($text);
                if (! defined $token) {

                    # If it's an abnormally terminated element/tag, it's treated as ordinary text
                    push (@tokenList, 'text', $firstChar);
                    $text = substr($text, 1);
                    $self->ivPoke('lastTokenType', 'text');

                } elsif ($token eq '') {

                    # Assume this is an incomplete element/tag. Store it in an emergency buffer,
                    #   and wait until the next packet arrives
                    if (defined $self->emergencyBuffer) {
                        $self->ivPoke('emergencyBuffer', $self->emergencyBuffer . $text);
                    } else {
                        $self->ivPoke('emergencyBuffer', $text);
                    }

                    # Tokenisation is complete
                    if ($insertAtStartFlag) {
                        $self->ivUnshift('currentTokenList', @tokenList);
                    } else {
                        $self->ivPoke('currentTokenList', @tokenList);
                    }

                    return 1;

                } elsif ($self->mxpMode eq 'client_agree') {

                    # A valid MXP element/tag
                    push (@tokenList, 'mxp', $token);
                    $text = substr($text, length($token));
                    $self->ivPoke('lastTokenType', 'mxp');

                } elsif ($self->puebloMode eq 'client_agree') {

                    # A valid Pueblo element/tag
                    push (@tokenList, 'pueblo', $token);
                    $text = substr($text, length($token));
                    $self->ivPoke('lastTokenType', 'pueblo');

                } else {

                    # Emergency fallback (should never be executed) - treat as ordinary text
                    push (@tokenList, 'text', $token);
                    $text = substr($text, length($token));
                    $self->ivPoke('lastTokenType', 'text');
                }

            # 9. MXP/Pueblo entities (if MXP or Pueblo are enabled)
            } elsif ($firstChar eq '&' && $mxpPuebloFlag) {

                # Attempt to extract a valid MXP/Pueblo entity
                $token = $self->extractMxpPuebloEntity($text);
                if (! defined $token) {

                    # If it's an invalid entity, display it 'as is'
                    push (@tokenList, 'text', $firstChar);
                    $text = substr($text, 1);
                    $self->ivPoke('lastTokenType', 'text');

                } else {

                    # A valid MXP/Pueblo entity
                    push (@tokenList, 'ent', $token);
                    $text = substr($text, length($token));
                    $self->ivPoke('lastTokenType', 'ent');
                }

            # 10. MCP tokens, starting '#$#', and non-MCP tokens, starting '#$"' (if MCP is enabled)
            } elsif (
                ($mcpString eq '#$#' || $mcpString eq '#$"')
                && $self->mcpMode eq 'client_agree'
                && ($self->lastTokenType eq 'nl' || $self->lastTokenType eq 'mcp')
            ) {
                # For MCP tokens starting '#$#', extract the whole line, including the newline
                #   character(s) at the end
                if ($mcpString eq '#$#') {

                    if ($text =~ m/^(\#\$\#[^\n\r]*[\n\r])/) {

                        $token = $1;

                        # Extract a line ending \n\r, \r\n or just \n or \r
                        $nextChar = substr($text, length($token), 1);
                        if (
                            $nextChar ne ''
                            && (
                                ($token =~ m/[\n]$/ && $nextChar eq "\r")
                                || ($token =~ m/[\r]$/ && $nextChar eq "\n")
                            )
                        ) {
                            $token .= $nextChar;
                        }

                        push (@tokenList, 'mcp', $token);
                        $text = substr($text, length ($token));
                        $self->ivPoke('lastTokenType', 'mcp');

                    } else {

                        # No newline characters found
                        # Assume this is an incomplete MCP message. Store it in an emergency buffer,
                        #   and wait until the next packet arrives
                        if (defined $self->emergencyBuffer) {
                            $self->ivPoke('emergencyBuffer', $self->emergencyBuffer . $text);
                        } else {
                            $self->ivPoke('emergencyBuffer', $text);
                        }

                        # Tokenisation is complete
                        if ($insertAtStartFlag) {
                            $self->ivUnshift('currentTokenList', @tokenList);
                        } else {
                            $self->ivPoke('currentTokenList', @tokenList);
                        }

                        return 1;
                    }

                # For non-MCP tokens starting '#$"', extract that part
                # The rest of the line is an in-bound line, which should start either '#$#' or
                #   '#$"'; if either occurs, extract them as an ordinary text token so they're not
                #   treated as MCP-related
                } else {

                    push (@tokenList, 'nomcp', substr($text, 0, 3));
                    $text = substr($text, 3);
                    $self->ivPoke('lastTokenType', 'nomcp');

                    $mcpString = substr($text, 0, 3);
                    if ($mcpString eq '#$#' || $mcpString eq '#$"') {

                        push (@tokenList, 'text', $mcpString);
                        $text = substr($text, 3);
                        $self->ivPoke('lastTokenType', 'text');
                    }
                }

            # 11. An ordinary text token, everything from the beginning of $text up to:
            #       - The last character which isn't an "\n", "\r", "\e", "\b" or the IAC character
            #           chr(255)
            #       - The last character which isn't "<" or "&", when MXP/Pueblo are enabled
            #       - The last character before a !!SOUND or !!MUSIC tag, when MSP flexible tag
            #           placement is enabled
            #       - The end of $text, if none of the above apply
            } else {

                # If an MSP sound trigger is present, we don't need to look further than that
                if (
                    $mspFlag
                    && ($axmud::CLIENT->allowMspFlexibleFlag || $self->lastTokenType eq 'nl')
                ) {
                    $mspPosn = index($text, '!!SOUND');
                    if ($mspPosn == -1) {

                        $mspPosn = index($text, '!!MUSIC');
                    }
                }

                if (defined $mspPosn && $mspPosn > -1) {

                    $someText = substr($text, 0, $mspPosn);

                } else {

                    $someText = $text;
                }

                # Check for the end of a token, up to the end of $someText
                if ($mxpPuebloFlag) {

                    if ($someText =~ m/^([^\n\r\e\b\377].*?)[\n\r\e\b\377\<\&]/) {

                        $token = $1;

                    } else {

                        # The rest of $someText doesn't contain any of those characters, so the
                        #   whole token is $someText
                        $token = $someText;
                    }

                } elsif ($text =~ m/^([^\n\r\e\b\377].*?)[\n\r\e\b\377]/) {

                    $token = $1;

                } else {

                    $token = $someText;
                }

                # Add the text token to the token list
                push (@tokenList, 'text', $token);
                $text = substr($text, length($token));
                $self->ivPoke('lastTokenType', 'text');
            }

        } until ($text eq '');

        # Operation complete. Store the list as an IV, before returning
        if ($insertAtStartFlag) {
            $self->ivUnshift('currentTokenList', @tokenList);
        } else {
            $self->ivPoke('currentTokenList', @tokenList);
        }

        return 1;
    }

    sub respondIncomingData {

        # Called by $self->processIncomingData when a newline token is processed (and not ignored),
        #   or when an artificial newline token is inserted by some part of the code, or when the
        #   calling function runs out of tokens (meaning that the incoming data didn't end with a
        #   newline character)
        # Also called by any token-processing function which needs to display an incomplete line,
        #   before it can finish processing its token (e.g. by $self->processMxpFrameElement)
        #
        # Prepares to display a complete or partial line, and updates some IVs
        #
        # Expected arguments
        #   $type           - 'nl' for a newline token, 'go' for an artificially-inserted newline
        #                       token, 'ga' for an IAC GA 'go ahead' token or 'part' if the calling
        #                       function has run out of tokens
        #   $origLine       - The original text received from the world, before any tokens were
        #                       extracted
        #   $stripLine      - $origLine, but with all non-text tokens removed
        #   %lineTagHash    - A hash of the Axmud colour/style tags stripped from $stripLine. A hash
        #                       in the form
        #                           $lineTagHash{line_offset} = reference_to_list_of_tags
        #                   - Will not be an empty hash, because when $self->processTagHash is
        #                       reset, it's always given an entry at offset 0
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $type, $origLine, $stripLine, %lineTagHash) = @_;

        # Local variables
        my (
            $bufferObj, $wholeText,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $type || ! defined $origLine || ! defined $stripLine || ! %lineTagHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->respondIncomingData', @_);
        }

        # Add any stripped text already displayed on this line to the stripped text that's about to
        #   be displayed, so we can check for empty line suppression
        $wholeText =  $self->getPartialLine() . $stripLine;

        # If empty line suppression is turned on (and the character is marked as logged in, when
        #   required), suppress empty lines, as necessary
        if (
            $type ne 'nl'
            || ! (
                $self->currentWorld->suppressEmptyLineCount
                && ($self->currentWorld->suppressBeforeLoginFlag || $self->loginFlag)
                && $wholeText =~ m/^\s*$/
                && (
                    # Suppress all empty lines
                    $self->currentWorld->suppressEmptyLineCount == 1
                    # Suppress consecutive empty lines
                    || $self->checkSuppressLine()
                )
            )
        ) {
            # Prepare everything that needs to be displayed (after checking it for triggers, and so
            #   on), and then display it
            if (! $self->processLinePortion($type, $origLine, $stripLine, %lineTagHash)) {

                # Store the un-displayed text in the emergency buffer, assuming that the next packet
                #   received will contain the rest of this incomplete line
                if (defined $self->emergencyBuffer) {
                    $self->ivPoke('emergencyBuffer', $self->emergencyBuffer . $origLine);
                } else {
                    $self->ivPoke('emergencyBuffer', $origLine);
                }
            }
        }

        # Update IVs, which must be reset every time processed tokens are actually displayed
        $self->ivPoke('processOrigLine', '');
        $self->ivPoke('processStripLine', '');
        $self->ivPoke('processImageLine', '');
        $self->ivEmpty('processTagHash');
        # Inform $self->processIncomingData that a complete or partial line has been displayed, in
        #   case it doesn't already know
        $self->ivPoke('processRetainFlag', TRUE);

        # (Later code is simpler, if hashes of Axmud colour/style tags always have at least one
        #   entry, representing an empty list at the beginning of the line)
        $self->ivPoke('processTagHash', 0, \@emptyList);

        return 1;
    }

    sub processLinePortion {

        # Called by $self->respondIncomingData
        # A line portion is either a complete line of text, ending in a newline character, or a
        #   partial line of text (because an earlier portion has already been displayed, or because
        #   the line doesn't end with a newline character, or both)
        #
        # Sometimes the line portion needs to be split into segments, for example if
        #   - The line portion contains a recognised command prompt, so the line must be split
        #   - A splitter trigger has fired on this line
        # If there are multiple segments, they are treated as separate lines
        #
        # This function splits the line portion into segments, and then displays each segment
        #   separately (ordinarily, there's only one segment that's exactly the same as the line
        #   portion)
        #
        # Expected arguments
        #   $type           - 'nl' if the line portion ends with a newline token, 'go' if the line
        #                       portion ends with an artificially-inserted newline token, 'ga' if
        #                       the line portion ends in an IAC GA 'go ahead' sequences, or 'part'
        #                       if the line portion doesn't end with a newline character at all
        #   $origPortion    - The original line portion received from the world, before any tokens
        #                       were extracted
        #   $stripPortion   - $origPortion, but with all non-text tokens removed
        #   %lineTagHash    - A hash of the Axmud colour/style tags stripped from $stripLine. A hash
        #                       in the form
        #                           $lineTagHash{line_offset} = reference_to_list_of_tags
        #                   - Will not be an empty hash, because when $self->processTagHash is
        #                       reset, it's always given an entry at offset 0
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $type, $origPortion, $stripPortion, %lineTagHash) = @_;

        # Local variables
        my (
            $newLineFlag, $matchFlag, $previousOffset,
            @offsetList,
        );

        # Check for improper arguments
        if (
            ! defined $type || ! defined $origPortion || ! defined $stripPortion || ! %lineTagHash
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->processLinePortion', @_);
        }

        if ($type eq 'nl' || $type eq 'go') {
            $newLineFlag = TRUE;
        } else {
            $newLineFlag = FALSE;
        }

        # Because of the lack of a newline character in a prompt, if the user types
        #   'north;north;north', the command prompt and the following text appear on the same line
        # Test $stripPortion against known command prompt patterns. If the line portion matches any
        #   of the patterns, we have to split the line portion at that point (unless the matching
        #   text occurs at the end of $stripPortion, in which case there's no point in splitting it)
        # @offsetList contains the offsets of the first character after any matching text
        #
        # At the same time we'll check splitter triggers. @offsetList contains the offset of the
        #   first character after the point at which a line portion is split into two
        @offsetList = $self->checkLineSplit($type, $stripPortion);
        if (! @offsetList) {

            # There are no command prompts in the middle of $stripPortion, and no splitter trigger
            #   has split the line portion into two or more segments, so process the whole line
            #   portion as a single line segment
            $self->processLineSegment(
                $origPortion,
                $stripPortion,
                $newLineFlag,
                FALSE,          # Let the function decide whether the segment is a prompt, or not
                %lineTagHash,
            );

        } else {

            # Split the line portion into separate line segments, as if we were inserting a newline
            #   character at all the positions in @offsetList
            # This job is complicated by the fact that we also have to divide %lineTagHash,
            #   adjusting the position of each group of tags
            $previousOffset = 0;        # Start at the beginning of $stripPortion
            for (my $offsetCount = 0; $offsetCount <= scalar @offsetList; $offsetCount++) {

                my (
                    $offset, $segment, $thisNewLineFlag, $promptFlag,
                    @emptyList,
                    %thisTagHash,
                );

                # Get the segment of the line between the last offset used (or the beginning of the
                #   line portion and this offset, if it's the first offset used) and the end of the
                #   matching text
                if ($offsetCount < scalar @offsetList) {

                    # Segment is not at the end of $stripPortion
                    $offset = $offsetList[$offsetCount];
                    $segment = substr(
                        $stripPortion,
                        $previousOffset,
                        ($offset - $previousOffset),
                    );

                    # We treat the segment as if it were a line portion that ended with a newline
                    #   character
                    $thisNewLineFlag = TRUE;
                    # The segment definitely ends with a prompt
                    $promptFlag = TRUE;

                } else {

                    # The segment is at the end of $stripPortion
                    $segment = substr($stripPortion, $previousOffset);
                    # We treat the segment as ending with a newline character, or not, depending on
                    #   whether the whole line portion ends with a newline character
                    $thisNewLineFlag = $newLineFlag;
                    # The segment might end with a prompt, or not; it's up to ->processLineSegment
                    #   to decide
                    $promptFlag = FALSE;
                }

                # Create a new hash of Axmud colour/style tags that occur in the matched text,
                #   adjusting their positions (offsets) accordingly
                foreach my $posn (keys %lineTagHash) {

                    my ($listRef, $newPosn);

                    $listRef = $lineTagHash{$posn};
                    $newPosn = $posn - $previousOffset;

                    if (
                        $posn >= $previousOffset
                        && (! defined $offset || $posn < $offset)
                    ) {
                        $thisTagHash{$newPosn} = $listRef;
                    }
                }

                # %lineTagHash (as well as %thisTagHash) always have a key-value pair at offset 0,
                #   because it keeps the code simple
                # If a line has been split into multiple segments, only the first portion will now
                #   have a tag hash with a key-value pair at offset 0. Add a new key-value pair at
                #   offset 0 for all segments which now lack it
                if (! exists $thisTagHash{0}) {

                    $thisTagHash{0} = \@emptyList;
                }

                # Process the line segment
                $self->processLineSegment(
                    $origPortion,
                    $segment,
                    $thisNewLineFlag,
                    $promptFlag,
                    %thisTagHash,
                );

                # The next line segment begins after this one
                $previousOffset = $offset;
            }
        }

        if (! $newLineFlag && ! $self->promptFlag && ! $self->eorgaFlag) {

            # $stripPortion (the whole line portion, before it was divided into segments) ends with
            #   a prompt (i.e., doesn't end with a newline character)
            # If it's a recognised command prompt, we have to call $self->processPrompt to handle it
            #   (but don't bother if any Go Ahead (GA) or End of Record (EOR) has been received by
            #   this session; assume that the world will tell us when to process a command prompt)
            OUTER: foreach my $pattern ($self->currentWorld->cmdPromptPatternList) {

                if ($stripPortion =~ m/$pattern$/) {

                    # The TRUE value tells ->processPrompt to set ->promptInsertFlag, which in turn
                    #   instructs ->processIncomingData to insert an artificial newline character,
                    #   if necessary
                    $self->processPrompt(TRUE);
                    last OUTER;
                }
            }
        }

        # Write the 'receive' logfile (all other logfiles have already been written by the call to
        #   $self->processLineSegment)
        $self->writeReceiveDataLog($stripPortion, $self->processImageLine, $newLineFlag);

        return 1;
    }

    sub processLineSegment {

        # Called by $self->processLinePortion, which received a complete or partial line of
        #   received text
        # If that line portion matched recognised command prompts or matched splitter triggers, it
        #   will have been split into two or more segments; this function is called for each
        #   segment
        # If not, this function is called once, with the segment being the whole line portion
        #
        # Expected arguments
        #   $origPortion    - The original line portion received from the world, before any tokens
        #                       were extracted
        #   $segment        - The line segment. After all non-text tokens are extracted from
        #                       $origPortion, it is divided into one or more segments; this is one
        #                       of those segments
        #   $newLineFlag    - Flag set to TRUE if this line segment is to be displayed with a
        #                       newline character following it, FALSE if it should not be displayed
        #                       with a newline character following it
        #   $promptFlag     - Flag set to TRUE if this line segment definitely ends in a prompt; set
        #                       to FALSE if this function should decide if it ends in a prompt, or
        #                       not
        #   %segmentTagHash - A hash of Axmud colour/style tags, in the form:
        #                       $segmentTagHash{offset} = reference_to_list_of_colour_and_style_tags
        #                   - Will not be an empty hash, because when $self->processTagHash is
        #                       reset, it's always given an entry at offset 0
        #
        # Return values
        #   'undef' on improper arguments or if the line is empty, and has been suppressed due to
        #       line suppression IVs in the current world
        #   1 otherwise

        my ($self, $origPortion, $segment, $newLineFlag, $promptFlag, %segmentTagHash) = @_;

        # Local variables
        my (
            $modSegment, $gagFlag, $gagLogFlag, $instructListRef, $dependentCallListRef, $modFlag,
            $textViewObj, $bufferText, $addText, $bufferObj, $testLine, $char, $modCmd,
            @instructList, @dependentCallList, @offsetList, @displayList, @explicitList,
            @initTagList, @emptyList, @specialList,
            %modTagHash, %mxpFlagTextHash, %currentTagHash,
        );

        # Check for improper arguments
        if (
            ! defined $origPortion || ! defined $segment || ! defined $newLineFlag
            || ! defined $promptFlag || ! %segmentTagHash
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->processLineSegment', @_);
        }

        # Check the segment for valid URLs and valid email addresses. If any are found, modify
        #   %segmentTagHash to include start start/stop positions of each link
        $self->extractClickLinks($segment, \%segmentTagHash);

        # Test the segment against triggers. If any of them fire, $self->checkTriggers will return
        #   a modified version of $segment and %segmentTagHash
        # (NB Splitter triggers have already been tested against this line portion)
        ($modSegment, $gagFlag, $gagLogFlag, $instructListRef, $dependentCallListRef, %modTagHash)
            = $self->checkTriggers($origPortion, $segment, $newLineFlag, %segmentTagHash);

        if (defined $modSegment) {

            # At least one trigger fired
            $modFlag = TRUE;
            @instructList = @$instructListRef;
            @dependentCallList = @$dependentCallListRef;

        } else {

            # No triggers fired
            $modSegment = $segment;
            %modTagHash = %segmentTagHash;
        }

        # Get a sorted list of offsets at which Axmud colour/style tags occur. Even if %modTagHash
        #   contains no colour/style tags at all, there will still be a key-value pair
        #   corresponding to position 0 (the beginning of this line segment) - where the key is 0
        #   and the corresponding value is an empty list
        @offsetList = sort {$a <=> $b} (keys %modTagHash);

        # Import the hash of stored text appearing between MXP custom elements, and reset the IV
        #   ready for the next line segment
        %mxpFlagTextHash = $self->mxpFlagTextStoreHash;
        $self->ivEmpty('mxpFlagTextStoreHash');

        # Import the current textview (the one in which this piece will be displayed)
        $textViewObj = $self->currentTabObj->textViewObj;
        # Import the hash of colour/style tags that apply at the textview's insert position
        # Every time we display a piece, update this hash; when we've finished displayed pieces (or
        #   when the current textview changes), update the textview's IV
        %currentTagHash = $textViewObj->colourStyleHash;

        for (my $offsetCount = 0; $offsetCount < scalar @offsetList; $offsetCount++) {

            my (
                $offset, $nextOffset, $tagListRef, $piece,
                @pieceTagList,
            );

            # If the session's current textview has just changed (possible, but very unlikely),
            #   display all the pieces we have so far in the previous textview, before updating
            #   variables to use the new one
            if (
                $self->currentTabObj->textViewObj ne $textViewObj
                && @displayList
                && (
                    ($modFlag && ! $gagFlag)    # Trigger fired, but doesn't have 'gag' attribute
                    || ! $modFlag               # No trigger fired
                )
            ) {
                # If this function is inserting text into the session's default textview object,
                #   then a GA::Buffer::Display object is going to be created (or updated)
                # This function should inform the textview object what the number of the
                #   GA::Buffer::Display object will be, so the the textview object can compare it to
                #   its internal buffer line number (and, having done that, display appropriate
                #   tooltips)
                if ($self->currentTabObj eq $self->defaultTabObj) {

                    # Inform the textview object
                    $textViewObj->useDisplayBufferNum($self->displayBufferCount);
                }

                # Display the line pieces we have so far
                if ($offsetCount == (scalar @offsetList - 1)) {

                    $self->displayLinePieces(
                        $textViewObj,
                        # Last offset, so might use 'after' argument in call to
                        #   GA::Obj::TextView->insertMultipleText
                        $newLineFlag,
                        \@displayList,
                        \@explicitList,
                    );

                } else {

                    $self->displayLinePieces(
                        $textViewObj,
                        # Not last offset, so definitely don't use 'after' argument in call to
                        #   GA::Obj::TextView->insertMultipleText
                        FALSE,
                        \@displayList,
                        \@explicitList,
                    );
                }

                # Reset variables, so we can display any remaining line pieces
                $textViewObj->set_colourStyleHash(%currentTagHash);

                $textViewObj = $self->currentTabObj->textViewObj;
                @displayList = ();
                @explicitList = ();
            }

            # $offset is the position in $line where one or more Axmud colour/style tags occur, and
            #   thus the beginning of this line piece
            $offset = $offsetList[$offsetCount];
            # $nextOffset is the position in $line where the next set of Axmud colour/style tags
            #   occur. If there are no more tags after those at position $offset, then we leave
            #   $nextOffset set to 'undef'
            if ($offsetCount < (scalar @offsetList - 1)) {

                $nextOffset = $offsetList[$offsetCount + 1];
            }

            # Get the Axmud colour/style tags that apply to this line piece
            $tagListRef = $modTagHash{$offset};

            # Update %currentTagHash with the Axmud colour/style tags that occur at the beginning of
            #   this line piece
            %currentTagHash = $textViewObj->applyColourStyleTags(
                $self,
                $tagListRef,
                %currentTagHash,
            );

            # Using the updated hash, get the list of Axmud colour/style tags that apply to the
            #   line piece (including the tags that applied before the line piece, and which haven't
            #   been modified/removed)
            @pieceTagList = $textViewObj->listColourStyleTags(%currentTagHash);

            # Remember which tags applied at the beginning of the line segment, so we can pass it
            #   to $self->updateDisplayBuffer
            if ($offset == 0) {

                @initTagList = @pieceTagList;
            }

            # Get the line piece itself (some text with all non-text tokens removed)
            if (defined $nextOffset) {

                $piece = substr($modSegment, $offset, ($nextOffset - $offset));

            } else {

                $piece = substr($modSegment, $offset);   # Rest of the $line
            }

            # So that we display the whole line segment with a single call to
            #   GA::Obj::TextView->insertText, compile a list of line pieces and the tags that
            #   apply to them
            push (@displayList, $piece, \@pieceTagList);
            # In case GA::Client->debugLineTagsFlag is TRUE, keep a copy of the original colour/
            #   style tags at each offset, so that explicit colour/style tags can be displayed
            push (@explicitList, $tagListRef);
        }

        # If we only have a newline character to display (and no actual line pieces), insert an
        #   empty string
        if (! @displayList && $newLineFlag) {

            push (@displayList, '', \@emptyList);
            push (@explicitList, []);
        }

        # Display the pieces (if allowed)
        if (
            @displayList
            && (
                ($modFlag && ! $gagFlag)    # Trigger fired, but doesn't have 'gag' attribute
                || ! $modFlag               # No trigger fired
            )
        ) {
            # If this function is inserting text into the session's default textview object, then a
            #   GA::Buffer::Display object is going to be created (or updated)
            # This function should inform the textview object what the number of the
            #   GA::Buffer::Display object will be, so the the textview object can compare it to its
            #   internal buffer line number (and, having done that, display appropriate tooltips)
            if ($self->currentTabObj eq $self->defaultTabObj) {

                # Inform the textview object
                $textViewObj->useDisplayBufferNum($self->displayBufferCount);
            }

            # Display the line pieces
            $self->displayLinePieces(
                $textViewObj,
                $newLineFlag,
                \@displayList,
                \@explicitList,
            );
        }

        # Update the textview object's IV
        $textViewObj->set_colourStyleHash(%currentTagHash);

        # Write to logs, if allowed (even for an empty line)
        if (
            ($modFlag && ! $gagFlag)   # Trigger fired, but doesn't have 'gag' attribute
            || ! $modFlag
        ) {
            # NB The 'receive' logfile is written by $self->writeReceiveDataLog, which is called by
            #   $self->processLinePortion, not this function
            $self->writeIncomingDataLogs($modSegment, $newLineFlag);
        }

        # Update the received display buffer
        $self->updateDisplayBuffer(
            $origPortion,
            $segment,
            $modSegment,
            $newLineFlag,
            \@offsetList,
            \%modTagHash,
            \@initTagList,
            \%mxpFlagTextHash,
        );

        # If a newline character has just been displayed, reset the IV showing how much explicit
        #   text for explicit line numbers/tags is on the (new) line
        if ($newLineFlag) {

            $self->ivPoke('explicitTextLength', 0);
        }

        # If text-to-speech conversion is required, add the received text to the TTS buffer, which
        #   will be read aloud when control passes back to $self->incomingDataLoop
        if (
            $axmud::CLIENT->systemAllowTTSFlag
            && $axmud::CLIENT->ttsReceiveFlag
            # (Don't bother add any line segments which contain no readable characters, since the
            #   TTS can't read them and it may mess up the artificial full stop added below)
            && $modSegment =~ m/\w/
        ) {
            $bufferText = $self->ttsBuffer;
            $addText = $modSegment;

            if ($axmud::CLIENT->ttsSmoothFlag) {

                # To make the text sound more natural, when spoken by the TTS engine, if the
                #   existing contents of the TTS buffer ends with a newline character which is not
                #   preceded by a punctuation mark, and if the new text starts with a capital
                #   letter, insert an artificial full stop
                if (
                    # The last line stored in ->ttsBuffer ended with a newline character
                    $bufferText =~ m/\n$/
                    # The most recent line contains alphanumeric characters but doesn't end with a
                    #   punctuation mark, and is optionally followed by one or more empty lines
                    && ! ($bufferText =~ m/[[:alnum:]]\s*[\.\,\:\;\!\?][\s*\n]+$/)
                    # The new line starts with a capital letter
                    && $addText =~ m/^\s*[[:upper:]]/
                ) {
                    $bufferText =~ s/\n$/\.\n/;
                }

                # If the new segment contains larget gaps (specifically, three or more consecutive
                #   whitespace characters), also insert an artificial full stop there
                $addText =~ s/(\w)\s{3,}/$1\. /;
            }

            $bufferText .= $addText;
            if ($newLineFlag) {

                $bufferText .= "\n";
            }

            # Update the buffer (if allowed)
            if (
                # Automatic login already processed
                $self->loginFlag
                # We Don't have to wait for a login before converting text
                || ! $axmud::CLIENT->ttsLoginFlag
                # We do have to wait for a login before converting text, but this is a prompt, and
                #   prompts are still converted before a login
                || ! $newLineFlag
            ) {
                $self->ivPoke('ttsBuffer', $bufferText);
            }
        }

        # Check for an MXP prompt notification
        if ($self->ivExists('mxpFlagTextHash', 'Prompt')) {

            $promptFlag = TRUE;
        }

        # Handle command prompts generally
        if ($promptFlag) {

            # Process the (definite) command prompt on the next spin of the maintain loop
            $self->ivPoke('promptCheckTime', $self->sessionTime);

        } elsif (! $newLineFlag) {

            # If $self->sessionTime reaches this time without any more text being received from the
            #   world, treat is as a command prompt
            $self->ivPoke(
                'promptCheckTime',
                $self->sessionTime + $axmud::CLIENT->promptWaitTime,
            );
        }

        # Perform any instructions created by any triggers that fired, but don't allow Perl
        #   commands (which should already have been evaluated)
        foreach my $instruction (@instructList) {

            $self->doInstruct($instruction, TRUE);
        }

        # For any dependent triggers that fired, call the class and method specified by the fired
        #   trigger
        if (@dependentCallList) {

            do {

                my ($listRef, $class, $method);

                $listRef = shift @dependentCallList;

                $class = shift @$listRef;
                $method = shift @$listRef;

                $class->$method(@$listRef);

            } until ( ! @dependentCallList);
        }

        # Fire any hooks that are using the 'receive_text' hook event
        $self->checkHooks('receive_text', $segment);

        # Deal with any login stuff
        if ($self->displayBufferCount) {

            $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
            $testLine = $bufferObj->modLine;

            if ($self->loginPromptsMode ne 'none') {

                # Test login patterns against the whole of the most recently-received line (in case
                #   the line matching a login pattern is split across packets)

                # In login modes 'lp' and 'world_cmd', if we're looking out for login success
                #   patterns, see if the line matches any of them
                if ($self->loginSuccessPatternList) {

                    INNER: foreach my $pattern ($self->loginSuccessPatternList) {

                        if ($testLine =~ m/$pattern/) {

                            # Success! Complete the login
                            $self->doLogin();
                            last INNER;
                        }
                    }

                # In login mode 'tiny', if we're looking out for the text which signals that the
                #   world is ready to receive the login, set the flag
                # Likewise for login mode 'world_cmd', but only if ->loginConnectPatternList is set)
                } elsif (
                    $self->loginPromptsMode eq 'tiny'
                    || ($self->loginPromptsMode eq 'world_cmd' && $self->loginConnectPatternList)
                ) {
                    INNER: foreach my $pattern ($self->loginConnectPatternList) {

                        if ($testLine =~ m/$pattern/) {

                            # Success! Set the flag that allows $self->spinMaintainLoop to call
                            #   $self->processCmdLoginMode
                            $self->ivPoke('loginConnectFoundFlag', TRUE);
                            last INNER;
                        }
                    }

                # In login mode 'mission', a login success pattern interrupts the mission
                #   immediately
                } elsif (
                    $self->loginPromptsMode eq 'mission'
                    && $self->currentWorld->loginSuccessPatternList
                ) {
                    INNER: foreach my $pattern ($self->currentWorld->loginSuccessPatternList) {

                        if ($testLine =~ m/$pattern/) {

                            # Success! Complete the login
                            $self->doLogin();
                            last INNER;
                        }
                    }
                }
            }

            if ($self->loginSpecialList && $self->initChar) {

                # Test the patterns in ->loginPatternList against the whole of the most
                #   recently-received line (in case the line matching a pattern is split across
                #   packets), initially looking for a line which matches the character's name
                #   (case-insensitively)
                $char = $self->initChar;
                if ($testLine =~ m/$char/i) {

                    # Line matches the character's name. Now, does it match the requirements of
                    #   ->loginPatternList? A list in groups of 3, in the form
                    #       (pattern, group_substring_number, world_command)
                    @specialList = $self->loginSpecialList;
                    do {

                        my (
                            $pattern, $grpNum, $worldCmd,
                            @grpNumList,
                        );

                        $pattern = shift @specialList;
                        $grpNum = shift @specialList;
                        $worldCmd = shift @specialList;

                        if (@grpNumList = ($testLine =~ m/$pattern/i)) {

                            # Line matches one of the specified patterns. Now check that the
                            #   character name appears in the right place (if required)
                            # NB In @grpNumList, the first group substring number is at index 0, so
                            #   we need to subtract one
                            $grpNum--;

                            if (
                                # Character names appear only once per line, so it doesn't matter
                                #   where in the line it appears
                                $grpNum == -1
                                # Character names appear multiple lines per line; the one we need to
                                #   check is in group substring number $grpNum
                                || lc($grpNumList[$grpNum]) eq lc($char)
                            ) {
                                # Success! Now we can subtitute group substrings
                                # If the corresponding world command, $worldCmd, is enclosed in
                                #   double-quotes, it's safe to use the ee modifier which will
                                #   convert $1, $2 etc into the contents of the matching group
                                #   substrings (otherwise we could end up executing arbitrary Perl
                                #   code)
                                $modCmd = $testLine;
                                $worldCmd = "\"$worldCmd\"";

                                $modCmd =~ s/$pattern/$worldCmd/iee;
                            }
                        }

                    } until ($modCmd || ! @specialList);

                    # Absolutely no reason why $worldCmd shouldn't be set now, but just to be
                    #   safe...
                    if ($modCmd) {

                        # Send the world command, telling the world which character to login
                        $self->writeText(
                            'Automatic login: Logging in character \'' . $char . '\', sending world'
                            . ' command \'' . $modCmd . '\'',
                        );
                        $self->worldCmd($modCmd);
                    }

                    # Stop checking received lines for these patterns
                    $self->ivEmpty('loginSpecialList');
                }
            }
        }

        return 1;
    }

    sub processEmptySegment {

        # Called by $self->processIncomingData when a token of the type 'ctrl' is received
        # Before a 'ctrl' token can be processed, any undisplayed text tokens must be displayed.
        #   However, if there are no undisplayed text tokens, but there are some Axmud colour/style
        #   tags waiting to be applied to the next text token, then those colour/style tags must be
        #   stored in the current textview's hash, ready for the next call to
        #   $self->processLineSegment
        # This function is a cut-down version of $self->processLineSegment
        #
        # Expected arguments
        #   %segmentTagHash - A hash of Axmud colour/style tags, in the form:
        #                       $segmentTagHash{offset} = reference_to_list_of_colour_and_style_tags
        #                   - Should contain exactly one entry for the offset 0
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, %segmentTagHash) = @_;

        # Local variables
        my (
            $listRef, $textViewObj,
            %currentTagHash,
        );

        # Check for improper arguments
        if (! %segmentTagHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processEmptySegment', @_);
        }

        # Get the (potentially empty) list of Axmud colour/style tags
        $listRef = $segmentTagHash{0};
        if (defined $listRef && @$listRef) {

            # Import the current textview object, and store the unused colour/style tags in it,
            #   ready for the next call to $self->processLineSegment
            $textViewObj = $self->currentTabObj->textViewObj;

            %currentTagHash = $textViewObj->applyColourStyleTags(
                $self,
                $listRef,
                $textViewObj->colourStyleHash,
            );

            $textViewObj->set_colourStyleHash(%currentTagHash);
        }

        return 1;
    }

    sub displayLinePieces {

        # Called by $self->processLineSegment
        # Prepares explicit line numbers and explicit colour/style tags (if required), updates
        #   positions of any links, then displays one or more line pieces from the calling
        #   function's line segment
        #
        # Expected arguments
        #   $textViewObj    - This session's current textview object (GA::Obj::TextView)
        #   $newLineFlag    - Flag set to TRUE if this line segment is to be displayed with a
        #                       newline character following it, FALSE if it should not be displayed
        #                       with a newline character following it
        #   $displayListRef - Reference to a list in groups of 2, in the form
        #                       (line_piece, reference_to_list_of_Axmud_colour_style_tags, ...)
        #   $explicitListRef
        #                   - Reference to a list (in groups of 1). Each item in the list is
        #                       itself a list reference, containing the original Axmud colour/style
        #                       tags specified by the world, before they were applied to the
        #                       textview object. This list is used to display explicit colour/style
        #                       tags, and contains exactly one item for every pair of items in
        #                       $displayListRef
        #
        # Return values
        #   'undef' on improper arguments or if the call to GA::Obj::TextView->insertMultipleText
        #       fails
        #   1 otherwise

        my ($self, $textViewObj, $newLineFlag, $displayListRef, $explicitListRef, $check) = @_;

        # Local variables
        my (
            $thisLine, $thisPosn, $numString, $extraLength, $combLength, $listRef,
            @modList,
        );

        # Check for improper arguments
        if (
            ! defined $textViewObj || ! defined $newLineFlag || ! defined $displayListRef
            || ! defined $explicitListRef
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayLinePieces', @_);
        }

        # Get the current textview's insert position, so we can adjust the position of any links
        #   correctly
        ($thisLine, $thisPosn) = $textViewObj->getInsertPosn();

        # When GA::Client->debugLineNumsFlag is set, show explicit display buffer line numbers at
        #   the beginning of a line
        if ($axmud::CLIENT->debugLineNumsFlag && $thisPosn == 0) {

            # Display the line number (in contrasting colours)
            $numString = '<' . $self->displayBufferCount . '> ',
            # Display it immediately. Don't include it in the list of line pieces sent to
            #   Games::Axmud::Obj::TextView->insertMultipleText just below
            $textViewObj->insertText($numString, 'RED', 'ul_white', 'echo');

            # If an incomplete link has been completed and added to $self->mxpTempLinkList, but has
            #   not yet been processed by GA::Obj::TextView->add_incompleteLink, any link object
            #   (GA::Obj::Link) which occurs after the explicit line number will have the wrong
            #   offset. Update it
            foreach my $linkObj ($self->mxpTempLinkList) {

                if (
                    $linkObj->lineNum == $thisLine
                    && $linkObj->posn >= ($thisPosn + $self->explicitTextLength)
                ) {
                    $linkObj->ivPoke('posn', $linkObj->posn + length($numString));
                }
            }

            $self->ivPoke(
                'explicitTextLength',
                $self->explicitTextLength + length($numString),
            );

        } else {

            $numString = '';
        }

        # When GA::Client->debugLineTagsFlag is set, show explicit colour/style tags at the
        #   beginning of every line piece
        $extraLength = 0;
        $combLength = $thisPosn + length($numString);

        if ($axmud::CLIENT->debugLineTagsFlag) {

            do {

                my ($piece, $tagListRef, $origTagListRef, $extraPiece);

                $piece = shift @$displayListRef;
                $tagListRef = shift @$displayListRef;
                $origTagListRef = shift @$explicitListRef;
                $extraPiece = '';

                foreach my $tag (@$origTagListRef) {

                    $extraPiece .= '[' . $tag . ']';
                }

                push (@modList,
                    $extraPiece,
                    ['white', 'ul_blue'],
                    $piece,
                    $tagListRef,
                );

                $extraLength += length($extraPiece);

                # Update completed links, as described above
                foreach my $linkObj ($self->mxpTempLinkList) {

                    if (
                        $linkObj->lineNum == $thisLine
                        && $linkObj->posn >= $combLength
                    ) {
                        $linkObj->ivPoke('posn', $linkObj->posn + length($extraPiece));
                    }
                }

                $combLength += length($piece) + length($extraPiece);


            } until (! @$displayListRef);

            @$displayListRef = @modList;
        }

        # Set the combined length for explicit line numbers and explicit colour/style tags on this
        #   line
        $self->ivPoke(
            'explicitTextLength',
            $self->explicitTextLength + $extraLength,
        );

        # Use a newline character at the end of the final piece, if required; otherwise use an
        #   'echo' tag to explicitly prevent one
        $listRef = $$displayListRef[-1];
        if ($newLineFlag) {
            push (@$listRef, 'after');
        } else {
            push (@$listRef, 'echo');
        }

        # Display the line pieces all in one go
        return $textViewObj->insertMultipleText(@$displayListRef);
    }

    # (->processIncomingData / ->tokeniseIncomingData / ->respondIncomingData etc support functions)

    sub displayRawIncomingData {

        # Called by $self->processIncomingData, straight after the call to ->tokeniseIncomingData
        # Displays the raw incoming text, in the terminal window, RawText task window and RawToken
        #   task window (if we're required to)
        #
        # Expected arguments
        #   $text       - The incoming text, before it was tokenised
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $text, $check) = @_;

        # Local variables
        my (
            $termOutput, $newlineFlag,
            @currentList, @tokenList,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayRawIncomingData', @_);
        }

        # If the global variable is set, all text received from the world (except out-of-bounds
        #   text) is also written to the terminal, with non-printable characters like ESC written as
        #   <n>
        if ($axmud::TEST_TERM_MODE_FLAG) {

            $termOutput = '';

            foreach my $char (split(//, $text)) {

                if (ord($char) >= 32 && ord($char) < 127) {
                    $termOutput .= $char;
                } elsif ($char eq "\n" || $char eq "\r") {
                    $termOutput .= $char;
                } else {
                    $termOutput .= '<'. ord($char) . '>';
                }
            }

            print $termOutput;
        }

        # If we're still waiting for a login, store the raw text/raw tokens in buffers, so that
        #   they're available to the RawText/RawToken tasks as soon as those tasks are initialised
        # If the login still hasn't been completed after $self->loginWarningTime, then give up
        #   collecting
        if (
            ! $self->loginFlag
            && $self->loginWarningTime
            && $self->loginWarningTime > $self->sessionTime
        ) {
            if ($text ne '') {

                $self->ivPush('rawTextBufferList', $self->packetCount, $text);
            }

            if ($self->currentTokenList) {

                # (Second argument is a list reference)
                @currentList = $self->currentTokenList;
                $self->ivPush('rawTokenBufferList', $self->packetCount, \@currentList);
            }
        }

        # If the RawText task is open, display the received text (including any escape sequences)
        #   in its task window
        if ($self->rawTextTask && $self->rawTextTask->taskWinFlag) {

            # Deal with raw text received since the last spin of the task loop
            $self->rawTextTask->insertText(
                '<' . $self->packetCount . '>',
                'WHITE',
                'ul_red',
                'echo',
            );

            foreach my $string (split(m/\n/, $text)) {

                $self->rawTextTask->insertText($string, 'after');
            }
        }

        # If the RawToken task is open, display the packet number and then each token in turn
        if ($self->rawTokenTask && $self->rawTokenTask->taskWinFlag) {

            $newlineFlag = FALSE;

            # Deal with raw tokens received since the last spin of the task loop
            @tokenList = $self->currentTokenList;
            if (@tokenList) {

                # @tokenList is in the form (type, argument, type, argument...) where 'argument' is
                #   usually the token itself
                if ($self->rawTokenTask->countPacketFlag) {

                    $self->rawTokenTask->insertText(
                        '<' . $self->packetCount . '>',
                        'WHITE',
                        'ul_red',
                    );
                }

                do {

                    $newlineFlag = $self->rawTokenTask->displayRawToken(
                        shift @tokenList,
                        shift @tokenList,
                        $newlineFlag,
                    );

                } until (! @tokenList);
            }
        }

        return 1;
    }

    sub getPartialLine {

        # Called by several token-processing functions, including by $self->processIncomingData
        #   itself, generally to check for empty lines
        # If a partial line (one not ending in a newline character) has been displayed, returns the
        #   stripped text (i.e. the combined text tokens) from that partial line
        # If the last line displayed ended in a newline character, just returns an empty string
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the string described above

        my ($self, $check) = @_;

        # Local variables
        my ($bufferObj, $text);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getPartialLine', @_);
        }


        if ($self->displayBufferLast) {

            $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
            if ($bufferObj && ! $bufferObj->newLineFlag) {

                return $bufferObj->stripLine;
            }
        }

        # Default value if no partial line displayed
        return '';
    }

    sub detectPueblo {

        # Called by $self->processIncomingData to detect Pueblo negotations, and to respond
        #   appropriately
        #
        # Expected arguments
        #   $text   - The incoming data received from the world, before it has been tokenised
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $text, $check) = @_;

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->detectPueblo', @_);
        }

        # On the first line, detect Pueblo (if allowed)
        # v1.2.025 - The introductory Pueblo message is supposed to appear on the first line (only),
        #   but Ancient Anguish has moved it below its logo. From this version onwards, we keep
        #   checking for Pueblo until login is complete
        if (
            $axmud::CLIENT->usePuebloFlag
            && ! $self->currentWorld->ivExists('telnetOverrideHash', 'pueblo')
            && $self->puebloMode ne 'client_agree'
#            && $self->packetCount <= 1
#            && $text =~ m/^\s*This world is Pueblo (\d+\.\d+) Enhanced\./i
            && ! $self->loginFlag
            && $text =~ m/This world is Pueblo (\d+\.\d+) Enhanced\./i
        ) {
            if ($self->mxpMode eq 'client_agree') {

                # Can't use Pueblo and MXP at the same time
                $self->ivPoke('puebloMode', 'client_refuse');

            } else {

                # Pueblo allowed
                $self->ivPoke('puebloMode', 'client_agree');
                $self->ivPoke('puebloVersion', $1);
                $self->send('PUEBLOCLIENT 2.01');       # Same response as zMud

                # Change the properties of 'link' tags, so they are displayed in cyan, and without
                #   an underline
                $self->defaultTabObj->textViewObj->updateLinkTag();
            }
        }

        return 1;
    }

    sub detectMCP {

        # Called by $self->processIncomingData to detect MCP negotations, and to respond
        #   appropriately
        #
        # Expected arguments
        #   $text   - The incoming data received from the world, before it has been tokenised
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $text, $check) = @_;

        # Local variables
        my (
            $token, $min, $max, $negotiateObj,
            @availableList, @list, @list2, @list3,
            %useHash, %checkHash,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->detectMCP', @_);
        }

        # Detect MCP (if allowed; not necessarily on the first line)
        if (
            $axmud::CLIENT->useMcpFlag
            && ! $self->currentWorld->ivExists('telnetOverrideHash', 'mcp')
            && $self->mcpMode ne 'client_agree'
            && $text =~ m/(\#\$\#mcp version\: ([0-9\.])* to\: ([0-9\.]*))/
        ) {
            # Only MCP version 2.1 is supported
            $token = $1;
            $min = $2;
            $max = $3;

            if ($min <= $self->constMcpVersion && $max >= $self->constMcpVersion) {

                # MCP allowed
                $self->ivPoke('mcpMode', 'client_agree');
                # Generate a random authentification key, and send it to the server to indicate the
                #   client's acceptance of MCP messages
                $self->generateMcpKey();

                # MCP package objects inherit from GA::Generic::Mcp. GA::Client->mcpPackageHash
                #   contains a list of MCP package objects supported by Axmud, and MCP package
                #   objects which are defined by plugins
                # Eliminate any MCP packages defined by disabled plugins
                foreach my $mcpObj ($axmud::CLIENT->ivValues('mcpPackageHash')) {

                    my $pluginObj;

                    if (defined $mcpObj->plugin) {

                        $pluginObj = $axmud::CLIENT->ivShow('pluginHash', $mcpObj->plugin);
                        if ($pluginObj && $pluginObj->enabledFlag) {

                            push (@availableList, $mcpObj);
                        }

                    } else {

                        push (@availableList, $mcpObj);
                    }
                }

                # Some MCP package objects specify a list of MCP package names which are supplanted
                #   when the former is used by this session, so we need to generate a list of names
                #   of MCP packages which are supplanted, or which are supplantable
                #
                # For MCP package objects which aren't on that list, we inform the world immediately
                #   that this session supports them
                # For MCP packages that do appear on the list, we wait until the world sends a
                #   'mcp-negotiate-end' message, and then we decide which among several alternative
                #   packages this session will support
                foreach my $mcpObj (@availableList) {

                    if ($mcpObj->supplantList) {

                        foreach my $name ($mcpObj->name, $mcpObj->supplantList) {

                            $checkHash{$name} = undef;
                        }
                    }
                }

                foreach my $mcpObj (@availableList) {

                    my $cloneObj;

                    # Clone the object (with default settings) in the client's IV into an object
                    #   (with custom settings) stored in one of this session's IV
                    $cloneObj = $mcpObj->clone($self);
                    if ($cloneObj) {

                        if (! exists $checkHash{$cloneObj->name}) {

                            # Use this MCP package object immediately
                            $self->ivAdd('mcpPackageHash', $cloneObj->name, $cloneObj);

                        } else {

                            # This session might use this package, or it might use an alternative;
                            #   mark the package as being one we need to decide on later
                            $self->ivAdd('mcpWaitHash', $cloneObj->name, $cloneObj);
                        }
                    }
                }

                # Inform the world of any MCP package objects we've already decided to use
                # It's nice to specify 'official' MCP packages first, and then everything else in
                #   alphabetical order. Compile a list of packages in that order
                foreach my $mcpObj (@availableList) {

                    if ($mcpObj->name eq 'mcp-negotiate') {
                        push (@list, $mcpObj);
                    } elsif (substr($mcpObj->name, 0, 4) eq 'mcp-') {
                        push (@list2, $mcpObj);
                    } else {
                        push (@list3, $mcpObj);
                    }
                }

                # (@list contains, at most, one item; no sorting needed)
                @list2 = sort {lc($a->name) cmp lc($b->name)} (@list2);
                @list3 = sort {lc($a->name) cmp lc($b->name)} (@list3);

                foreach my $mcpObj (@list, @list2, @list3) {

                    $self->mcpSendMsg(
                        'mcp-negotiate-can',
                            'package',
                             $mcpObj->name,
                             'min-version',
                             $mcpObj->minVersion,
                             'max-version',
                             $mcpObj->maxVersion,
                    );
                }

                # For the MCP package 'mcp-negotiate', version 1.0, no 'mcp-negotiate-can' message
                #   is sent for the 'mcp-negotiate' package itself; support for it is implicit
                # Implement this by setting the MCP package object's ->useVersion to 1.0 now, and
                #   let the world tell the GA::Session to update to version 2.0
                $negotiateObj = $self->ivShow('mcpPackageHash', 'mcp-negotiate');
                if ($negotiateObj) {

                    $negotiateObj->set_useVersion('1.0');
                }

                # In addition, in MCP package 'mcp-negotiate', version 1.0, there is no
                #   'mcp-negotiate-end' message
                # Deal with this by setting a time limit, after which if we still haven't received
                #   the 'mcp-negotiate-end', insert an artifical one (which takes care of all the
                #   complications)
                $self->ivPoke('mcpCheckTime', $self->sessionTime + $self->mcpWaitTime);

            } else {

                # MCP versions supported by the server not supported by the client
                $self->ivPoke('mcpMode', 'client_refuse');
                $self->mcpDebug(
                    $token,
                    'Unsupported MCP version range ' . $min . '-' . $max,
                    8000,
                );
            }
        }

        return 1;
    }

    sub findNewLineEscape {

        # Called by $self->tokeniseIncomingData
        # Finds the position of the first newline or escape character in a string, ignoring the
        #   first character of that string (which is assumed to be a newline or escape character)
        #
        # Expected arguments
        #   $string     - The string to check
        #
        # Return values
        #   'undef' on improper arguments or if $string doesn't contain any newline or escape
        #       characters (ignoring the first character of $string)
        #   Otherwise, returns the offset of the first newline or escape character in $string

        my ($self, $string, $check) = @_;

        # Local variables
        my ($num, $offset);

        # Check for improper arguments
        if (! defined $string || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findNewLineEscape', @_);
        }

        # Ignore the first character
        $string = substr($string, 1);

        foreach my $char ("\n", "\r", "\e") {

            my $num = index($string, $char);

            if ($num >= 0 && (! defined $offset || $offset > $num)) {

                $offset = $num;
            }
        }

        # If no newline/escape characters were found (besides the first character), $offset will
        #   still be 'undef'
        if (defined $offset) {

            # Take account of the initial character we removed
            return $offset + 1;

        } else {

            return undef;
        }
    }

    sub checkLineSplit {

        # Called by $self->processLinePortion to work out if a line portion should be split into two
        #   or more segements, because the line portion matches one of the world profile's command
        #   prompt patterns, or because it matches a splitter trigger
        # (This only affects the way Axmud treats received text, after all non-text tokens have been
        #   extracted by $self->processIncomingData; for example, it doesn't affect MXP at all)
        #
        # Returns a list of offsets representing the first character of each segment
        # It's not possible to split the line portion at the beginning or the end, so the list of
        #   offsets never contains 0 or n, where n = the length of the line
        # The list of offsets is returned in ascending order, and there are no duplicate offsets (so
        #   if a command prompt and a splitter trigger both split the line portion at the same
        #   place, that offset is only added to the list once)
        #
        # Expected arguments
        #   $type           - 'nl' if the received line of text ended with a newline token (which
        #                       has since been removed from $stripText), 'go' if the received line
        #                       of text ended with an artificially-inserted newline token, 'ga' if
        #                       the received line of text ended with an IAC GA 'go ahead' token, or
        #                       'part' if the received line of text didn't end with a newline
        #                       character at all
        #   $stripText      - The line portion (containing only combined text tokens)
        #
        # Return values
        #   An empty list on improper arguments or if no command prompt patterns or splitter
        #       triggers match the line portion
        #   Otherwise, returns the list of offsets described above

        my ($self, $type, $stripText, $check) = @_;

        # Local variables
        my (
            @emptyList, @offsetList, @deleteList,
            %checkHash,
        );

        # Check for improper arguments
        if (! defined $type || ! defined $stripText || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->checkLineSplit', @_);
            return @emptyList;
        }

        # First split the line portion, according to the command prompt patterns specified by the
        #   current world profile
        foreach my $pattern ($self->currentWorld->cmdPromptPatternList) {

            while ($stripText =~ m/($pattern).+/g) {

                my $offset = $+[1];

                if ($offset != 0 && $offset != length($stripText)) {

                    push (@offsetList, $offset);
                    # We'll use a hash to eliminate duplicates, once we start checking splitter
                    #   triggers
                    $checkHash{$offset} = undef;
                }
            }
        }

        # Check every active trigger interface, in the correct order, looking for splitter triggers
        #   that match $stripText
        OUTER: foreach my $number ($self->triggerOrderList) {

            my ($obj, $stimulus, $response, $afterFlag, $ignoreFlag, $count, $copyLine);

            $obj = $self->ivShow('interfaceNumHash', $number);

            # If the trigger isn't a splitter trigger, ignore it
            # If the trigger is a dependent trigger, don't fire it
            # If the trigger is disabled, don't fire it
            # If the trigger requires a received line of text ending with a newline character and
            #   received line of text doesn't end with a newline character (real or artificial),
            #   don't fire it
            # If the trigger requires a login and the character isn't logged in, don't fire it
            if (
                ! $obj->ivShow('attribHash', 'splitter')
                || ! $obj->indepFlag
                || ! $obj->enabledFlag
                || ($obj->ivShow('attribHash', 'need_prompt') && $type ne 'ga' && $type ne 'part')
                || ($obj->ivShow('attribHash', 'need_login') && ! $self->loginFlag)
            ) {
               next OUTER;
            }

            $stimulus = $obj->stimulus;
            $response = $obj->response;
            $afterFlag = $obj->ivShow('attribHash', 'split_after');
            $ignoreFlag = $obj->ivShow('attribHash', 'ignore_case');
            $count = 0;

            # An independent splitter trigger.
            # The trigger's 'stimulus' attribute must match $stripText for the trigger to fire
            # (NB Using $stripText in the block breaks the algorithm. No idea why, but a workaround
            #   is to perform the pattern match on another variable)
            $copyLine = $stripText;
            if (
                (! $ignoreFlag && ($copyLine =~ m/$stimulus/g))
                || ($ignoreFlag && ($copyLine =~ m/$stimulus/gi))
            ) {
                # Now we test the line portion against the trigger's 'response' attribute. We split
                #   the line portion immediately before the matching portion of text or, if the
                #   trigger's 'keep_splitting' attribute is TRUE, immediately after it
                if ($ignoreFlag) {

                    while ($stripText =~ m/($response)/gi) {

                        my $offset = $-[0];             # Split at beginning of matching segment

                        if ($afterFlag) {

                            $offset += length($1) ;     # Split at end of matching segment
                        }

                        if (
                            $offset > 0
                            && $offset < length($stripText)
                            && ! exists $checkHash{$offset}
                        ) {
                            push (@offsetList, $offset);
                            $checkHash{$offset} = undef;
                            $count++;
                        }
                    }

                } else {

                    while ($stripText =~ m/($response)/g) {

                        my $offset = $-[0];             # Split at beginning of matching segment

                        if ($afterFlag) {

                            $offset += length($1) ;     # Split at end of matching segment
                        }

                        if (
                            $offset > 0
                            && $offset < length($stripText)
                            && ! exists $checkHash{$offset}
                        ) {
                            push (@offsetList, $offset);
                            $checkHash{$offset} = undef;
                            $count++;
                        }
                    }
                }

                # The trigger has fired only if both the stimulus and response match $stripText
                if ($count) {

                    # Temporary triggers should be marked for deletion, after firing for the first
                    #   time
                    if ($obj->ivShow('attribHash', 'temporary')) {

                        push (@deleteList, $obj);
                    }

                    # Should we continue checking other triggers?
                    if (! $obj->ivShow('attribHash', 'keep_checking')) {

                        # Don't check any more triggers
                        last OUTER;
                    }
                }
            }
        }

        # Any temporary triggers which fired can now be deleted
        foreach my $obj (@deleteList) {

            $self->removeInterface($obj);
        }

        # Sort the list of offsets, before returning it
        return (sort {$a <=> $b} (@offsetList));
    }

    # (Called by ->tokeniseIncomingData to extract tokens)

    sub extractEscSequence {

        # Called by $self->tokeniseIncomingData when it encounters an "\e" escape character, which
        #   probably starts an escape sequence
        # Attempts to extract a valid escape sequence
        #
        # Valid escape sequences are one of the following:
        #   - OSC colour palette escape sequences, in the form: ESC [ P xxxxxxx
        #   - MXP escape sequences in the form: ESC [ # z
        #   - VT100 SGR (Select Graphic Rendition) escape sequences (most of which are implemented)
        #   - xterm titlebar escape sequences in the form: ESC ] 0 ; xxx BEL
        #   - xterm trucolor foreground escape sequences in the form: ESC [ 38 ; 2 ; r ; g ; b m
        #   - xterm trucolor background escape sequences in the form: ESC [ 48 ; 2 ; r ; g ; b m
        #   - Other VT100 escape sequences (many of which are recognised, but not implemented)
        #
        # Expected arguments
        #   $text   - The remaining portion of the received text, which in this case starts with an
        #               "\e" escape character
        #
        # Return values
        #   An empty list on improper arguments or if no valid escape sequence can be extracted
        #   Otherwise, returns a list in the form (token, data, seq_type), where 'token' is the
        #       complete escape sequence token, 'data' is the escape sequence token with some of the
        #       initial characters removed, and 'seq_type' is one of the strings 'osc', 'mxp',
        #       'sgr', 'vt', 'xterm', 'trcol_fg' or 'trcol_bg'

        my ($self, $text, $check) = @_;

        # Local variables
        my (
            $regex,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractEscSequence', @_);
            return @emptyList;
        }

        # Check there is some text after the initial "\e" character
        if (length $text <= 1) {

            return @emptyList;
        }

        # OSC colour palette escape sequences
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b\x5bP           - The escape character (ASCII 27) + [P
        #   [0-9A-F]{7}         - Seven hexadecimal characters
        if ($axmud::CLIENT->oscPaletteFlag && $text =~ m/^((\x1b\x5bP)([0-9A-Fa-f]{7}))/) {

            # $3 is the token, with the initial ESC [ P removed
            return ($1, $3, 'osc');

        # MXP escape sequences (only extracted when MXP is enabled)
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b\x5b            - The escape character (ASCII 27) + [
        #   \d+                 - One or more integers
        #   z                   - Final z character
        } elsif ($self->mxpMode eq 'client_agree' && $text =~ m/^((\x1b\x5b)(\d+)z)/) {

            # $3 is the token, with the initial ESC [ removed and final z removed
            return ($1, $3, 'mxp');

        # xterm titlebar sequences
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b\x5d\0\;        - The escape character (ASCII 27) + ]0;
        #   .*                  - The middle section
        #   ?                   - Ungreedy match of the final character...
        #   \x{7}               - ...which is the BEL character (wrapped in {..} to prevent a
        #                           Perl error)
        } elsif ($text =~ m/^((\x21\x5d\0\;)(.*?)\x{7})/) {

            # $3 is the token, with the initial ESC ] 0 ; and final BEL removed
            return ($1, $3, 'xterm');

        # xterm trucolor sequences
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b\x5b            - The escape character (ASCII 27) + [
        #   [3|4]8;2;           - Foreground/background identifier
        #   [\d\;]*             - The middle section, in the form 'Value;Value;Value;'
        #   m                   - Final m character
        } elsif ($text =~ m/^((\x1b\x5b)([3|4]8;2;)([\d\;]*m))/) {

            # $3 specifies a foreground or background colour
            # $4 is the token, with the initial ESC ] 0 ; (but not the final m) removed
            if ($3 eq '38;2;') {
                return ($1, $4, 'trcol_fg');
            } else {
                return ($1, $4, 'trcol_bg');
            }

        # VT100 SGR (Select Graphic Rendition) escape sequences
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b\x5b            - The escape character (ASCII 27) + [
        #   \x9b                - ASCII 155
        #   [\d\;]*             - The middle section, in the form 'Value;Value;Value...'
        #   m                   - Final m character
        } elsif ($text =~ m/^((\x1b\x5b|\x9b)([\d\;]*m))/) {

            # $3 is the token, with the initial ESC [ (but not the final m) removed
            return ($1, $3, 'sgr');

        # Other VT100 escape sequences, including VT52 compatibility mode
        #   (...)               - Group substring contains the whole escape sequence
        #   \x1b                - The escape character (ASCII 27)
        #   \[[cg]              - ESC [ CHAR
        #   \[\d*[ABCDchilnq]   - ESC [ num CHAR
        #   \[\?\d[ghl]         - ESC [ ? int CHAR
        #   \[[\d\;]*[FHJKRry]  - ESC [ num ; num ... CHAR          (num and ; are optional)
        #   [\(\)][AB0123]      - ESC ( CHAR   /   ESC ) CHAR   /   ESC ( INT   /   ESC ) INT
        #   \#[3-68]            - ESC # int
        #   [\=\<\>ABCDEFGHIJKMNOZc78]      - ESC CHAR   /   ESC NUM
        #   \/Z                 - ESC/Z
        #   \d+                 - ESCLineColumn
        } else {

            $regex = '^((\x1b)(\[[cg]|\[\d*[ABCDchilnq]|\[\?\d[ghl]|\[[\d\;]*[FHJKRry]'
                        . '|[\(\)][AB0123]|\#[3-68]|[\=\<\>ABCDEFGHIJKMNOZc78]|\/Z|\d+))';

            if ($text =~ m/$regex/) {

                # $3 is the token, with the initial ESC removed
                return ($1, $3, 'vt');

            } else {

                # No valid escape sequence found. Display a debug message, if required, but don't
                #   bother if $text is just 'ESC['
                if (
                    $axmud::CLIENT->debugEscSequenceFlag
                    && $text ne "\e["
                ) {
                    $self->writeDebug(
                        'Invalid escape sequence: ESC+' . substr($text, 1),
                        $self->_objClass . '->extractEscSequence',
                    );
                }

                return @emptyList;
            }
        }
    }

    sub extractMspSoundTrigger {

        # Called by $self->tokeniseIncomingData when it encounters a token starting "!!SOUND" or
        #   "!!MUSIC", at the start of a line, which probably starts an MSP sound trigger
        # Attemps to extract a valid MSP sound trigger in the form
        #   !!SOUND(...)
        #   !!MUSIC(...)
        #
        # Expected arguments
        #   $text   - The remaining portion of the received text, which in this case starts with
        #               "!!SOUND" or "!!MUSIC"
        #
        # Return values
        #   'undef' on improper arguments or if it's not a valid MSP sound trigger
        #   An empty string if it's an incomplete MSP sound trigger
        #   Otherwise returns the token containing the valid MSP sound trigger

        my ($self, $text, $check) = @_;

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->extractMspSoundTrigger', @_);
        }

        # Extract the MSP sound trigger
        if ($text =~ m/^(\!\!(SOUND|MUSIC)\([^\n\r\e\)]*\))/) {

            # Valid MSP sound trigger
            return $1;

        } elsif ($text =~ m/^(\!\!(SOUND|MUSIC)\(^\n\r\e\)$)/) {

            # Incomplete MSP sound trigger; append it to the emergency buffer, and wait for the
            #   next packet
            return '';

        } else {

            # Invalid MSP sound trigger
            return undef;
        }
    }

    sub extractMxpPuebloElement {

        # Called by $self->tokeniseIncomingData when it encounters a "<" character, which probably
        #   starts an MXP/Pueblo element
        # Attempts to extract a valid MXP/Pueblo element in the form <...>, which may itself contain
        #   embedded <...> structures
        #
        # MXP comments are in the form <!-- this is a comment -->. The intermediate section 'this is
        #   a comment' can contain anything except '-->' itself and the "\n", "\r" and "\e"
        #   characters; this function tries to extract an MXP comment, before extracting an MXP
        #   element
        # MXP elements are also abnormally terminated by "\n", "\r" and "\e"
        # The calling function should have checked that MXP is enabled, i.e. $self->mxpMode is
        #   'client_agree'
        #
        # There's no specification for how Pueblo elements are abnormally terminated, so we apply
        #   the same rules
        # The calling function should have checked that Pueblo is enabled, i.e. $self->puebloMode is
        #   'client_agree'
        #
        # Expected arguments
        #   $text   - The remaining portion of the received text, which in this case starts with a
        #               "<" character
        #
        # Return values
        #   'undef' on improper arguments or if it's an abnormally terminated MXP/Pueblo element
        #   An empty string if it's an incomplete MXP/Pueblo element
        #   Otherwise returns the token containing the valid MXP/Pueblo element

        my ($self, $text, $check) = @_;

        # Local variables
        my (
            $endPosn, $comment, $nlPosn, $escPosn, $origText, $bracketCount, $elemLength,
            $singleFlag, $doubleFlag,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->extractMxpPuebloElement',
                @_,
            );
        }

        # Check there is some text after the initial "<" character
        if (length $text <= 1) {

            # Incomplete MXP element, probably because it is split across two packets
            return '';
        }

        # Extract an MXP comment, if one is found
        # (There are no Pueblo comments to extract)
        if ($self->mxpMode eq 'client_agree' && substr($text, 0, 4) eq '<!--') {

            $endPosn = index($text, '-->');
            if ($endPosn == -1) {

                # Incomplete MXP comment, probably because it is split across two packets. Wait for
                #   the next packet
                return '';
            }

            $comment = substr($text, 0, ($endPosn + 3));

            # The comment is abnormally terminated by an "\n" or "\e" escape character
            $nlPosn = index($comment, "\n");
            if ($nlPosn == -1) {

                $nlPosn = index($comment, "\r");
            }

            if ($nlPosn != -1) {

                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<!--',         # We don't have the whole token yet
                        'Comment abnormally terminated by newline character',
                        1001,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<!--',
                        'Comment abnormally terminated by newline character',
                        6001,
                    );
                }

                # Abnormally terminated element
                return undef;
            }

            $escPosn = index($comment, "\e");
            if ($escPosn != -1) {

                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<!--',         # We don't have the whole token yet
                        'Comment abnormally terminated by escape character',
                        1002,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<!--',
                        'Comment abnormally terminated by escape character',
                        6002,
                    );
                }

                # Abnormally terminated element
                return undef;
            }

            # Valid comment found
            if ($self->mxpMode eq 'client_agree' && $axmud::CLIENT->debugMxpCommentFlag) {

                $self->writeDebug('MXP COMMENT: ' . $comment);

            } elsif (
                $self->puebloMode eq 'client_agree'
                && $axmud::CLIENT->debugPuebloCommentFlag
            ) {
                $self->writeDebug('PUEBLO COMMENT: ' . $comment);
            }

            return $comment;
        }

        # Remove the first character, which is guaranteed to be a "<"
        $origText = $text;
        $text = substr($text, 1);
        # The initial "<" character is the first bracket
        $bracketCount = 1;
        # (The length of the element, so far, is 1 character - i.e. the initial '<'
        $elemLength = 1;

        # Go throught $text, increasing $bracketCount for every '<' found, and decreasing it for
        #   every '>' found until $bracketCount is 0 (or the end of $text is reached)
        # However, we ignore '<' and '>' character inside a set of single '..' or double ".."
        #   quotes. (Literal ' or " quote characters can be specified within element arguments by
        #   using '' or ""; the algorithm takes account of this)
        do {

            my (
                $thisPosn, $type, $leftPosn, $rightPosn, $singlePosn, $doublePosn, $nlPosn,
                $escPosn,
            );

            $leftPosn = index($text, "<");
            if ($leftPosn > -1) {

                $thisPosn = $leftPosn;
                $type = 'left';
            }

            $rightPosn = index($text, ">");
            if ($rightPosn > -1 && (! defined $thisPosn || $rightPosn < $thisPosn)) {

                $thisPosn = $rightPosn;
                $type = 'right';
            }

            $singlePosn = index($text, "'");
            if ($singlePosn > -1 && (! defined $thisPosn || $singlePosn < $thisPosn)) {

                $thisPosn = $singlePosn;
                $type = 'single';
            }

            $doublePosn = index($text, "\"");
            if ($doublePosn > -1 && (! defined $thisPosn || $doublePosn < $thisPosn)) {

                $thisPosn = $doublePosn;
                $type = 'double';
            }

            # The element is abnormally terminated by an "\n", "\r" or "\e" escape character
            $nlPosn = index($text, "\n");
            if ($nlPosn != -1 && (! defined $thisPosn || $nlPosn < $thisPosn)) {

                $thisPosn = $nlPosn;
                $type = 'nl';
            }

            $nlPosn = index($text, "\r");
            if ($nlPosn != -1 && (! defined $thisPosn || $nlPosn < $thisPosn)) {

                $thisPosn = $nlPosn;
                $type = 'nl';
            }

            $escPosn = index($text, "\e");
            if ($escPosn != -1 && (! defined $thisPosn || $escPosn < $thisPosn)) {

                $thisPosn = $escPosn;
                $type = 'esc';
            }

            if (! $type) {

                # Incomplete MXP element, probably because it is split across two packets
                return '';

            } elsif ($type eq 'left') {

                if (! $singleFlag && ! $doubleFlag) {

                    # < found and it's not inside a pair of single/double quotes
                    $bracketCount++;
                }

            } elsif ($type eq 'right') {

                if (! $singleFlag && ! $doubleFlag) {

                    # > found, and it's not inside a pair of single/double quotes
                    $bracketCount--;
                }

            } elsif ($type eq 'single') {

                # Ignore single quotes inside a pair of double quotes
                if (! $doubleFlag) {

                    if (! $singleFlag) {
                        $singleFlag = TRUE;
                    } else {
                        $singleFlag = FALSE;
                    }
                }

            } elsif ($type eq 'double') {

                # Ignore double quotes inside a pair of single quotes
                if (! $singleFlag) {

                    if (! $doubleFlag) {
                        $doubleFlag = TRUE;
                    } else {
                        $doubleFlag = FALSE;
                    }
                }

            } elsif ($type eq 'nl') {

                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<',            # We don't have the whole token yet
                        'Element abnormally terminated by newline character',
                        1011,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<',
                        'Element abnormally terminated by newline character',
                        6011,
                    );
                }

                # Abnormally terminated element
                return undef;

            } elsif ($type eq 'esc') {

                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<',            # We don't have the whole token yet
                        'Element abnormally terminated by escape character',
                        1012,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<',
                        'Element abnormally terminated by escape character',
                        6012,
                    );
                }

                # Abnormally terminated element
                return undef;
            }

            # We can dispense with all text up to and including this bracket or quote character
            $text = substr($text, $thisPosn + 1);
            $elemLength += ($thisPosn + 1);

        } until (! $text || ! $bracketCount);

        if ($bracketCount) {

            # Incomplete MXP element, probably because it is split across two packets
            return '';

        } else {

            # End of the element found
            return substr($origText, 0, $elemLength);
        }
    }

    sub extractMxpPuebloEntity {

        # Called by $self->processIncomingData when it encounters a "&" character, which probably
        #   starts an MXP/Pueblo entity
        # Attempts to extract a valid MXP/Pueblo entity in the form &keyword;
        # The entity keyword must start with a letter (A-Za-z) and then consist of letters, numbers
        #   or underline characters. No other characters are permitted (including non-Latin
        #   alphabets)
        # (This function also recognises entities in the form '&#nnn;' )
        # If a valid entity isn't found, the calling function displays the text 'as is'
        #
        # NB The calling function should have checked that MXP or Pueblo is enabled, i.e.
        #   $self->mxpMode or $self->puebloMode is 'client_agree'
        #
        # Expected arguments
        #   $text   - The remaining portion of the received text, which in this case starts with a
        #               "&" character
        #
        # Return values
        #   'undef' on improper arguments or if an invalid entity is found
        #   Otherwise returns the token containing the valid entity

        my ($self, $text, $check) = @_;

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->extractMxpPuebloEntity', @_);
        }

        # Check there is some text after the initial "&" character
        if (length $text <= 1) {

            return undef;
        }

        # Try to extract an entity
        if ($text =~ m/(\&[A-Za-z][A-Za-z0-9_]*\;)/) {

            return $1;

        # Entities in the form '&#nnn;' are also recognised
        } elsif ($text =~ m/(\&\#[0-9]{1,3}\;)/) {

            return $1;

        } else {

            return undef;
        }
    }

    # (Called by ->processIncomingData to convert a non-text token into a tag list)

    sub updateEndLine {

        # Called by $self->processIncomingData
        #
        # After a line portion ending in a newline character has been displayed, updates IVs and
        #   prepares a list of Axmud colour/style tags that should be applied to the beginning of
        #   the next line (because of MXP stuff)
        #
        # Expected arguments
        #   $type       - The token type, 'nl' for an ordinary newline token or 'go' for an
        #                   artificially-inserted newline token
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of Axmud colour/style tags that should be applied to the
        #       beginning of the next line (may be an empty list)

        my ($self, $type, $check) = @_;

        # Local variables
        my (@emptyList, @tagList);

        # Check for improper arguments
        if (! defined $type || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->updateEndLine', @_);
            return @emptyList;
        }

        if (defined $self->mxpLineMode) {

            # If we're in the middle of a <V>...</V> construction, the construction is abnormally
            #   terminated
            if ($self->mxpCurrentVar) {

                $self->mxpDebug(
                    "\n",
                    'Variable abnormally terminated by newline character',
                    1301,
                );

                $self->ivUndef('mxpCurrentVar');
            }

            # If we're in the middle of an <A>...</A> construction, the construction is abnormally
            #   terminated
            if ($self->mxpCurrentLink) {

                $self->mxpDebug(
                    "\n",
                    'Link abnormally terminated by newline character',
                    1302,
                );

                $self->ivUndef('mxpCurrentLink');
            }

            # If we're in the middle of a <SEND>...</SEND> construction, the construction is
            #   abnormally terminated
            if ($self->mxpCurrentSend) {

                $self->mxpDebug(
                    "\n",
                    'Send abnormally terminated by newline character',
                    1303,
                );

                $self->ivUndef('mxpCurrentSend');
            }

            # All outstanding 'open' tags are closed after a newline character (but not after a line
            #   spacing tag like <BR>, any only in 'open line mode')
            if ($type eq 'nl' && $self->mxpLineMode == 0) {

                push (@tagList, $self->partiallyEmptyMxpStack());
            }

            # Newline characters cause the MXP line mode to be reset to the default mode (but line
            #   spacing tags like <BR> do not)
            if ($type eq 'nl') {

                if (! $self->mxpDefaultMode) {

                    # When ->mxpDefaultMode is 0, the default mode is 'open'
                    push (@tagList, $self->setMxpLineMode(0));

                } else {

                    # ->mxpDefaultMode values of 5-7 correspond to ->mxpLineMode values of 0-2
                    push (@tagList, $self->setMxpLineMode($self->mxpDefaultMode - 5));
                }
            }
        }

        # If we're in the middle of two matching custom tags which defined tag properties, e.g.
        #   from the MXP spec, <RName>...</RName>, update the stored text
        # Represent the newline character as a space, but only if the existing stored text ends with
        #   a non-whitespace character (an acceptable compromise over textual purity)
        foreach my $key ($self->ivKeys('mxpFlagTextHash')) {

            my $text = $self->ivShow('mxpFlagTextHash', $key);

            if ($text ne '' && $text =~ m/\S$/) {

                $self->ivAdd('mxpFlagTextHash', $key, $text . ' ');
            }
        }

        # If Pueblo is waiting for a new line to insert the Axmud style tag 'justify_default',
        #   inform $self->processIncomingData that it's now safe to do so
        if ($self->puebloJustifyMode eq 'wait_newline') {

            $self->ivPoke('puebloJustifyMode', 'wait_loop');
        }

        return @tagList;
    }

    sub processEscChar {

        # Called by $self->processIncomingData when it processes an escape token (containing a
        #   single escape character, i.e. one which doesn't start a valid escape sequence)
        #
        # Processes the escape token
        #
        # Expected arguments
        #   $token      - An extracted token containing the escape character
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of Axmud colour/style tags generated by closing any open MXP
        #       tags (may be an empty list)

        my ($self, $token, $check) = @_;

        # Local variables
        my (@emptyList, @tagList);

        # Check for improper arguments
        if (! defined $token || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processEscChar', @_);
            return @emptyList;
        }

        # Update IVs as if this token were a text token (so, the escape character is in fact
        #   displayed on a subsequent call to $self->processLinePortion)

        if (defined $self->mxpLineMode) {

            # If we're in the middle of a <V>...</V> construction, the construction is abnormally
            #   terminated
            if ($self->mxpCurrentVar) {

                $self->mxpDebug(
                    $token,
                    'Variable abnormally terminated by escape character',
                    1401,
                );

                $self->ivUndef('mxpCurrentVar');
            }

            # If we're in the middle of an <A>...</A> construction, the construction is abnormally
            #   terminated
            if ($self->mxpCurrentLink) {

                $self->mxpDebug(
                    $token,
                    'Link abnormally terminated by escape character',
                    1402,
                );

                $self->ivUndef('mxpCurrentLink');
            }

            # If we're in the middle of a <SEND>...</SEND> construction, the construction is
            #   abnormally terminated
            if ($self->mxpCurrentSend) {

                $self->mxpDebug(
                    $token,
                    'Send abnormally terminated by escape character',
                    1403,
                );

                $self->ivUndef('mxpCurrentSend');
            }

            # All outstanding tags are closed after a newline character (but only in 'open line
            #   mode')
            if ($self->mxpLineMode == 0) {

                @tagList = $self->emptyMxpStack();
            }
        }

        return @tagList;
    }

    sub processEscSequence {

        # Called by $self->processIncomingData when it processes an escape sequence token
        # Any undisplayed text doesn't need to be displayed before this token is processed
        #   (otherwise, $self->processCtrlSequence is called instead)
        #
        # For OSC colour palette sequences, stores the newly-defined colour in $self->oscColourHash
        # For MXP escape sequences, updates IVs
        # For VT100 SGR (Select Graphic Rendition) escape sequences, converts the sequence into a
        #   list of Axmud colour/style tags
        # For xterm titlebar escape sequences, updates IVs
        # For xterm trucolor escape sequences, converts the seequences into an Axmud colour tag
        #
        # NB If we're in the middle of an MXP <V>...</V> construction, a valid escape sequence
        #   doesn't abnormally terminate the construction (but an invalid escape sequence does,
        #   handled by ->processEscChar, does)
        #
        # Expected arguments
        #   $token  - An extracted token containing the escape sequence
        #   $data   - The middle portion of the escape sequence, after the initial (compulsory)
        #               characters have been removed
        #   $type   - The type of escape sequence: 'osc', 'mxp', 'sgr', 'xterm', 'trcol_fg' or
        #               'trcol_bg'
        #
        # Return values
        #   An empty list on improper arguments or if the escape sequence is invalid
        #   Otherwise, returns a list of equivalent Axmud colour/style tags, when required (may be
        #       an empty list)

        my ($self, $token, $data, $type, $check) = @_;

        # Local variables
        my (
            $char, $colour, $tag,
            @emptyList, @valueList, @tagList,
            %colourHash, %styleHash,
        );

        # Check for improper arguments
        if (! defined $token || ! defined $data || ! defined $type || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processEscSequence', @_);
            return @emptyList;
        }

        # Import IVs (for quick lookup)
        %colourHash = $axmud::CLIENT->constANSIColourHash;
        %styleHash = $axmud::CLIENT->constANSIStyleHash;

        # Process the escape sequence
        if ($type eq 'osc') {

            # OSC colour palette escape sequence in the form: ESC [ P xxxxxxx
            # (The earlier call to $self->extractEscSequence checked that OSC colour paletters are
            #   enabled, i.e. GA::Client->oscPaletteFlag is TRUE)

            # Split the 'xxxxxxx' part into two components
            $char = substr($data, 0, 1);    # Which basic ANSI colour to change, in the range 0-F
            $colour = substr($data, 1);     # The RGB colour to use instead, e.g. FF0000

            # Update $self->oscColourHash. If $char is invalid, however, ignore this escape
            #   sequence
            $tag = $axmud::CLIENT->ivShow('constOscPaletteHash', uc($char));
            if (defined $tag) {

                $self->ivAdd('oscColourHash', $tag, '#' . uc($colour));
            }

        } elsif ($type eq 'mxp') {

            # MXP escape sequences in the form ESC [ # z
            # (The earlier call to $self->extractEscSequence checked that MXP is allowed, i.e.
            #   $self->mxpMode is 'client_agree')

            # As soon as the first MXP escape sequence is received, the MXP mode IVs must be set to
            #   their default values
            if (! defined $self->mxpLineMode) {

                $self->ivPoke('mxpLineMode', 0);
                $self->ivPoke('mxpDefaultMode', 0);
                $self->ivPoke('mxpTempMode', undef);
            }

            # Turn on the window blinker, and update IVs
            $self->turnOnBlinker(1);

            # $data contains an integer, the '#' in ESC [ # \
            # $data should be a value in the range 0-7, 10-12, 19-99; but it's allowed for
            #   these values to have leading zeros. Make sure any leading zeros are removed
            $data += 0;
            # Check that it's a valid line value
            if (
                ! (
                    ($data >= 0 && $data <= 7)
                    || ($data >= 10 && $data <= 12)
                    || ($data >= 19 && $data <= 99)
                )
            ) {
                # Invalid MXP escape sequence; ignore it
                $self->mxpDebug(
                    $token,
                    'Invalid value \'' . $data . '\' in MXP escape sequence (expected 0-7,'
                    . ' 10-12, 19, 20-99)',
                    1501,
                );

                return @emptyList;
            }

            # Process the sequence (modes 0-7 are the most frequent)
            if ($data <= 7) {

                # 0-7: Line mode escape sequences
                if ($data >= 0 && $data <= 2) {

                    # 0 - Open line, 1 - Secure line, 2 - Locked line
                    push (@tagList, $self->setMxpLineMode($data));
                    $self->ivUndef('mxpTempMode');

                } elsif ($data == 3) {

                    # 3 - Reset
                    # Close all open tags
                    push (@tagList, $self->emptyMxpStack());

                    # Update IVs
                    push (@tagList, $self->setMxpLineMode(0));
                    $self->ivPoke('mxpDefaultMode', 0);
                    $self->ivUndef('mxpTempMode');

                } elsif ($data == 4) {

                    # 4 - Temp secure mode
                    $self->ivPoke('mxpTempMode', $self->mxpLineMode);
                    push (@tagList, $self->setMxpLineMode(1, TRUE));

                } elsif ($data >= 5 && $data <= 7) {

                    # 5 - Lock open mode, 6 - Lock secure mode, 7 - Lock locked mode
                    $self->ivPoke('mxpDefaultMode', $data);
                    $self->ivUndef('mxpTempMode');
                    push (@tagList, $self->setMxpLineMode($data - 5));
                }

            } elsif ($data >=10 && $data <= 12) {

                # Room modes
                push (@tagList, 'mxpm_' . $data);       # e.g. 'mxpm_10'

            } elsif ($data == 19) {

                # Welcome text
                push (@tagList, 'mxpm_19');
                # ...which is not displayed during an MXP relocate operation
                if ($self->mxpRelocateMode ne 'none') {

                    $self->ivPoke('mxpRelocateQuietLineFlag', TRUE);
                }

            } elsif ($data >= 20 && $data <= 99) {

                # User-defined modes
                push (@tagList, 'mxpm_' . $data);       # e.g. 'mxpm_20'
            }

        } elsif ($type eq 'sgr') {

            # VT100 SGR (Select Graphic Rendition) escape sequences in the form
            #   ESC [ Value ; ... ; Value m

            # Extract the value(s)
            # A list of integers separated by ';' characters
            $data = substr($data, 0, (length($data) - 1));

            # Some worlds (e.g. Viking MUD) use the escape sequence 'Esc [ m' instead of 'Esc [ 0 m'
            # Convert the former to the latter, if found
            if (! $data) {

                $data = '0';
            }

            # $data is in the form 'Value;...;Value'. where Value is in the range 0-1, 3-9, 22-25,
            #   27-29, 30-39, 40-49
            @valueList = split(/;/, $data);
            # It's valid to use 'Value's with leading 0s. Remove the leading zeros
            foreach my $value (@valueList) {

                if ($value =~ m/^\d+$/) {

                    $value += 0;
                }
            }

            # NB We're using 'eq' rather than '==' to prevent a Perl error, if we analyse a sequence
            #   containing 'ESC [ 1 , 31 m' rather than the correct 'ESC [ 1 ; 31 m', or even if the
            #   string contains invalid non-numerical characters
            #
            # Special case: xterm-256 colours will set @valueList to (38, 5, n) or (48, 5, n)
            #   (corresponding to escape sequences 'ESC [ 38 ; 5 ; n m' and 'ESC [ 48 ; 5 ; n m').
            #   In this case, @valueList must contain exactly 3 values
            if ($valueList[0] eq '38' || $valueList[0] eq '48') {

                # If it's not a valid sequence, ignore it
                if (
                    scalar @valueList == 3
                    && $valueList[1] eq '5'
                    && $axmud::CLIENT->ivExists('xTermColourHash', 'x' . $valueList[2])
                ) {
                    if ($valueList[0] eq '38') {
                        push (@tagList, 'x' . $valueList[2]);       # e.g. 'x255'
                    } else {
                        push (@tagList, 'ux' . $valueList[2]);      # e.g. 'ux255'
                    }
                }

            # Otherwise, it's an ANSI escape sequence, which can contain an arbitary number of
            #   values
            } else {

                INNER: foreach my $value (@valueList) {

                    # (Sequences listed roughly in order of popularity)

                    # 0 - All attributes off
                    if ($value eq '0') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'attribs_off');

                    # 1 - Bold on
                    } elsif ($value eq '1') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'bold');

                    # 22 - Bold off
                    } elsif ($value eq '22') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'bold_off');

                    # 30-37 - Text colours
                    # 40-47 - Underlay colours
                    } elsif (exists $colourHash{$value}) {

                        push (@tagList, $colourHash{$value});   # e.g. 'red', 'ul_red'

                    # 3 - Italics
                    # 4 - Underline on
                    # 5 - Blink (slow) on
                    # 6 - Blink (rapid) on
                    } elsif (exists $styleHash{$value}) {

                        push (@tagList, $styleHash{$value});

                    # 7 - Reverse video on
                    } elsif ($value eq '7') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'reverse');

                    # 27 - Reverse video off
                    } elsif ($value eq '27') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'reverse_off');

                    # 8 - Conceal on
                    } elsif ($value eq '8') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'conceal');

                    # 28 - Conceal off
                    } elsif ($value eq '28') {

                        # Use a dummy Axmud style tag
                        push (@tagList, 'conceal_off');

                    # 39 - Default text colour
                    } elsif ($value eq '39') {

                        push (@tagList, $self->currentTabObj->textViewObj->textColour);

                    # 49 - Default underlay colour
                    } elsif ($value eq '49') {

                        push (
                            @tagList,
                            $self->currentTabObj->textViewObj->underlayColour,
                        );
                    }
                }
            }

        } elsif ($type eq 'xterm') {

            # xterm titlebar escape sequences in the form 'ESC]0;xxxBEL'

            # Update IVs
            $self->ivPoke('xTermTitle', $data);

            if ($axmud::CLIENT->xTermTitleFlag) {

                # The tab title must be updated (the routine call to $self->checkTabLabels by
                #   $self->spinMaintainLoop will handle it)
                $self->ivPoke('showXTermTitleFlag', TRUE);
            }

        } elsif ($type eq 'trcol_fg' || $type eq 'trcol_bg') {

            # xterm trucolor escape sequences in the form 'ESC[38;2;r;g;bm' (for foreground colours)
            #   or 'ESC[48;2;r;g;bm' (for background colours)

            # Extract the value(s)
            # A list of integers separated by ';' characters
            $data = substr($data, 0, (length($data) - 1));

            # $data is in the form 'Value;Value;Value'. where Value is in the range 0-255
            @valueList = split(/;/, $data);
            # It's valid to use 'Value's with leading 0s. Remove the leading zeros
            foreach my $value (@valueList) {

                if ($value =~ m/^\d+$/) {

                    $value += 0;
                }
            }

            # Convert the values into an Axmud RGB tag, in the form '#000000'
            # NB As for SGR, use 'eq' rather than '==' to prevent a Perl error for invalid
            #   non-numerical characters
            $tag = sprintf("%02lx%02lx%02lx", $valueList[0], $valueList[1], $valueList[2]);
            if ($type eq 'trcol_fg') {
                push (@tagList, '#' . uc($tag));                    # e.g. '#FFFFFF'
            } else {
                push (@tagList, 'u#' . uc($tag));                   # e.g. 'u#FFFFFF'
            }
        }

        # Return any Axmud colour/style tags generated (may be an empty list)
        return @tagList;
    }

    sub processCtrlSequence {

        # Called by $self->processIncomingData when it processes an escape sequence token
        # Any undisplayed text must be displayed before this token is processed (and has been
        #   displayed by the calling function)
        # If undisplayed text didn't need to be displayed, $self->processEscSequence was called
        #   instead
        #
        # Processes the control sequence, typically by making a call to the current textview object.
        #   If the control sequence can't be processed or if it isn't recognised, it is ignored
        # Currently, all control sequences processed by this function are VT100 escape sequences
        #   (but most VT100 escape sequences are not implemented in Axmud, so are just ignored)
        #
        # Expected arguments
        #   $token  - An extracted token containing the escape sequence
        #   $data   - Everything after the ESC character
        #   $type   - The type of escape sequence, which will be 'vt'
        #
        # Return values
        #   'undef' on improper arguments, if VT100 control sequences are disabled, if the control
        #       sequence doesn't appear to be valid, if the control sequence hasn't been implemented
        #       or if the control sequence can't be processed for some reason
        #   1 otherwise

        my ($self, $token, $data, $type, $check) = @_;

        # Local variables
        my ($enableFlag, $textViewObj, $lastChar, $error, $yPos, $xPos, $top, $bottom);

        # Check for improper arguments
        if (! defined $token || ! defined $data || ! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processCtrlSequence', @_);
        }

        # Check that VT100 control sequences are enabled, before proceeding
        if ($axmud::TEST_CTRL_SEQ_FLAG) {
            $enableFlag = FALSE;
        } elsif ($self->currentWorld->ivExists('termOverrideHash', 'useCtrlSeqFlag')) {
            $enableFlag = $self->currentWorld->ivShow('termOverrideHash', 'useCtrlSeqFlag');
        } else {
            $enableFlag = $axmud::CLIENT->useCtrlSeqFlag;
        }

        if (! $enableFlag) {

            return undef;
        }

        # Get the current textview object (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # All remaining control sequences beginning ESC [
        # There are many types of these control sequences, which can be distinguished by the final
        #   character in $data; so remove that character
        $lastChar = substr($data, -1);
        $data = substr($data, 0, (length($data) - 1));

        if (substr($data, 0, 1) eq '[') {

            $data = substr($data, 1);

            # Cursor Up
            #   ESC [ Value A
            if ($lastChar eq 'A') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertPosn(($data * -1), 0);

                } else {

                    $error = 'Invalid \'Cursor Up\' sequence';
                }

            # Cursor Down
            #   ESC [ Value B
            } elsif ($lastChar eq 'B') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertPosn($data, 0);

                } else {

                    $error = 'Invalid \'Cursor Down\' sequence';
                }

            # Cursor Forward
            #   ESC [ Value C
            } elsif ($lastChar eq 'C') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertPosn(0, $data);

                } else {

                    $error = 'Invalid \'Cursor Forward\' sequence';
                }

            # Cursor Back
            #   ESC [ Value D
            } elsif ($lastChar eq 'D') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertPosn(0, ($data * -1));

                } else {

                    $error = 'Invalid \'Cursor Back\' sequence';
                }

            # Cursor Next Line
            #   ESC [ Value E
            } elsif ($lastChar eq 'E') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertLine($data);

                } else {

                    $error = 'Invalid \'Cursor Next Line\' sequence';
                }

            # Cursor Previous Line
            #   ESC [ Value F
            } elsif ($lastChar eq 'F') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertLine(($data * -1));

                } else {

                    $error = 'Invalid \'Cursor Previous Line\' sequence';
                }

            # Cursor Horizontal Absolute
            #   ESC [ Value G
            } elsif ($lastChar eq 'G') {

                if ($data eq '') {

                    $data = 1;
                }

                if ($axmud::CLIENT->intCheck($data, 1)) {

                    return $textViewObj->adjustInsertOffset($data);

                } else {

                    $error = 'Invalid \'Cursor Horizontal Attribute\' sequence';
                }

            # Cursor Position
            #   ESC [ Line ; Column H
            #   ESC [ Line ; Column f
            } elsif ($lastChar eq 'H' || $lastChar eq 'f') {

                # Get the 'Line' and 'Column' (we'll ignore any values after the first two)
                ($yPos, $xPos) = split(/;/, $data);

                # "The upper left corner of the screen is considered row 1, column 1. However, an
                #   omitted parameter or a parameter value of 0 is treated as a value of 1 for
                #   purposes of screen addressing."
                if (! defined $yPos || $yPos eq '' || $yPos eq '0') {

                    $yPos = 1;
                }

                if (! defined $xPos || $xPos eq '' || $xPos eq '0') {

                    $xPos = 1;
                }

                if (
                    ! $axmud::CLIENT->intCheck($yPos, 1)
                    || ! $axmud::CLIENT->intCheck($xPos, 1)
                ) {
                    $error = 'Invalid line and/or column in \'Cursor Position\' sequence';

                } else {

                    # The textview object uses (0, 0) as the top left corner
                    return $textViewObj->setInsertPosn(--$yPos, --$xPos);
                }

            # Erase in Display
            #   ESC [ Value J
            } elsif ($lastChar eq 'J') {

                if ($data eq '' || $data eq '0') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearBufferAfterPosn(TRUE);

                } elsif ($data eq '1') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearBufferBeforePosn(TRUE);

                } elsif ($data eq '2' || $data eq '3') {

                    # Axmud doesn't implement a scrollback buffer, in the same way that a terminal
                    #   emulator would, so both values clear the entire textbuffer
                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearBuffer(TRUE);

                } else {

                    $error = 'Invalid \'Erase in Display\' sequence';
                }

            # Erase in Line
            #   ESC [ K
            } elsif ($lastChar eq 'K') {

                if ($data eq '' || $data eq '0') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearLineAfterPosn(TRUE);

                } elsif ($data eq '1') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearLineBeforePosn(TRUE);

                } elsif ($data eq '2') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->clearLineAroundPosn(TRUE);

                } else {

                    $error = 'Invalid \'Erase in Line\' sequence';
                }

            # Identify what terminal type
            #   ESC [ c
            #   ESC [ 0 c
            } elsif ($lastChar eq 'c') {

                if ($data eq '' || $data eq '0') {

                    # 'VT100 capable of 132 column mode, with bold+blink+underline+inverse'
                    $self->put(chr(27) . '[?1;2c');

                } else {

                    $error = 'Invalid \'Identify what terminal type\' sequence';
                }

            # Set cursor key to application
            #   ESC [ ? 1 h
            } elsif ($lastChar eq 'h') {

                if ($data eq '[?1') {

                    $self->ivPoke('ctrlCursorMode', 'application');
                    return 1;

                } else {

                    $error = 'Unimplemented mode sequence';
                }

            # Set cursor key to cursor
            #   ESC [ ? 1 l

                if ($data eq '[?1') {

                    $self->ivPoke('ctrlCursorMode', 'cursor');
                    return 1;

                } else {

                    $error = 'Unimplemented mode sequence';
                }

            # Set Top and Bottom Margins
            #   ESC [ Line ; Line r
            } elsif ($lastChar eq 'r') {

                # Get the two 'Line' values (we'll ignore any values after the first two)
                ($top, $bottom) = split(/;/, $data);

                if (
                    ! $axmud::CLIENT->intCheck($top, 1)
                    || ! $axmud::CLIENT->intCheck($bottom, 1)
                    || $top >= $bottom
                ) {
                    $error
                        = 'Invalid line and/or column in \'Set Top and Bottom Margins\' sequence';

                } else {

                    # The textview object uses (0, 0) as the top left corner
                    if ($textViewObj->set_scrlRegion(--$top, --$bottom)) {

                        # Cursor must be placed at the 'home' position
                        return $textViewObj->setInsertPosn(0, 0);

                    } else {

                        # Failed to set scroll region
                        return undef;
                    }
                }

            # Save Cursor Position
            #   ESC [ s
            } elsif ($lastChar eq 's') {

                if ($data eq '') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->storeInsertPosn(TRUE);

                } else {

                    $error = 'Invalid \'Save Cursor Position\' sequence';
                }

            # Restore Cursor Position
            #   ESC [ u
            } elsif ($lastChar eq 'u') {

                if ($data eq '') {

                    # The textview's overwrite mode is automatically turned on, if off
                    return $textViewObj->restoreInsertPosn();

                } else {

                    $error = 'Invalid \'Restore Cursor Position\' sequence';
                }

            } else {

                $error = 'Unimplemented control sequence';
            }

        # All control sequences not beginning ESC [
        } else {

            if ($data eq '') {

                # Set alternate keypad mode
                #   ESC =
                if ($lastChar eq '=') {

                    $self->ivPoke('ctrlKeypadMode', 'alternate');
                    return 1;

                # Set numeric keypad mode
                #   ESC >
                } elsif ($lastChar eq '>') {

                    $self->ivPoke('ctrlKeypadMode', 'numeric');
                    return 1;

                # Save cursor position and attributes
                #   ESC 7
                } elsif ($lastChar eq '7') {

                    # The TRUE argument means to turn on the textview's overwrite mode
                    return $textViewObj->storeInsertPosn(TRUE);

                # Restore cursor position and attributes
                #   ESC 8
                } elsif ($lastChar eq '8') {

                    # The textview's overwrite mode is automatically turned on, if off
                    return $textViewObj->restoreInsertPosn();

                # Move/scroll window up one line
                #   ESC D
                } elsif ($lastChar eq 'D') {

                    return $textViewObj->scrlRegionUp(1)

                # Move/scroll window down one line
                #   ESC M
                } elsif ($lastChar eq 'M') {

                    return $textViewObj->scrlRegionDown(1)

                } else {

                    $error = 'Unimplemented control sequence';
                }

            } else {

                $error = 'Unimplemented control sequence';
            }
        }

        # Unrecognised, invalid or unimplemented control sequence. Show a debug message, if allowed
        if ($error && $axmud::CLIENT->debugEscSequenceFlag) {

            $self->writeDebug(
                $error . ': ' . substr($token, 1),
                $self->_objClass . '->extractEscSequence',
            );
        }

        return undef;
    }

    sub processMspSoundTrigger {

        # Called by $self->processIncomingData when it encounters an MSP sound trigger, in the form
        #   "!!SOUND(...)" or "!!MUSIC(...)"
        # Also called by $self->processMxpSoundElement when processing MXP sounds
        #
        # Processes the MSP sound trigger, producing a list of parameters which are passed to
        #   GA::Client->playSoundFile for playing
        #
        # Expected arguments
        #   $token      - An extracted token containing the MSP sound trigger
        #
        # Optional arguments
        #   $mxpFlag    - Set to TRUE for MXP <SOUND> and <MUSIC> tags, to which MXP file filters
        #                   can be applied; set to FALSE (or 'undef') for MSP sound triggers
        #
        # Return values
        #   'undef' on improper arguments or if the token is invalid, and should be displayed as
        #       normal text
        #   1 otherwise

        my ($self, $token, $mxpFlag, $check) = @_;

        # Local variables
        my (
            $paramString, $type, $fName, $v, $l, $p, $c, $t, $u, $path, $file, $dir, $ext,
            $fetchObj, $string, $urlRegex, $convertFlag,
            @list, @objList,@fileList,
        );

        # Check for improper arguments
        if (! defined $token || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMspSoundTrigger', @_);
        }

        # Extract the parameter string (everything between the opening and closing brackets)
        if ($token =~ m/\!\!SOUND\((.*)\)/) {

            $paramString = $1;
            $type = 'sound';

        } elsif ($token =~ m/\!\!MUSIC\((.*)\)/) {

            $paramString = $1;
            $type = 'music';

        } else {

            # Invalid MSP sound token
            return undef;
        }

        if (! $paramString) {

            # Invalid MSP sound token (empty parameter string is not allowed - first parameter is
            #   compulsory)
            return undef;
        }

        # Extract the individual parameters (which are separated by one or more spaces, but do not
        #   contain spaces)
        @list = split(/\s+/, $paramString);

        # The first parameter (file name) is compulsory
        $fName = shift(@list);

        # FName=OFF is a special case
        if ($fName eq 'Off') {

            # The file name 'Off' is reserved; must playing sound or music triggers, if no other
            #   parameters are received
            if (! @list) {

                @objList = $self->ivValues('soundHarnessHash');
                foreach my $soundObj (@objList) {

                    if ($type eq $soundObj->type) {

                        # Stop this sound
                        $soundObj->stop();
                        # ...and update the registry
                        $self->ivDelete('soundHarnessHash', $soundObj->number);
                    }
                }

                return 1;

            # Otherwise, we expect @list to be in the form (U=some_url) which sets a default
            #   download URL
            } else {

                $u = shift @list;

                if (substr($u, 0, 2) ne 'U=') {

                    # Invalid parameter list - play nothing
                    return 1;
                }

                $u = substr($u, 2);

                $urlRegex = $axmud::CLIENT->constUrlRegex;
                if (! ($u =~ m/$urlRegex/)) {

                    # Invalid URL - play nothing
                    return 1;

                } else {

                    # The code below is a little simpler, if the partial URL ends with a slash
                    if (substr($u, -1, 1) ne '/') {

                        $u .= '/';
                    }

                    # Set the default MSP download URL, and play no sound
                    $self->ivPoke('mspDefaultURL', $u);

                    # Nothing to play, this time
                    return 1;
                }
            }
        }

        # Remaining parameters are in the form <param-name>=<param-value>, where <param-name> is a
        #   one-letter upper-case identifier (A-Z)
        # If any parameters are not specified, use default values
        $v = 100;
        $l = 1;
        $p = 50;
        $c = 1;
        $t = '';

        if ($self->mspDefaultURL) {
            $u = $self->mspDefaultURL;
        } else {
            $u = '';
        }

        # Extract parameter values, and check they're valid values
        foreach my $item (@list) {

            my ($name, $value);

            if ($item =~ m/(.*)\=(.*)/) {

                $name = $1;
                $value = $2;

                if ($name eq '' || $value eq '') {

                    # Invalid parameter format - play nothing
                    return 1;

                } elsif ($type eq 'sound' && $name eq 'C') {

                    # C parameters only used with !!MUSIC - play nothing
                    return 1;

                } elsif ($type eq 'music' && $name eq 'P') {

                    # P parameters only used with !!SOUND - play nothing
                    return 1;

                } elsif ($name eq 'V') {

                    # (If the value is invalid, $v remains set to its default value. This is also
                    #   true for $l, $p and $c)
                    if (! $axmud::CLIENT->floatCheck($value, 0, 100)) {

                        $v = $value;
                    }

                } elsif ($name eq 'L') {

                    if (! $axmud::CLIENT->intCheck($value, 1) && $value ne '-1') {

                        $l = $value;
                    }

                } elsif ($name eq 'P') {

                    if (! $axmud::CLIENT->floatCheck($value, 0, 100)) {

                        $p = $value;
                    }

                } elsif ($name eq 'C') {

                    if ($value eq '0' || $value eq '1') {

                        $c = $value;
                    }

                } elsif ($name eq 'T') {

                    $t = $value;

                } elsif ($name eq 'U') {

                    $u = $value;

                } else {

                    # Invalid parameter name - play nothing
                    return 1;
                }

            } else {

                # Invalid parameter - play nothing
                return 1;
            }
        }

        # If no file extension is specified, provide one
        ($file, $dir, $ext) = File::Basename::fileparse($fName, qr/\.[^.]*/);
        if (! $ext) {

            if ($type eq 'sound') {
                $fName .= '.wav';
            } else {
                $fName .= '.mid';
            }
        }

        # Convert $fName into a full filepath ($fName must a relative filepath, pointing at a
        #   directory used to store sounds for the current world)
        if ($t) {

            $path = $axmud::DATA_DIR . '/msp/' . $self->currentWorld->name
                        . '/' . $t . '/' . $fName;

        } else {

            $path = $axmud::DATA_DIR . '/msp/' . $self->currentWorld->name . '/' . $fName;
        }

        # If this file actually exists, use it. Otherwise...
        if (! -e $path) {

            # If a partial URL was specified...
            if ($u) {

                # If we're not allowed to download, then there's nothing to play
                if (
                    (! $mxpFlag && ! $axmud::CLIENT->allowMspLoadSoundFlag)
                    || ($mxpFlag && ! $axmud::CLIENT->allowMxpLoadSoundFlag)
                ) {
                    # Play nothing
                    return 1;
                }

                # Compile the full URL from which to download
                if (substr($u, -1, 1) ne '/') {

                    $u .= '/';
                }

                $u .= $fName;

                # Attempt to download the file
                ($file, $dir) = File::Basename::fileparse($path);
                $fetchObj = File::Fetch->new(uri => $u);
                if (! $fetchObj->fetch(to => $dir)) {

                    # Download error - play nothing
                    return 1;
                }

            # Otherwise, if the file doesn't exist, $path may contain wildcards (the MSP spec
            #   specifies * and ? specifically). Get a list of matching files, and choose a random
            #   one
            } else {

                ($file, $dir) = File::Basename::fileparse($path);

                # Convert MSP wildcards into regex equivalents (but only for the file itself, not
                #   its containing directory)
                # * must match the remainder of the file name
                $file =~ s/\*.*/.*/;
                # ? must match exactly one character
                $file =~ s/\?/.?/;
                $path = $dir . $file;

                # Get a list of matching files
                @fileList = grep {/$path/} glob "$dir*";
                if (! @fileList) {

                    # No matching file found. If the server specified a relative filepath (e.g.
                    #   'zone231/room22.wav', and that file (the equivalent of
                    #   /home/.../axmud-data/msp/world_name/zone231/room22.wav) doesn't exist, we
                    #   can strip away the subdirectory and look in the main directory instead
                    #   (i.e., in /home/.../axmud-data/msp/world_name/room22.wav)
                    $path = $axmud::DATA_DIR . '/msp/' . $self->currentWorld->name . '/' . $file;
                    $dir = $axmud::DATA_DIR . '/msp/' . $self->currentWorld->name . '/';
                    @fileList = grep {/$path/} glob "$dir*";
                }

                if (@fileList == 1) {

                    $path = $fileList[0];

                } else {

                    $path = $fileList[rand(scalar @fileList)];
                }

                if (! $path) {

                    # No matching sound file - play nothing
                    return 1;
                }
            }
        }

        # For sound files, Axmud only supports file extensions in GA::Client->constSoundFormatHash
        ($file, $dir, $ext) = File::Basename::fileparse($path, qr/\.[^.]*/);
        $ext =~ s/^\.//;
        if (! $axmud::CLIENT->ivExists('constSoundFormatHash', $ext)) {

            if (! $mxpFlag) {

                # File format not supported
                return 1;

            } else {

                # For any other file extension, apply the MXP filter (if the world has specified
                #   one)
                $path = $self->applyMxpFileFilter($path);
                if (! $path) {

                    # No MXP file filter supplied, or file conversion failed
                    return 1;
                }

                # Check the file format of the converted file is supported
                ($file, $dir, $ext) = File::Basename::fileparse($path, qr/\.[^.]*/);
                $ext =~ s/^\.//;
                if (! $axmud::CLIENT->ivExists('constSoundFormatHash', $ext)) {

                    # File format not supported
                    return 1;

                } else {

                    $convertFlag = TRUE;
                }
            }
        }

        # We can only play multiple sound triggers concurrently, if the flag is set
        # We can never play more than one music trigger concurrently
        @objList = $self->ivValues('soundHarnessHash');
        foreach my $soundObj (@objList) {

            if (
                (
                    # Concurrent sound triggers not allowed (at the moment)
                    ! $axmud::CLIENT->allowMspMultipleFlag
                    && $type eq 'sound'
                    && $soundObj->type eq 'sound'
                ) || (
                    # Concurrent music triggers not allowed (ever)
                    $type eq 'music'
                    && $soundObj->type eq 'music'
                )
            ) {
                # Stop this sound
                $soundObj->stop();
                # ...and update the registry
                $self->ivDelete('soundHarnessHash', $soundObj->number);
            }
        }

        # Apply the sound priority (but not for music triggers)
        if ($type eq 'sound') {

            @objList = $self->ivValues('soundHarnessHash');
            foreach my $soundObj (@objList) {

                if ($soundObj->type eq 'sound' && $soundObj->priority < $p) {

                    # This sound has a lower priority, so stop it
                    $soundObj->stop();
                    # ...and update the registry
                    $self->ivDelete('soundHarnessHash', $soundObj->number);
                }
            }
        }

        # Apply the continue flag
        if ($type eq 'music') {

            @objList = $self->ivValues('soundHarnessHash');
            foreach my $soundObj (@objList) {

                if ($soundObj->type eq 'music' && $soundObj->path eq $path) {

                    # This music trigger is already playing

                    # C=1
                    if ($c) {

                        # Allow the existing music trigger to continue playing, but modify its
                        #   repeat count
                        if ($soundObj->repeat != -1) {

                            $soundObj->ivPoke('repeat', $soundObj->repeat + $l);
                        }

                        return 1;

                    # C=0
                    } else {

                        # Restart the music trigger. In fact, stop the current music trigger, and
                        #   let the new one start playing in a moment, using the same file.  (The
                        #   TRUE argument means 'don't delete the sound file itself')
                        $soundObj->stop(TRUE);
                        # ...and update the registry
                        $self->ivDelete('soundHarnessHash', $soundObj->number);

                        # Combine the repeat count for the two duplicate sounds
                        if ($soundObj->repeat == -1 || $l == -1) {

                            $l = -1;

                        } else {

                            $l = $l + $soundObj->repeat;
                        }
                    }
                }
            }
        }

        # Play the sound file, passing any optional parameters
        $axmud::CLIENT->playSoundFile(
            $self,
            $path,
            $convertFlag,
            $type,
            $v,
            $l,
            $p,
            $c,
        );

        return 1;
    }

    sub processMxpHeadingTag {

        # Called by $self->processIncomingData
        #
        # Process an MXP heading token: <H1>...</H1> - <H6>...</H6>
        #
        # Expected arguments
        #   $origText   - The original text received from the world, before this token was
        #                   extracted
        #   $token      - An extracted token containing the MXP element
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origText, $token, $check) = @_;

        # Local variables
        my (
            $flag, $num,
            @emptyList, @tagList,
            %stackHash,
        );

        # Check for improper arguments
        if (! defined $token || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpHeadingTag', @_);
            return @emptyList;
        }

        # In MXP, the format is
        #   <H1>...</H1>, etc
        # In Pueblo, the format is
        #   <H1 align=xxx>...</H1>, etc

        # Extract the forward slash (if present) and the number, removing the diamond brackets and
        #   ignoring any attributes
        if ($token =~ m/\<(\/?)H(\d)/) {

            $flag = $1;
            $num = $2;

        } else {

            # This shouldn't be possible, as the calling function already used a regex
            return @emptyList;
        }

        # <H1>, etc
        if (! $flag) {

            if ($self->mxpHeadingFlag) {

                # In successive <Hx>...<Hx> tags, the second <Hx> tag is treated as though it were
                #   preceded by a closing </Hx> tag (regardless of whether the number x is the
                #   same for both tags)

                # Force a line break by inserting an artificial newline token
                $self->ivUnshift('currentTokenList', 'go', undef);

            } else {

                # Ignore all newline characters until the closing </Hx> tag
                $self->ivPoke('mxpHeadingFlag', TRUE);
            }

            # If the client flag is set, the font isn't changed (but the line breaks are used as
            #   normal)
            if ($axmud::CLIENT->allowMxpFontFlag) {

                $stackHash{'font_size'} = $axmud::CLIENT->ivShow('constHeadingSizeHash', $num)
                                            * $axmud::CLIENT->constFontSize;
                $stackHash{'spacing'} = $axmud::CLIENT->ivShow('constHeadingSpacingHash', $num)
                                            * $axmud::CLIENT->constFontSize;
                $stackHash{'font_name'} = $axmud::CLIENT->constFont;
                $stackHash{'bold_flag'} = TRUE;

                # Create a dummy style tag that GA::Obj::TextView->applyColourStyleTags can
                #   interpret, e.g. 'mxpf_monospace_bold_12'
                push (@tagList, $self->createMxpFontTag(%stackHash));

                # Create a new MXP stack object and store it in the current textview object,
                #   updating the latter's IVs
                if (
                    ! $self->currentTabObj->textViewObj->createMxpStackObj(
                        $self,
                        'H' . $num,
                        %stackHash,
                    )
                ) {
                    $self->mxpDebug($token, 'Internal error while processing element', 4101);

                    return @emptyList;
                }
            }

            # Operation complete
            return @tagList;

        # </H1>, etc
        } else {

            if (! $self->mxpHeadingFlag) {

                # An invalid </Hx>...</Hx> construction
                $self->mxpDebug($token, '</Hx> tag after earlier closing </Hx> tag', 4102);

                return @emptyList;
            }

            # Stop ignoring newline characters
            $self->ivPoke('mxpHeadingFlag', FALSE);

            # Force a line break by inserting an artificial newline token
            $self->ivUnshift('currentTokenList', 'go', undef);

            # If the client flag is set, restore the previous font
            if ($axmud::CLIENT->allowMxpFontFlag) {

                return $self->popMxpStack('H' . $num);

            } else {

                return @emptyList;
            }
        }
    }

    sub processMxpElement {

        # Called by $self->processIncomingData when it encounters an MXP element (a tag like '<B>'),
        #   or by $self->processMxpCustomElement recursively
        #
        # Processes the MXP element, updating IVs and returning a corresponding list of Axmud
        #   colour/style tags, where necessary
        #
        # NB If we're in the middle of a <V>...</V> construction, an element that isn't either the
        #   opening or closing tag doesn't abnormally terminate the construction
        #
        # Expected arguments
        #   $token      - An extracted token containing the MXP element
        #
        # Optionl arguments
        #   $origText   - Specified when called by $self->processIncomingData, representing the
        #                   received line of text, up to (but not including) the MXP tag
        #   $parseMode  - Sometimes specified when this function calls itself recursively
        #               - Set to 'simple' when processing element definitions. Only simple atomic
        #                   elements like <B> (so don't allow <!ELEMENT> for example), and don't
        #                   allow closing elements like </B>
        #               - When set to 'simple_close', the same limitations apply as when set to
        #                   'simple'. In addition, the element itself isn't processed, instead the
        #                   corresponding closing element is processed (i.e. if $token is <B>, we
        #                   process </B>)
        #   %attHash    - When called by $self->processMxpCustomElement, a hash that specifies any
        #                   attribute values that apply to this tag
        #               - For example, in the element '<COLOR &col;>', this function substitutes
        #                   the '&col;' for the attribute value matching the attribute 'col', where
        #                   'col' is a key in %attHash, and the attribute value is the key's
        #                   matching value
        #               - Both the argument name and value can be substituted; so <COLOR &col;> and
        #                   <COLOR FORE=&col;> have an identical effect
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a recognition flag (set to FALSE if the token is nothing to do with
        #       MXP which should be processed as ordinary text; set to TRUE if it's an MXP tag, even
        #       an invalid one) followed by an equivalent list of Axmud colour/style tags otherwise
        #       (may be an empty list)

        my ($self, $token, $origText, $parseMode, %attHash) = @_;

        # Local variables
        my (
            $origToken, $firstChar, $tagMode, $flexibleFlag, $keyword,
            @emptyList, @argList,
        );

        # Check for improper arguments
        if (! defined $token) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpElement', @_);
            return @emptyList;
        }

        # ($token will be modified during this function, but some parts of the function require the
        #   the original token text)
        $origToken = $token;

        # Ignore comments, in the form '<!-- this is a comment -->' ($self->extractMxpPuebloElement
        #   has already checked that an element beginning with '<!--' ends with a '-->', so we only
        #   need to check the first part of the string
        if (substr($token, 0, 4) eq '<!--') {

            # Recognition flag, followed by empty Axmud colour/style tag list
            return TRUE;
        }

        # MXP elements are in the form:
        #   <keyword [args]>            ($tagMode 'open')
        #   </keyword>                  ($tagMode 'close')
        #   <!keyword [args]>           ($tagMode 'defn')

        # Remove the initial < followed by optional whitespace, and the final > preceded by optional
        #   whitespace
        $token =~ s/^\<\s*//;
        $token =~ s/\s*\>$//;
        # In case there's nothing left, don't bother looking for keywords or arguments...
        if (! $token) {

            $self->mxpDebug($origToken, 'Processed an empty element', 1601);

            # Treat token as ordinary text
            return @emptyList;
        }

        # Remove the initial / or !, if present
        $firstChar = substr($token, 0, 1);
        if ($firstChar eq '/') {

            # </keyword>
            $token = substr($token, 1);
            $tagMode = 'close';

        } elsif ($firstChar eq '!') {

            # <!keyword [args]>
            $token = substr($token, 1);
            $tagMode = 'defn';

        } else {

            # <keyword [args]>
            $tagMode = 'open';
        }

        # Remove the keyword, which 'must start with a letter (A-Z) and then consist of letters,
        #   numbers or the underline character'
        # v1.1.510 - The Gathering MUD uses hyphens, instead of underscores. Allow that only if the
        #   flag is set (allow dots and slashes, too)
        if ($self->currentWorld->ivExists('mxpOverrideHash', 'flexible')) {
            $flexibleFlag = $self->currentWorld->ivShow('mxpOverrideHash', 'flexible');
        } else {
            $flexibleFlag = $axmud::CLIENT->allowMxpFlexibleFlag;
        }

        if (
            (! $flexibleFlag && $token =~ m/^([A-Za-z][A-Za-z0-9_]*)/)
            || ($flexibleFlag && $token =~ m/^([A-Za-z][A-Za-z0-9_\-\.\\\/]*)/)
        ) {
            # (Simplify things by converting all keywords to upper-case)
            $keyword = uc($1);
            $token = substr($token, length($keyword));

        } else {

            if ($token =~ m/^[\'\"].*[\'\"]$/) {

                # Keyword not found
                $self->mxpDebug($origToken, 'Element contains quoted keyword', 1611);

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;

            } else {

                # Keyword not found
                $self->mxpDebug($origToken, 'Element contains invalid keyword', 1612);

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;
            }
        }

        # Some keywords are synonyms of others, e.g. <B>, <BOLD> and <STRONG> are all equivalent
        if ($axmud::CLIENT->ivExists('constMxpConvertHash', $keyword)) {

            $keyword = $axmud::CLIENT->ivShow('constMxpConvertHash', $keyword);
        }

        # Check that the keyword is either an official MXP element (e.g. <B>) or a user-defined
        #   element, stored as a key in $self->mxpElementHash
        if (
            # ($keyword is in upper case, but element names are stored in lower case)
            ! $axmud::CLIENT->ivExists('constMxpOfficialHash', $keyword)
            && ! $self->ivExists('mxpElementHash', lc($keyword))
        ) {
            # Ignore obsolete keywords
            if ($keyword eq 'SCRIPT') {

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;

            } else {

                # Treat token as ordinary text
                return @emptyList;
            }
        }

        # Handle $parseMode, if specified by the calling function
        if (defined $parseMode) {

            if ($tagMode eq 'close') {

                $self->mxpDebug(
                    $origToken,
                    'Malformed element (closing tag not valid in element definitions)',
                    1621,
                );

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;

            } elsif ($tagMode eq 'defn') {

                $self->mxpDebug(
                    $origToken,
                    'Malformed element (non-atomic tag not valid in element definitions)',
                    1622,
                );

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;

            } elsif ($parseMode eq 'simple_close') {

                # Don't process $token, which is an opening element like <B>; instead process the
                #   corresponding closing element, which is something like </B>
                $tagMode = 'close';     # Converts <B> to </B>
                $token = '';            # Converts <COLOR ...> to </COLOR>
            }
        }

        # Most keywords are only allowed in secure line mode
        if (
            ! $axmud::CLIENT->ivExists('constMxpModalHash', $keyword)
            && (! defined $self->mxpLineMode || $self->mxpLineMode != 1)
        ) {
            $self->mxpDebug($origToken, 'Secure element used in unsecure line', 1623);

            # Recognition flag, followed by empty Axmud colour/style tag list
           return TRUE;
        }

        # Get a list of arguments, separated by one or more whitespace characters
        # (The whole argument can be single-quoted, or double-quoted, in which case it can contain
        #   embedded whitespace or the '>' character)
        if ($token) {

            do {

                my (
                    $argName, $argValue, $key, $value,
                    @grpStringList,
                );

                ($token, $argName, $argValue) = $self->extractMxpArgument($token);
                if (! defined $token) {

                    # Improper arguments, or malformed argument
                    $self->mxpDebug($origToken, 'Malformed element', 1631);

                    # Recognition flag, followed by empty Axmud colour/style tag list
                    return TRUE;

                } else {

                    # Substitute the argument name or value, if they match one or more of the
                    #   attributes specified in %attHash
                    # e.g. In <COLOR &col;> and <COLOR FORE=&col;>, if there's a key in %attHash
                    #   called 'col', subsitute the &col; for the key's corresponding value
                    if (! defined $argValue) {

                        @grpStringList = ($argName =~ m/\&(\w+)\;/g);
                        foreach my $grpString (@grpStringList) {

                            if (exists $attHash{$grpString}) {

                                $value = $attHash{$grpString};
                                $argName =~ s/\&$grpString\;/$value/g;
                            }
                        }

                    } elsif (defined $argValue) {

                        @grpStringList = ($argValue =~ m/\&(\w+)\;/g);
                        foreach my $grpString (@grpStringList) {

                            if (exists $attHash{$grpString}) {

                                $value = $attHash{$grpString};
                                $argValue =~ s/\&$grpString\;/$value/g;
                            }
                        }
                    }

                    # If the argument is not in the form 'argument_name=argument_value', then
                    #   $argValue is 'undef', and $argName contains the whole argument
                    push (@argList, $argName, $argValue);

                    # After removing the 'argument' or the 'argument_name=argument_value'
                    #   construction, if there's anything left in $token, it must start with a
                    #   whitespace character
                    # This ensures that there are whitespace character(s) between each argument,
                    #   and prevents constructions like: name='value'name='value'
                    if ($token && $token =~ m/^\S/) {

                        $self->mxpDebug($origToken, 'Malformed element', 1632);

                        # Recognition flag, followed by empty Axmud colour/style tag list
                        return TRUE;
                    }
                }

            } until (! $token);
        }

        # Process each type of MXP element in its own function

        # Process modal elements: <B> <I> <U> <S> <H> <COLOR> <FONT>
        if ($axmud::CLIENT->ivExists('constMxpModalHash', $keyword)) {

            return (TRUE, $self->processMxpModalElement($origToken, $tagMode, $keyword, @argList));

        # Process HTML elements: <SMALL> <TT>
        } elsif ($keyword eq 'SMALL' || $keyword eq 'TT') {

            return (TRUE, $self->processMxpHtmlElement($origToken, $tagMode, $keyword, @argList));

        # Process element definitions: <!ELEMENT>
        } elsif ($keyword eq 'EL') {

            return (TRUE, $self->processMxpElementDefn($origToken, $tagMode, $keyword, @argList));

        # Process attribute lists for user-defined elements: <!ATTLIST>
        } elsif ($keyword eq 'AT') {

            return (TRUE, $self->processMxpAttElement($origToken, $tagMode, $keyword, @argList));

        # Process entity element: <!ENTITY>
        } elsif ($keyword eq 'EN') {

            return (TRUE, $self->processMxpEntElement($origToken, $tagMode, $keyword, @argList));

        # Process direct setting of variable (entity values): <V>...</V>
        } elsif ($keyword eq 'V') {

            return (TRUE, $self->processMxpVarElement($origToken, $tagMode, $keyword, @argList));

        # Process support requests: <SUPPORT>
        } elsif ($keyword eq 'SUPPORT') {

            return (
                TRUE,
                $self->processMxpSupportElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process frames: <FRAME>
        } elsif ($keyword eq 'FRAME') {

            return (TRUE, $self->processMxpFrameElement($origToken, $tagMode, $keyword, @argList));

        # Process cursor control: <DEST>...</DEST>
        } elsif ($keyword eq 'DEST') {

            return (TRUE, $self->processMxpDestElement($origToken, $tagMode, $keyword, @argList));

        # Process direct setting of clickable links: <A>...</A>
        } elsif ($keyword eq 'A') {

            return (
                TRUE,
                $self->processMxpLinkElement($origToken, $tagMode, $keyword, FALSE, @argList),
            );

        # Process direct setting of send links: <SEND>...</SEND>
        } elsif ($keyword eq 'SEND') {

            return (
                TRUE,
                $self->processMxpSendElement($origToken, $tagMode, $keyword, FALSE, @argList),
            );

        # Process sounds: <SOUND>, <MUSIC>
        } elsif ($keyword eq 'SOUND' || $keyword eq 'MUSIC') {

            return (TRUE, $self->processMxpSoundElement($origToken, $tagMode, $keyword, @argList));

        # Process images: <IMAGE>
        } elsif ($keyword eq 'IMAGE') {

            return (TRUE, $self->processMxpImageElement($origToken, $tagMode, $keyword, @argList));

        # Process sound/image filters: <FILTER>
        } elsif ($keyword eq 'FILTER') {

            return (TRUE, $self->processMxpFilterElement($origToken, $tagMode, $keyword, @argList));

        # Process gauges: <GAUGE>, <STAT>
        } elsif ($keyword eq 'GAUGE' || $keyword eq 'STAT') {

            return (TRUE, $self->processMxpGaugeElement($origToken, $tagMode, $keyword, @argList));

        # Process MXP crosslinking: <RELOCATE>
        } elsif ($keyword eq 'RELOCATE') {

            return (
                TRUE,
                $self->processMxpCrosslinkElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process MXP logins: <USER>, <PASSWORD>
        } elsif ($keyword eq 'USER' || $keyword eq 'PASSWORD') {

            return (TRUE, $self->processMxpLoginElement($origToken, $tagMode, $keyword, @argList));

        # Process other official MXP elements
        } elsif ($axmud::CLIENT->ivExists('constMxpOfficialHash', $keyword)) {

            return (
                TRUE,
                $self->processMxpOfficialElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process custom elements
        } elsif ($self->ivExists('mxpElementHash', lc($keyword))) {

            return (TRUE, $self->processMxpCustomElement($origToken, $tagMode, $keyword, @argList));

        # (This should never be executed)
        } else {

            $self->mxpDebug($origToken, 'Internal error while processing element', 1641);

            # Recognition flag, followed by empty Axmud colour/style tag list
            return TRUE;
        }
    }

    sub processPuebloElement {

        # Called by $self->processIncomingData when it encounters a Pueblo element (e.g. '<B>')
        #
        # Processes the Pueblo element, updating IVs and returning a corresponding list of Axmud
        #   colour/style tags, where necessary
        #
        # Expected arguments
        #   $token      - An extracted token containing the Pueblo element
        #   $origText   - Specified when called by $self->processIncomingData, representing the
        #                   received line of text, up to (but not including) the Pueblo element
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a recognition flag (set to FALSE if the token is nothing to do with
        #       Pueblo which should be processed as ordinary text; set to TRUE if it's a Pueblo
        #       element, even an invalid one) followed by an equivalent list of Axmud colour/style
        #       tags otherwise (may be an empty list)

        my ($self, $token, $origText, $check) = @_;

        # Local variables
        my (
            $origToken, $firstChar, $tagMode, $keyword,
            @emptyList, @argList,
        );

        # Check for improper arguments
        if (! defined $token) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloElement', @_);
            return @emptyList;
        }

        # ($token will be modified during this function, but some parts of the function require the
        #   the original token text)
        $origToken = $token;

        # Ignore comments, in the form '<!-- this is a comment -->' ($self->extractMxpPuebloElement
        #   has already checked that an element beginning with '<!--' ends with a '-->', so we only
        #   need to check the first part of the string
        if (substr($token, 0, 4) eq '<!--') {

            # Recognition flag, followed by empty Axmud colour/style tag list
            return TRUE;
        }

        # Pueblo elements are in the form:
        #   <keyword [args]>            ($tagMode 'open')
        #   </keyword>                  ($tagMode 'close')

        # Remove the initial < followed by optional whitespace, and the final > preceded by optional
        #   whitespace
        $token =~ s/^\<\s*//;
        $token =~ s/\s*\>$//;
        # In case there's nothing left, don't bother looking for keywords or arguments...
        if (! $token) {

            $self->puebloDebug($origToken, 'Processed an empty element', 6601);

            # Treat token as ordinary text
            return @emptyList;
        }

        # Remove the initial /, if present
        $firstChar = substr($token, 0, 1);
        if ($firstChar eq '/') {

            # </keyword>
            $token = substr($token, 1);
            $tagMode = 'close';

        } else {

            # <keyword [args]>
            $tagMode = 'open';
        }

        # Remove the keyword, assuming that the same rules apply that apply to MXP keyword ('must
        #   start with a letter (A-Z) and then consist of letters, numbers or the underline
        #   character'
        if ($token =~ m/^([A-Za-z][A-Za-z0-9_]*)/) {

            # (Simplify things by converting all keywords to upper-case)
            $keyword = uc($1);
            $token = substr($token, length($keyword));

        } else {

            if ($token =~ m/^[\'\"].*[\'\"]$/) {

                # Keyword not found
                $self->puebloDebug($origToken, 'Element contains quoted keyword', 6611);

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;

            } else {

                # Keyword not found
                $self->puebloDebug($origToken, 'Element contains invalid keyword', 6612);

                # Recognition flag, followed by empty Axmud colour/style tag list
                return TRUE;
            }
        }

        # Some keywords are synonyms of others, e.g. <B>, <EM> and <STRONG> are all equivalent
        if ($axmud::CLIENT->ivExists('constPuebloConvertHash', $keyword)) {

            $keyword = $axmud::CLIENT->ivShow('constPuebloConvertHash', $keyword);
        }

        # Check that the keyword is an official Pueblo element, whether implemented or not
        if (! $axmud::CLIENT->ivExists('constPuebloOfficialHash', $keyword)) {

            # Treat token as ordinary text
            return @emptyList;

        # Ignore unimplemented keywords
        } elsif (! $axmud::CLIENT->ivExists('constPuebloImplementHash', $keyword)) {

            # Recognition flag, followed by empty Axmud colour/style tag list
            return TRUE;
        }

        # Get a list of arguments, separated by one or more whitespace characters
        # (The whole argument can be single-quoted, or double-quoted, in which case it can contain
        #   embedded whitespace or the '>' character)
        if ($token) {

            do {

                my ($argName, $argValue, $key, $value);

                ($token, $argName, $argValue) = $self->extractMxpArgument($token);
                if (! defined $token) {

                    # Improper arguments, or malformed argument
                    $self->puebloDebug($origToken, 'Malformed element', 6621);

                    # Recognition flag, followed by empty Axmud colour/style tag list
                    return TRUE;

                } else {

                    # If the argument is not in the form 'argument_name=argument_value', then
                    #   $argValue is 'undef', and $argName contains the whole argument
                    push (@argList, $argName, $argValue);

                    # After removing the 'argument' or the 'argument_name=argument_value'
                    #   construction, if there's anything left in $token, it must start with a
                    #   whitespace character
                    # This ensures that there are whitespace character(s) between each argument,
                    #   and prevents constructions like: name='value'name='value'
                    if ($token && $token =~ m/^\S/) {

                        $self->puebloDebug($origToken, 'Malformed element', 6622);

                        # Recognition flag, followed by empty Axmud colour/style tag list
                        return TRUE;
                    }
                }

            } until (! $token);
        }

        # Process each type of Pueblo element in its own function

        # Process modal elements: <B> <I> <U> <STRIKE>
        if ($axmud::CLIENT->ivExists('constPuebloModalHash', $keyword)) {

            return (TRUE, $self->processMxpModalElement($origToken, $tagMode, $keyword, @argList));

        # Process HTML element: <TT>
        } elsif ($keyword eq 'TT') {

            return (TRUE, $self->processMxpHtmlElement($origToken, $tagMode, $keyword, @argList));

        # Process colours: <COLOR>...</COLOR>
        } elsif ($keyword eq 'COLOR') {

            return (
                TRUE,
                $self->processPuebloColourElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process fonts: <FONT>...</FONT>
        } elsif ($keyword eq 'FONT') {

            return (
                TRUE,
                $self->processPuebloFontElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process base font: <BASEFONT>
        } elsif ($keyword eq 'BASEFONT') {

            return (
                TRUE,
                $self->processPuebloBaseFontElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process hyperlinks to send to MUD: <SEND>...</SEND>
        } elsif ($keyword eq 'SEND') {

            # Not in Pueblo 2.50 spec, so assume it's the same as the MXP tag
            return (
                TRUE,
                $self->processMxpSendElement($origToken, $tagMode, $keyword, FALSE, @argList),
            );

        # Process direct setting of clickable links: <A>...</A>
        } elsif ($keyword eq 'A') {

            return (
                TRUE,
                $self->processPuebloLinkElement($origToken, $tagMode, $keyword, FALSE, @argList),
            );

        # Process list: <UL>...</UL>, <OL>...</OL>
        } elsif ($keyword eq 'UL' || $keyword eq 'OL') {

            return (
                TRUE,
                $self->processPuebloListElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process list items: <LI>
        } elsif ($keyword eq 'LI') {

            return (
                TRUE,
                $self->processPuebloListItemElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process text justification: <CENTER>
        } elsif ($keyword eq 'CENTER') {

            return (
                TRUE,
                $self->processPuebloJustifyElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process literal text: <CODE>...</CODE>, <PRE>...</PRE>, <SAMP>...</SAMP>
        } elsif ($keyword eq 'CODE' || $keyword eq 'PRE' || $keyword eq 'SAMP') {

            return (
                TRUE,
                $self->processPuebloLiteralElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process images: <IMG>
        } elsif ($keyword eq 'IMG') {

            return (
                TRUE,
                $self->processPuebloImageElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process panes: <XCH_PANE>
        } elsif ($keyword eq 'XCH_PANE') {

            return (
                TRUE,
                $self->processPuebloPaneElement($origToken, $tagMode, $keyword, @argList),
            );

        # Process other implemented MXP elements
        } elsif ($axmud::CLIENT->ivExists('constPuebloImplementHash', $keyword)) {

            return (
                TRUE,
                $self->processPuebloImplementedElement($origToken, $tagMode, $keyword, @argList),
            );

        # (This should never be executed)
        } else {

            $self->puebloDebug($origToken, 'Internal error while processing element', 6631);

            # Recognition flag, followed by empty Axmud colour/style tag list
            return TRUE;
        }
    }

    # (Called by ->processIncomingData to convert a non-text token into other data)

    sub processMxpSpacingTag {

        # Called by $self->processIncomingData
        #
        # Process an MXP line spacing token: <NOBR>, <P>, </P>, <BR>, <SBR>
        # Also process one kind of MXP HTML element, <HR>
        #
        # Expected arguments
        #   $token      - An extracted token containing the MXP element
        #
        # Return values
        #   'undef' on improper arguments or if an invalid closing tag like </BR> is used
        #   1 otherwise

        my ($self, $token, $check) = @_;

        # Local variables
        my ($origToken, $width, $string, $fontSize);

        # Check for improper arguments
        if (! defined $token || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpSpacingTag', @_);
        }

        # Remove the < and > characters
        $origToken = $token;
        $token = uc($token);
        $token =~ s/^\<//;
        $token =~ s/\>$//;

        # Only the </P> element can be used as a closing tag (e.g. </NOBR> is invalid)
        if ($token ne '/P' && substr($token, 0, 1) eq '/') {

            $self->mxpDebug($origToken, 'Invalid line spacing tag \'' . $origToken . '\'', 4001);
        }

        # Process the tag
        if ($token eq 'BR') {

            # Force a line break by inserting an artificial newline token
            $self->ivUnshift('currentTokenList', 'go', undef);

        } elsif ($token eq 'NOBR') {

            # Ignore the next newline character
            $self->ivPoke('mxpIgnoreNewLineFlag', TRUE);

        } elsif ($token eq 'P') {

            if ($self->mxpParagraphFlag) {

                # In successive <P>...<P> tags, the second <P> tag is treated as though it were
                #   preceded by a closing </P> tag

                # Force a line break by inserting an artificial newline token
                $self->ivUnshift('currentTokenList', 'go', undef);

            } else {

                # Ignore all newline characters until the closing </P> tag
                $self->ivPoke('mxpParagraphFlag', TRUE);
            }

        } elsif ($token eq '/P') {

            if (! $self->mxpParagraphFlag) {

                # An invalid </P>...</P> construction
                $self->mxpDebug($origToken, '</P> tag after earlier closing </P> tag', 4002);

            } else {

                # Stop ignoring newline characters
                $self->ivPoke('mxpParagraphFlag', FALSE);

                # Force a line break by inserting an artificial newline token
                $self->ivUnshift('currentTokenList', 'go', undef);
            }

        } elsif ($token eq 'SBR') {

            # Axmud doesn't yet implement soft line breaks; treat it as an ordinary space character
            $self->ivUnshift('currentTokenList', 'text', ' ');

        } elsif ($token eq 'HR') {

            # An HTML element. Draw a poor man's 'horizontal rule' with simple ASCII characters

            # Force a line break by inserting an artificial newline token
            $self->ivUnshift('currentTokenList', 'go', undef);

            # Adjust the width to take account of different font sizes, especially inside headings
            #   (<H1>...</H1>, etc)
            $width = $self->currentTabObj->textViewObj->textWidthChars;
            $fontSize = $self->currentTabObj->textViewObj->ivShow('mxpModalStackHash', 'font_size');

            if ($fontSize ne '' && $fontSize != $axmud::CLIENT->constFontSize) {

                # (Subtracting 1 seems to produce the right answer more often, for unknown reasons)
                $width = int($width / ($fontSize / $axmud::CLIENT->constFontSize)) - 1;
            }

            # Draw the horizontal rule, which ends with another newline token
            $self->ivUnshift(
                'currentTokenList',
                    'text',
                    chr(0x2501) x $width,
                    'go',
                    undef,
            );
        }

        return 1;
    }

    sub processPuebloSpacingTag {

        # Called by $self->processIncomingData
        #
        # Process a Pueblo line spacing token: <BODY>, </BODY>, <P>, </P>, <BR>. <HR>
        #
        # Expected arguments
        #   $token      - An extracted token containing the Pueblo element
        #
        # Return values
        #   'undef' on improper arguments or if an invalid closing tag like </BR> is used
        #   1 otherwise

        my ($self, $token, $check) = @_;

        # Local variables
        my (
            $origToken, $wholeText, $bufferObj, $width, $fontSize,
            @list,
        );

        # Check for improper arguments
        if (! defined $token || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->processPuebloSpacingTag',
                @_,
            );
        }

        # Remove the < and > characters
        $origToken = $token;
        $token = uc($token);
        $token =~ s/^\<//;
        $token =~ s/\>$//;
        # These Pueblo tags can have attributes, but Axmud ignores those attributes
        $token =~ s/\s.*//;

        # Only the </BODY> and </P> elements can be used as a closing tag (e.g. </BR> is invalid)
        if ($token ne '/BODY' && $token ne '/P' && substr($token, 0, 1) eq '/') {

            $self->puebloDebug($origToken, 'Invalid line spacing tag \'' . $origToken . '\'', 7801);
        }

        # Process the tag
        if ($token eq 'BODY' || $token eq '/BODY') {

            # The Pueblo spec states that <BODY>...</BODY> is not necessary for world output.
            #   However, some worlds (e.g. Epoch) display some introductory text before the
            #   <BODY>...</BODY> construction, so we'll replace both tags with empty lines

            # If the current line contains any non-whitespace characters, we must insert a newline
            #   token to terminate it
            $wholeText = $self->getPartialLine() . $self->processStripLine;
            if ($wholeText =~ m/\S/) {

                $self->ivUnshift('currentTokenList', 'go', undef);
            }

            # Insert a second newline token to put space between this paragraph, and anything that
            #   follows it
            $self->ivUnshift('currentTokenList', 'go', undef);

        } elsif ($token eq 'BR') {

            # Force a line break inside or outside a paragraph
            $self->ivUnshift('currentTokenList', 'go', undef);

        } elsif ($token eq 'P') {

            # Special case: inside a <PRE>...</PRE> construction, <P> tags are implemented as a
            #   simple newline
            if ($self->puebloLiteralFlag) {

                # Force a line break
                $self->ivUnshift('currentTokenList', 'go', undef);
            }

            $self->ivPoke('puebloParagraphFlag', TRUE);

            # If the current line contains any non-whitespace characters, we must insert a newline
            #   token to terminate it
            $wholeText = $self->getPartialLine() . $self->processStripLine;
            if ($wholeText =~ m/\S/) {

                $self->ivUnshift('currentTokenList', 'go', undef);

            # If the line before that contained any newline characters, we must insert a newline
            #   token to put an empty line between the that text, and the beginning of the
            #   paragraph
            } elsif (defined $self->displayBufferLast) {

                $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
                if ($bufferObj->modLine =~ m/\S/) {

                    $self->ivUnshift('currentTokenList', 'go', undef);
                }
            }

        } elsif ($token eq '/P') {

            if (! $self->puebloParagraphFlag) {

                # An invalid </P>...</P> construction
                $self->puebloDebug($origToken, '</P> tag after earlier closing </P> tag', 7811);

            } else {

                $self->ivPoke('puebloParagraphFlag', FALSE);

                # If the current line contains any non-whitespace characters, we must insert a
                #   newline token to terminate it
                $wholeText = $self->getPartialLine() . $self->processStripLine;
                if ($wholeText =~ m/\S/) {

                    $self->ivUnshift('currentTokenList', 'go', undef);
                }

                # Insert a second newline token to put space between this paragraph, and anything
                #   that follows it
                $self->ivUnshift('currentTokenList', 'go', undef);
            }

        } elsif ($token eq 'HR') {

            # An HTML element. Draw a poor man's 'horizontal rule' with simple ASCII characters

            # Force a line break inside or outside a paragraph. The TRUE argument means 'don't close
            #   open MXP tags, as we would for a true newline character'
            push (@list, 'go', undef);

            # Adjust the width to take account of different font sizes, especially inside headings
            #   (<H1>...</H1>, etc)
            $width = $self->currentTabObj->textViewObj->textWidthChars;
            $fontSize = $self->currentTabObj->textViewObj->ivShow('mxpModalStackHash', 'font_size');
            if ($fontSize ne '' && $fontSize != $axmud::CLIENT->constFontSize) {

                # (Subtracting 1 seems to produce the right answer more often, for unknown reasons)
                $width = int($width / ($fontSize / $axmud::CLIENT->constFontSize)) - 1;
            }

            # Draw the horizontal rule
            push (@list, 'text', chr(0x2501) x $width);
            # ...which ends in another newline character
            push (@list, 'go', undef);

            $self->ivUnshift('currentTokenList', @list);
        }

        return 1;
    }

    sub processMxpEntity {

        # Called by $self->processIncomingData
        #
        # Process an MXP entity in the form %...;, replacing it with the named entity's value
        # For entities in the form '&#nnn;' replaces the nnn with the ASCII character of that value,
        #   but only for value in the range 32-255
        #
        # NB If we're in the middle of a <V>...</V> construction, an entity in the form %...'
        #   doesn't abnormally terminate the construction. Therefore the following construction is
        #   valid:
        #       <V>I have %number; gold coins</V>
        # ...and results in the variable's ->value being set to something 'I have 100 gold coins'
        #
        # Expected arguments
        #   $token      - The token containing the entity in the form &...;
        #   $text       - The remaining portion of text, which begins with the entity $token
        #
        # Return values
        #   'undef' on improper arguments or if the entity is unrecognised
        #   Otherwise returns the entity's value

        my ($self, $token, $text, $check) = @_;

        # Local variables
        my ($enNum, $enName, $entityObj);

        # Check for improper arguments
        if (! defined $token || ! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpEntity', @_);
        }

        # Handle entities in the form '&#nnn;'
        if ($token =~ m/\&\#([0-9]{1,3})\;/) {

            $enNum = $1;
            # We'll assume that 'nnn' can be '064' as well as '64'. Convert it to a numeric value
            $enNum += 0;

            # Ignore numbers not in the range 32-255
            if ($enNum < 32 || $enNum > 255) {

                return undef;

            } else {

                return chr($enNum);
            }
        }

        # Otherwise, get the entity name (the token will have at least three characters, due to the
        #   regex used in $self->extractMxpPuebloEntity, so there's no need to check for a minimum
        #   length)
        $enName = substr($token, 1, (length($token) - 2));

        # Does an entity called $enName exist?
        if (! $self->ivExists('mxpEntityHash', $enName)) {

            # Standard entity names don't have their own GA::Mxp::Entity object
            if (! $axmud::CLIENT->ivExists('constMxpEntityHash', $enName)) {

                $self->mxpDebug($token, 'Unrecognised entity \'' . $enName . '\'', 3901);

                return undef;

            } else {

                # Use the standard entity's value (an ASCII character)
                return $axmud::CLIENT->ivShow('constMxpEntityHash', $enName);
            }

        } else {

            # Replace the named entity with its value
            $entityObj = $self->ivShow('mxpEntityHash', $enName);
            return $entityObj->value;
        }
    }

    # (Called by ->processIncomingData to handle a text token)

    sub updateTextToken {

        # Called by $self->processIncomingData
        # Also called by $self->processMxpSpacingTag when processing a <SBR> or <HR> tag
        #
        # Updates IVs after a text token (a string which doesn't contain any of the none-text tokens
        #   removed by the calling function, such as newline characters, escape sequences, etc) is
        #   processed
        #
        # Expected arguments
        #   $token      - The token containing the text
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $token, $check) = @_;

        # Check for improper arguments
        if (! defined $token || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateTextToken', @_);
        }

        # If we're in the middle of a <V>...</V> construction, update the variable's value
        if ($self->mxpCurrentVar) {

            $self->mxpCurrentVar->ivPoke('value', $self->mxpCurrentVar->value . $token);
        }

        # If we're in the middle of an <A>...</A> construction, update the link's visible text
        if ($self->mxpCurrentLink) {

            $self->mxpCurrentLink->ivPoke('text', $self->mxpCurrentLink->text . $token);
        }

        # If we're in the middle of a <SEND>...</SEND> construction, update the link's visible text
        if ($self->mxpCurrentSend) {

            $self->mxpCurrentSend->ivPoke('text', $self->mxpCurrentSend->text . $token);
        }

        # If we're in the middle of two matching custom tags which defined tag properties, e.g.
        #   from the MXP spec, <RName>...</RName>, update the stored text
        foreach my $key ($self->ivKeys('mxpFlagTextHash')) {

            $self->ivAdd('mxpFlagTextHash', $key, $self->ivShow('mxpFlagTextHash', $key) . $token);
        }

        return 1;
    }

    # (Miscellaneous incoming functions)

    sub writeIncomingDataLogs {

        # Called by $self->processLineSegment to write logs after each line segment (usually
        #   comprising a whole line) is received from the world, and after any matching triggers
        #   have fired)
        # NB $self->writeReceiveDataLog is used to write the 'receive' logfile; this function is
        #   used to write all other logfiles
        #
        # Expected arguments
        #   $modLine        - A segment of the received text, comprising some or all of a line of
        #                       text received from a world, which has now been stripped of non-text
        #                       tokens like newline characters, escape sequences, etc; and then
        #                       possibly modified after all matching triggers have fired
        #   $newLineFlag    - Flag set to TRUE if this line segment is to be treated as if it ends
        #                       with a newline character, FALSE if is to be treated as if it does
        #                       not end with a newline character
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $modLine, $newLineFlag, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (! defined $modLine || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->writeIncomingDataLogs', @_);
        }

        # Write logs (if allowed)
        $axmud::CLIENT->writeLog(
            $self,
            TRUE,                           # Not world-specific logs
            $modLine,
            FALSE,                          # Don't precede with a newline character
            $newLineFlag,
            'main',                         # Write to these files
        );

        $axmud::CLIENT->writeLog(
            $self,
            FALSE,                          # World-specific logs
            $modLine,                       # After triggers fired
            FALSE,                          # Don't precede with a newline character
            $newLineFlag,
            'display',                      # Write to these files
        );

        # If we're recording lines in a separate logfile because of a character falling asleep,
        #   passing out or dying, do that now
        if ($self->logAsleepUntilLine) {

            @list = ($modLine);
            if ($self->displayBufferLast >= $self->logAsleepUntilLine) {

                $self->ivUndef('logAsleepUntilLine');
                push (@list, '--- (End of sleep record) ---');
            }

            foreach my $item (@list) {

                $axmud::CLIENT->writeLog(
                    $self,
                    FALSE,                          # World-specific logs
                    $item,
                    FALSE,                          # Don't precede with a newline character
                    $newLineFlag,
                    'sleep' ,                       # Write to these files
                );
            }
        }

        if ($self->logPassedOutUntilLine) {

            @list = ($modLine);
            if ($self->displayBufferLast >= $self->logPassedOutUntilLine) {

                $self->ivUndef('logPassedOutUntilLine');
                push (@list, '--- (End of passout record) ---');
            }

            foreach my $item (@list) {

                $axmud::CLIENT->writeLog(
                    $self,
                    FALSE,                          # World-specific logs
                    $item,
                    FALSE,                          # Don't precede with a newline character
                    $newLineFlag,
                    'passout',                      # Write to these files
                );
            }
        }

        if ($self->logDeadUntilLine) {

            @list = ($modLine);
            if ($self->displayBufferLast >= $self->logDeadUntilLine) {

                $self->ivUndef('logDeadUntilLine');
                push (@list, '--- (End of dead record) ---');
            }

            foreach my $item (@list) {

                $axmud::CLIENT->writeLog(
                    $self,
                    FALSE,                          # World-specific logs
                    $item,
                    FALSE,                          # Don't precede with a newline character
                    $newLineFlag,
                    'dead' ,                        # Write to these files
                );
            }
        }

        return 1;
    }

    sub writeReceiveDataLog {

        # Called by $self->processLinePortion to write the 'receive' logfile
        # Unlike other logfiles, lines written to 'receive' are not split into multiple lines by
        #   splitter triggers or by recognised command prompts
        #
        # Expected arguments
        #   $stripLine      - A segment of the received text, comprising some or all of a line of
        #                       text received from a world, which has now been stripped of non-text
        #                       tokens like newline characters, escape sequences, etc
        #   $imgLine        - The same line but with extra text added for any processed images
        #   $newLineFlag    - Flag set to TRUE if this line portion is to be treated as if it ends
        #                       with a newline character, FALSE if is to be treated as if it does
        #                       not end with a newline character
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $stripLine, $imgLine, $newLineFlag, $check) = @_;

        # Check for improper arguments
        if (! defined $stripLine || ! defined $imgLine || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->writeReceiveDataLog', @_);
        }

        # Write logs (if allowed)
        if ($axmud::CLIENT->logImageFlag) {

            $axmud::CLIENT->writeLog(
                $self,
                FALSE,                          # World-specific logs
                $imgLine,                       # Additional text representing processed images
                FALSE,                          # Don't precede with a newline character
                $newLineFlag,
                'receive',                      # Write to these files
            );

        } else {

            $axmud::CLIENT->writeLog(
                $self,
                FALSE,                          # World-specific logs
                $stripLine,                     # No text representing processed images
                FALSE,                          # Don't precede with a newline character
                $newLineFlag,
                'receive',                      # Write to these files
            );
        }

        return 1;
    }

    sub extractClickLinks {

        # Called by $self->processLineSegment
        # Before testing a received line against triggers, we need to see if there are any valid
        #   web URLs/email addresses and, if so, note their positions (so they can be used when the
        #   line is displayed in the current textview object)
        #
        # The calling function uses a hash of Axmud colour/style tags, in the form
        #   $tagHash{position} = reference_to_a_list_of_Axmud_colour_and_style_tags
        # ...where $position is the position in the line segment at which the colour/style tags
        #   apply (the first character is position 0)
        #
        # For any URLs/email addresses found, adds 'link' and 'link_off' style tags to the hash
        #
        # Expected arguments
        #   $stripText  - A segment of the received text, comprising some or all of a line of text
        #                   received from a world, which has now been stripped of non-text tokens
        #                   like newline characters, escape sequences, etc
        #   $tagHashRef - Reference to the hash of Axmud colour/style tags described above (which
        #                   this function may modify)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $stripText, $tagHashRef, $check) = @_;

        # Local variables
        my ($regex, $emailRegex);

        # Check for improper arguments
        if (! defined $stripText || ! defined $tagHashRef || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->extractClickLinks', @_);
        }

        # (Check email first, otherwise gmail.com is detected and fred@gmail.com isn't)
        # (Don't bother checking for email addresses, if there is no command set to open an external
        #   email application)
        if ($axmud::CLIENT->emailCmd) {

            # Import the regexes for recognising email addresses
            $emailRegex = $axmud::CLIENT->constEmailRegex;

            while ($stripText =~ m/($emailRegex)/gi) {

                my ($match, $start, $stop, $listRef);

                $match = $1;
                $start = length ($`);
                $stop = $start + length($&);

                # Ignore email addresses which already occurs between two 'link' tags
                if ($self->checkLinkTags($tagHashRef, $start, $stop)) {

                    if (exists $$tagHashRef{$start}) {

                        $listRef = $$tagHashRef{$start};
                        push (@$listRef, 'link');

                    } else {

                        $$tagHashRef{$start} = ['link'];
                    }

                    if (exists $$tagHashRef{$stop}) {

                        $listRef = $$tagHashRef{$stop};
                        push (@$listRef, 'link_off');

                    } else {

                        $$tagHashRef{$stop} = ['link_off'];
                    }
                }
            }
        }

        # (Don't bother checking for URLs, if there is no command set to open an external web
        #   browser)
        if ($axmud::CLIENT->browserCmd) {

            # Import the regexes for recognising URLs
            if ($axmud::CLIENT->shortUrlFlag) {

                $regex = '(' . $axmud::CLIENT->constUrlRegex . '|'
                            . $axmud::CLIENT->constShortUrlRegex . ')';

            } else {

                $regex = $axmud::CLIENT->constUrlRegex;
            }

            while (
                $stripText =~ m/($regex)/gi
            ) {
                my ($match, $start, $stop, $listRef);

                $match = $1;
                $start = length ($`);
                $stop = $start + length($&);

                # Ignore URLS which already occur between two 'link' tags
                if ($self->checkLinkTags($tagHashRef, $start, $stop)) {

                    if (exists $$tagHashRef{$start}) {

                        $listRef = $$tagHashRef{$start};
                        push (@$listRef, 'link');

                    } else {

                        $$tagHashRef{$start} = ['link'];
                    }

                    if (exists $$tagHashRef{$stop}) {

                        $listRef = $$tagHashRef{$stop};
                        push (@$listRef, 'link_off');

                    } else {

                        $$tagHashRef{$stop} = ['link_off'];
                    }
                }
            }
        }

        return 1;
    }

    sub checkLinkTags {

        # Called by $self->extractClickLinks when a URL or email address is found in some received
        #   text
        #
        # The calling function was supplied with a hash of Axmud colour/style tags, in the form
        #   $tagHash{position} = reference_to_a_list_of_Axmud_colour_and_style_tags
        # ...where $position is the position in the line segment at which the colour/style tags
        #   apply (the first character is position 0)
        #
        # This function checks the position of the URL/email address. If the whole URL/email address
        #   occurs outside of two matching 'link' tags, then it's safe to add new 'link' tags
        # e.g. ________EMAIL_______<link_tag>URL<link_off_tag>______    < We can add new tags
        # e.g. ________<link_tag>...EMAIL...<link_off_tag>__________    < We can't add new tags
        #
        # Expected arguments
        #   $tagHashRef     - Reference to the hash of Axmud colour/style tags described above
        #   $start, $stop   - Offsets for the beginning/end of the newly-found URL or email address
        #                       ($start is 0, if the URL/email address occurs at the start of the
        #                       line)
        #
        # Return values
        #   'undef' on improper arguments or if the URL/email address occurs between two matching
        #       'link' tags
        #   1 if it's safe to create two new matching 'link' tags

        my ($self, $tagHashRef, $start, $stop, $check) = @_;

        # Local variables
        my (
            $prevFlag, $thisStart, $thisStop, $thisOffset,
            @onOffsetList, @offOffsetList, @sortedList
        );

        # Check for improper arguments
        if (! defined $tagHashRef || ! defined $start || ! defined $stop || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkLinkTags', @_);
        }

        # Get a list of offsets at which 'link' and 'link_off' tags occur
        foreach my $offset (keys %$tagHashRef) {

            my $listRef = $$tagHashRef{$offset};

            if (grep m/^link$/, @$listRef) {

                push (@onOffsetList, $offset);
            }

            if (grep m/^link_off$/, @$listRef) {

                push (@offOffsetList, $offset);
            }
        }

        if (@onOffsetList || @offOffsetList) {

            # Sort the lists
            if (@onOffsetList) {

                @onOffsetList = sort {$a <=> $b} (@onOffsetList);
            }

            if (@offOffsetList) {

                @offOffsetList = sort {$a <=> $b} (@offOffsetList);
            }

            do {

                my ($onOffset, $offOffset, $thisFlag);

                $onOffset = $onOffsetList[0];
                $offOffset = $offOffsetList[0];

                # Remove 'link' and 'link_off' tags, one by one, in the order in which they occured
                if (defined $onOffset && defined $offOffset && $offOffset < $onOffset) {

                    $thisOffset = $offOffset;
                    $thisFlag = FALSE;              # 'link_off'
                    shift @offOffsetList;

                } elsif (defined $onOffset) {

                    $thisOffset = $onOffset;
                    $thisFlag = TRUE;               # 'link'
                    shift @onOffsetList;

                } elsif (defined $offOffset) {

                    $thisOffset = $offOffset;
                    $thisFlag = FALSE;              # 'link_off'
                    shift @offOffsetList;
                }

                # Special case: the first tag found should have been a 'link' tag, but if it was a
                #   'link_off' tag, implying that a link has been spread over two lines, then the
                #   URL/email address can't be found before it
                if (! defined $prevFlag && ! $thisFlag) {

                    if ($start <= $thisOffset) {

                        return undef;
                    }

                } else {

                    # 'link' tag found
                    if ($thisFlag) {

                        # Wait for the corresponding 'link_off'. If the previously found tag was
                        #   also 'link', ignore the new tag (two consecutive 'link' tags shouldn't
                        #   happen, but just in case, that's what we'll do)
                        if (! $prevFlag) {

                            $thisStart = $thisOffset;
                            $thisStop = undef;
                        }

                    # 'link_off' flag found
                    } else {

                        $thisStop = $thisOffset;
                        # Does any part of the URL/email address fall between the 'link' and
                        #   'link_off' tags?
                        if (
                            ($start >= $thisStart && $start <= $thisStop)
                            || ($stop >= $thisStart && $stop <= $thisStop)
                        ) {
                            # Either the beginning or the end of the URL/email address occurs
                            #   between two matching 'link'/'link_off' tags
                            return undef;

                        } else {

                            # Move on to the next two matching 'link'/'link_off' tags
                            $thisStart = undef;
                            $thisStop = undef;
                        }
                    }
                }

                $prevFlag = $thisFlag;

            } until (! @onOffsetList && ! @offOffsetList);

            # If the last tag found was a 'link' tag, then the URL/email address must not occur
            #   after it
            if (defined $thisStart && ! defined $thisStop && $start >= $thisOffset) {

                return undef;
            }
        }

        # The URL/email address doesn't occur between two matching 'link' tags
        return 1;
    }

    sub checkSuppressLine {

        # Called by $self->respondIncomingData when processing a newline character at the end of an
        #   empty line (i.e. one which contains no text tokens, or only text tokens consisting of
        #   whitespace)
        # This function assumes that the current world's ->suppressEmptyLineCount is set to an
        #   integer greater than 1 (other values are dealt with by the calling function)
        # Any number of consecutive empty lines below this value are preserved, but any number of
        #   consecutive empty lines at or above this value are suppresssed (ignored)
        # e.g. Set to 3; the first two consecutive empty lines are preserved, but the 3rd, 4th and
        #   5th are suppressed
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the line currently being processed by
        #       ->processLineSegment shouldn't be suppressed
        #   1 if the line should be suppressed

        my ($self, $check) = @_;

        # Local variables
        my ($line, $count, $target);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkSuppressLine', @_);
        }

        # If the display buffer is empty, there are no previous lines to check
        if (! $self->displayBufferCount) {

            return undef;
        }

        $line = $self->displayBufferLast;
        $count = 1;     # The line being processed is itself empty
        $target = $self->currentWorld->suppressEmptyLineCount;

        do {

            my $bufferObj;

            $count++;

            $bufferObj = $self->ivShow('displayBufferHash', $line);
            if (! $bufferObj->emptyFlag) {

                # Not enough consecutive empty lines; don't suppress the current empty line
                return undef;

            } else {

                # On the next DO loop, check the previous line in the buffer
                $line--;
            }

        } until ($count >= $target || $line < $self->displayBufferFirst);

        # There are enough consecutive empty lines; suppress the current empty line
        return 1;
    }

    sub processPrompt {

        # Called by $self->spinMaintainLoop after receiving some text that doesn't end with a
        #   newline character, Go Ahead (GA) sequence or EOR (End of Record) sequence, and after
        #   waiting for a short time for more text to be received
        # Also called by $self->optCallback after a TELOPT_EOR is received from the server, and by
        #   $self->processIncomingData after an IAC GA 'go ahead' sequence is processed
        # Called by $self->processLinePortion after displaying a line that ends with text matching
        #   a pattern in the world profile's ->cmdPromptPatternList
        #
        # Once this function is called, a prompt (any received text that doesn't end with a newline
        #   character) because a command prompt, for which Axmud assumes the world is now waiting
        #   for the user to send a world command
        # Updates IVs, processes the next part of an automatic login (if necessary), and fires the
        #   'prompt' hook event
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $insertFlag     - TRUE when called by $self->processLinePortion, in which case this
        #                       function instructs $self->processIncomingData to insert an
        #                       artificial newline character, if necesssary
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $insertFlag, $check) = @_;

        # Local variables
        my (
            $nickname, $bufferObj, $pattern, $initChar, $initAccount, $initPass,
            @loginCmdList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processPrompt', @_);
        }

        # Special check for IRE worlds, which send an EOR sequence before any text, which is picked
        #   up by $self->optCallback, which then calls this function
        # Because no text has been received, $self->displayBufferHash is empty, and there's nothing
        #   for this function to do
        if (! defined $self->displayBufferLast) {

            return 1;
        }

        # Get the character nickname, for use in automatic logins (using the character name as a
        #   backup)
        if ($self->currentChar) {
            $nickname = $self->currentChar->nickname;
        } else {
            $nickname = $self->initChar;
        }

        # Otherwise, process the prompt
        $self->ivIncrement('promptCount');
        # We'll need the current line of text, after any non-text tokens have been stripped from it,
        #    so get the display buffer object that stores it
        $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);

        # In login mode 'lp', 'world_cmd' and 'telnet', we're waiting for prompts so that we can do
        #   an automatic login
        if ($self->loginPromptsMode ne 'none') {

            # Mode 'lp' - LP/Diku/AberMUD login (consecutive prompts for character/password)
            if ($self->loginPromptsMode eq 'lp') {

                if ($self->promptCount == 1) {

                    # Send the character name
                    $self->worldCmd($nickname);

                } elsif ($self->promptCount == 2) {

                    # Send the password. The second argument is the substring in the first argument
                    #   which should be obscured (the whole string, in this case)
                    $self->worldCmd($self->initPass, $self->initPass);
                    # Wait for login success patterns (if there are any), otherwise mark the
                    #   character as logged in
                    $self->setLoginPatterns();
                }

            # Mode 'world_cmd' - send a sequence of world commands at the first prompt
            } elsif ($self->loginPromptsMode eq 'world_cmd' && $self->promptCount == 1) {

                $self->processCmdLoginMode();

            # Mode 'telnet' - basic telnet login (e.g. 'login:' 'password:')
            } elsif ($self->loginPromptsMode eq 'telnet' && $self->loginPromptPatternList) {

                $pattern = $self->ivShift('loginPromptPatternList');
                if ($bufferObj->stripLine =~ m/$pattern/i) {

                    if ($self->loginPromptPatternList) {

                        # Send the character name
                        $self->worldCmd($self->nickname);

                    } else {

                        # Send the password. The second argument is the substring in the first
                        #   argument which should be obscured (the whole string, in this case)
                        $self->worldCmd($self->initPass, $self->initPass);

                        # Wait for login success patterns (if there are any), otherwise mark the
                        #   character as logged in
                        $self->setLoginPatterns();
                    }

                } else {

                    # The pattern doesn't match this prompt. Re-insert it into the list, so that
                    #   it can be tested against the next prompt
                    $self->ivUnshift('loginPromptPatternList', $pattern);
                }
            }
        }

        # Fire any hooks that are using the 'login' hook event
        $self->checkHooks('prompt', $bufferObj->stripLine);

        # Update IVs
        $self->ivPoke('promptFlag', TRUE);
        if ($insertFlag) {
            $self->ivPoke('promptInsertFlag', TRUE);
        } else {
            $self->ivPoke('promptInsertFlag', FALSE);
        }

        $self->ivUndef('promptCheckTime');

        return 1;
    }

    # (Process MXP tokens)

    sub extractMxpArgument {

        # Called by $self->processMxpElement
        # Extracts a single argument from a token containing an MXP element; returns both the
        #   extracted argument and the modified token
        #
        # Expected arguments
        #   $token        - The remaining portion of a token containing an MXP element
        #
        # Return values
        #   An empty list on improper arguments or if a malformed MXP argument is found
        #   Otherwise, returns a list in the form:
        #       (remaining_token, argument_name, argument_value)
        #   ...in which:
        #       - 'remaining_token' is any text that remains, after the argument has been
        #           extracted (and leading whitespace removed from it)
        #       - (so, 'remaining_token' can be an empty string, if the last argument has been
        #           extracted)
        #       - If the argument is in the form 'argument_name=argument_value', then they are
        #           returned as 'argument_name' and 'argument_value', with the middle '='
        #           character and leading/trailing whitespace removed
        #       - If the argument is not in the form 'argument_name=argument_value', then
        #           'argument_name' contains the whole argument, and 'argument_value' is undef
        #       - ('argument_name' can be an empty string, if there was no argument left to
        #           extract)

        my ($self, $token, $check) = @_;

        # Local variables
        my (
            $firstChar, $argName, $argValue,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $token || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMxpArgument', @_);
            return @emptyList;
        }

        # Remove leading whitespace
        $token =~ s/^\s*//;
        if (! $token) {

            # No MXP argument left to extract
            if ($self->mxpMode eq 'client_agree') {

                $self->mxpDebug(
                    '<',            # This function doesn't know the whole token
                    'Element contains missing argument(s)',
                    1101,
                );

            } elsif ($self->puebloMode eq 'client_agree') {

                $self->puebloDebug(
                    '<',
                    'Element contains missing argument(s)',
                    6101,
                );
            }

            return ('', '', undef);
        }

        # If the MXP argument begins wih a single (or double) quote, look for the closing single (or
        #   double quote)
        # The enclosed text can contain the opposite quote (e.g. 'some " text'), or the same
        #   quote doubled ('the boy''s dog')
        $firstChar = substr($token, 0, 1);
        if ($firstChar eq '\'' || $firstChar eq '"') {

            $argName = $self->extractMxpQuote($token);
            if (! defined $argName) {

                # Improper arguments or malformed MXP argument
                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<',            # This function doesn't know the whole token
                        'Malformed argument',
                        1102,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<',
                        'Malformed argument',
                        6102,
                    );
                }

                return @emptyList;

            } else {

                # (Add 2, because the quote characters were removed from $argName);
                $token = substr($token, (length($argName) + 2));
                return ($token, $argName, undef);
            }
        }

        # Arguments by keyword are in the form
        #   argument_name=argument_value
        # ...where 'argument_name' cannot be quoted, by 'argument_value' CAN be quoted
        if ($token =~ m/^([A-Za-z][A-Za-z0-9_]*)\=/) {

            $argName = $1;                                      # Doesn't include the '='
            $argValue = '';
            $token = substr($token, (length($argName) + 1));    # Doesn't include the '='

            $firstChar = substr($token, 0, 1);
            if ($firstChar eq '\'' || $firstChar eq '"') {

                # 'argument_value' is potentially within a pair of single or double quotes

                $argValue = $self->extractMxpQuote($token);
                if (! defined $argValue) {

                    # Improper arguments or malformed MXP argument
                    if ($self->mxpMode eq 'client_agree') {

                        $self->mxpDebug(
                            '<',            # This function doesn't know the whole token
                            'Malformed argument',
                            1103,
                        );

                    } elsif ($self->puebloMode eq 'client_agree') {

                        $self->puebloDebug(
                            '<',
                            'Malformed argument',
                            6103,
                        );
                    }

                    return @emptyList;

                } else {

                    # (Add 2, because the quote characters were removed from $argName);
                    $token = substr($token, (length($argValue) + 2));
                }

            } else {

                # 'argument_value' is definitely not within a pair of single or double quotes
                if ($token =~ m/^([\S]*)\s/) {

                    $argValue = $1;
                    $token = substr($token, length($argValue));

                } else {

                    # No whitespace characters found
                    $argValue = $token;
                    $token = '';
                }

                # 'argument_name=' constructions are not allowed
                if ($argValue eq '') {

                    # Improper arguments or malformed MXP argument
                    if ($self->mxpMode eq 'client_agree') {

                        $self->mxpDebug(
                            '<',            # This function doesn't know the whole token
                            'Malformed argument',
                            1104,
                        );

                    } elsif ($self->puebloMode eq 'client_agree') {

                        $self->puebloDebug(
                            '<',
                            'Malformed argument',
                            6104,
                        );
                    }

                    return @emptyList;

                # argument_name=argument_value=something_else is not valid (however, '=' characters
                #   can be used inside a pair of quotes; the code above has already dealth with
                #   that)
                } elsif (index ($argValue, '=') > -1) {

                    # Improper arguments or malformed MXP argument
                    if ($self->mxpMode eq 'client_agree') {

                        $self->mxpDebug(
                            '<',            # This function doesn't know the whole token
                            'Malformed argument',
                            1105,
                        );

                    } elsif ($self->puebloMode eq 'client_agree') {

                        $self->puebloDebug(
                            '<',
                            'Malformed argument',
                            6105,
                        );
                    }

                    return @emptyList;
                }
            }

        # Arguments by position are in the form '...', "..." or a string text containing no
        #   whitespace characters / quotes / '>' characters
        } else {

            # The MXP argument is everything up to the first whitespace character
            if ($token =~ m/^(.*?)\s/) {

                $argName = $1;
                $token = substr($token, length($argName));

            } else {

                # No whitespace characters found
                $argName = $token;
                $token = '';
            }
        }

        return ($token, $argName, $argValue);
    }

    sub extractMxpQuote {

        # Called by $self->extractMxpArgument
        # Extracts an argument (or an argument value, in arguments of the type
        #   'argument_name=argument_value') from the remaining portion of an MXP element, in which
        #   the argument/argument value is enclosed by single or double quotes
        # The enclosed text can contain the opposite quote (e.g. 'some " text'), or the same
        #   quote doubled ('the boy''s dog')
        # The first and last characters, which are both ' or "s, are removed from the returned
        #   string
        #
        # Expected arguments
        #   $token        - The remaining portion of a token containing an MXP element
        #
        # Return values
        #   'undef' on improper arguments. if the first character in $token isn't a single or double
        #       quote or if $token is less than two characters long
        #   Otherwise, returns the argument/argument value, including its containing quotes

        my ($self, $token, $check) = @_;

        # Local variables
        my ($firstChar, $arg);

        # Check for improper arguments
        if (! defined $token || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->extractMxpArgument', @_);
        }

        # Check that $token contains at least two characters (the opening and closing quotes)
        if (length ($token) < 2) {

            # Improper arguments or malformed MXP argument
            if ($self->mxpMode eq 'client_agree') {

                $self->mxpDebug(
                    '<',            # This function doesn't know the whole token
                    'Element contained malformed quoted text',
                    1201,
                );

            } elsif ($self->puebloMode eq 'client_agree') {

                $self->puebloDebug(
                    '<',
                    'Element contained malformed quoted text',
                    6201,
                );
            }

            return undef;
        }

        # Remove the first character
        $firstChar = substr($token, 0, 1);
        if ($firstChar ne '\'' && $firstChar ne '"') {

            if ($self->mxpMode eq 'client_agree') {

                $self->mxpDebug(
                    '<',            # This function doesn't know the whole token
                    'Element contained malformed quoted text',
                    1202,
                );

            } elsif ($self->puebloMode eq 'client_agree') {

                $self->puebloDebug(
                    '<',
                    'Element contained malformed quoted text',
                    6202,
                );
            }

            return undef;

        } else {

            $token = substr($token, 1);
        }

        # Now go through the text of $token, looking for a closing quote matching $firstChar, but
        #   ignoring a duplicate quote (i.e. '' or "")
        $arg = '';

        do {

            my ($posn, $doublePosn);

            $posn = index($token, $firstChar);
            $doublePosn = index ($token, $firstChar. $firstChar);

            if ($posn == -1) {

                # $token does not contain a matching quote, so it's a malformed element
                if ($self->mxpMode eq 'client_agree') {

                    $self->mxpDebug(
                        '<',            # This function doesn't know the whole token
                        'Element contained malformed quoted text',
                        1203,
                    );

                } elsif ($self->puebloMode eq 'client_agree') {

                    $self->puebloDebug(
                        '<',
                        'Element contained malformed quoted text',
                        6203,
                    );
                }

                return undef;

            } elsif ($doublePosn > -1 && $doublePosn == $posn) {

                # The first quote found is a duplicate quote. Remove the text up to that point
                #   (including the duplicate quote), and then carry on
                $arg .= substr($token, 0, ($posn + 2));
                $token = substr($token, length($arg));

            } else {

                # Closing quote found. Remove it
                $arg .= substr($token, 0, $posn);
                return $arg;
            }

        } until (! $token);

        # Duplicate quote possibly found, but no matching closing quote, so it's a malformed element
        if ($self->mxpMode eq 'client_agree') {

            $self->mxpDebug(
                '<',            # This function doesn't know the whole token
                'Element contained malformed quoted text',
                1204,
            );

        } elsif ($self->puebloMode eq 'client_agree') {

            $self->puebloDebug(
                '<',
                'Element contained malformed quoted text',
                6204,
            );
        }

        return undef;
    }

    sub processMxpModalElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP model element: <B> <I> <U> <S> <H> <COLOR> <FONT>
        # (NB Other modal elements, such as <A> and <SEND>, are processed in their own functions)
        # (NB In Pueblo, <STRIKE> is used in place of <S>)
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $textViewObj, $string, $blinkFlag, $string2, $blinkFlag2, $stackObj, $converted,
            @emptyList, @origList, @checkList, @tagList,
            %checkHash, %ivHash, %stackHash, %convertHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpModalElement', @_);
            return @emptyList;
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # Convert Pueblo <STRIKE> to MXP <S>
        if ($self->puebloMode eq 'client_agree' && $keyword eq 'STRIKE') {

            $keyword = 'S';
        }

        # Process atomic modal elements that have arguments
        if ($keyword eq 'C' || $keyword eq 'F') {

            # <COLOR FORE=foreground [BACK=background]>
            # </COLOR>
            # <FONT FACE=name [SIZE=size] [COLOR=foreground] [BACK=background]>
            # </FONT>
            if (
                ($tagMode eq 'open' && ! @argList)
                || ($tagMode eq 'close' && @argList)
                || $tagMode eq 'defn'
            ) {
                $self->mxpDebug($origToken, 'Malformed element', 2001);

                return @emptyList;

            # <COLOR FORE=foreground [BACK=background]>
            # <FONT FACE=name [SIZE=size] [COLOR=foreground] [BACK=background]>
            } elsif ($tagMode eq 'open') {

                # Process @argList
                if ($keyword eq 'C') {

                    @origList = @checkList = ('fore', 'back');
                    # Hash of argument names which don't take a corresponding value
                    %checkHash = ();
                    # Default argument values
                    %ivHash = (
                        'fore'      => undef,
                        'back'      => undef,
                    );

                } else {

                    @origList = @checkList = ('face', 'size', 'color', 'back');
                    # Hash of argument names which don't take a corresponding value
                    %checkHash = ();
                    # Default argument values
                    %ivHash = (
                        'face'      => undef,
                        'size'      => undef,
                        'color'     => undef,
                        'back'      => undef,
                    );
                }

                if (@argList) {

                    do {

                        my ($argName, $argValue) = $self->findMxpArgsByPosn(
                            \@origList,
                            \@checkList,
                            \%ivHash,
                            \%checkHash,
                            shift @argList,     # not 'undef'
                            shift @argList,     # might be 'undef'
                        );

                        if (! defined $argName) {

                            # Unrecognised argument name, or repeating argument name
                            $self->mxpDebug($origToken, 'Malformed element', 2002);

                            return @emptyList;

                        } else {

                            $ivHash{$argName} = $argValue;
                        }

                    } until (! @argList);
                }

                # 'foreground' and 'background' can be standard HTML colours (keys in
                #   GA::Client->constHtmlColourHash), RGB colour tags (in the form '#000000')
                # http://www.zuggsoft.com/zmud/mxp.htm states that we can use 'color attribute names
                #   such as 'blink', but neglects to specify WHICH colour attributes we can use - so
                #   'blink' is the only one Axmud implements
                # If used, Axmud expects that 'foreground'/'background' will be in the form
                #   'colour,blink' or 'blink,colour'
                if ($ivHash{'fore'}) {

                    ($string, $blinkFlag) = $self->convertMxpColour($ivHash{'fore'}, FALSE);
                    if (! defined $string) {

                        $self->mxpDebug(
                            $origToken,
                            'Unrecognised foreground colour \'' . $ivHash{'fore'} . '\'',
                            2003,
                        );

                        return @emptyList;

                    } else {

                        push (@tagList, $string);
                        $stackHash{'colour_foreground'} = $string;
                    }

                } elsif ($ivHash{'color'}) {

                    ($string, $blinkFlag) = $self->convertMxpColour($ivHash{'color'}, FALSE);
                    if (! defined $string) {

                        $self->mxpDebug(
                            $origToken,
                            'Unrecognised foreground colour \'' . $ivHash{'color'} . '\'',
                            2004,
                        );

                        return @emptyList;

                    } else {

                        push (@tagList, $string);
                        $stackHash{'colour_foreground'} = $string;
                    }
                }

                if ($ivHash{'back'}) {

                    ($string2, $blinkFlag2) = $self->convertMxpColour($ivHash{'back'}, TRUE);
                    if (! defined $string2) {

                        $self->mxpDebug(
                            $origToken,
                            'Unrecognised background colour \'' . $ivHash{'back'} . '\'',
                            2005,
                        );

                        return @emptyList;

                    } else {

                        push (@tagList, $string2);
                        $stackHash{'colour_background'} = $string2;
                    }
                }

                if ($blinkFlag || $blinkFlag2) {

                    push (@tagList, 'blink_slow');
                    $stackHash{'blink_flag'} = TRUE;
                }

                # If the GA::Client flag is not set, still allow a <FONT> tag to change the font
                #   colour, but don't allow it to change the font name (and don't display an error
                #   in complaint)
                if ($keyword eq 'F' && $axmud::CLIENT->allowMxpFontFlag) {

                    if ($ivHash{'face'}) {

                        $stackHash{'font_name'} = $ivHash{'face'};
                    }

                    if ($ivHash{'size'}) {

                        $stackHash{'font_size'} = $ivHash{'size'};
                    }

                    # Create a dummy style tag that $self->applyColouStyleTags can interpret
                    #   e.g. 'mxpf_monospace_bold_12'
                    push (@tagList, $self->createMxpFontTag(%stackHash));
                }

                # Create a new MXP stack object and store it in the current textview object,
                #   updating the latter's IVs
                if (! $textViewObj->createMxpStackObj($self, $keyword, %stackHash)) {

                    $self->mxpDebug($origToken, 'Internal error while processing element', 2006);

                    return @emptyList;

                } else {

                    # Operation complete
                    return @tagList;
                }

            # </C>
            # </F>
            } else {

                # Cancel the MXP text attribute
                return $self->popMxpStack($keyword);
            }

        # Process atomic modal elements that don't have arguments, i.e. <B> <I> <U> <S>/<STRIKE> <H>
        } else {

            if (@argList || $tagMode eq 'defn') {

                # These modal elements shouldn't have arguments
                $self->mxpDebug($origToken, 'Malformed element', 2011);

                return @emptyList;

            } elsif ($tagMode eq 'open') {

                # Convert keywords like 'B' into the corresponding keys used in
                #   GA::Obj::TextView->mxpModalStackHash, e.g. convert 'B' to 'bold_flag'
                $converted = $axmud::CLIENT->ivShow('constMxpStackConvertHash', $keyword);
                if (defined $converted) {

                    # (In ->mxpModalStackHash, the corresponding value is always TRUE or FALSE)
                    $convertHash{$converted} = TRUE;
                }

                # Create a new MXP stack object and store it in the current textview object,
                #   updating the latter's IVs
                if (! $textViewObj->createMxpStackObj($self, $keyword, %convertHash)) {

                    $self->mxpDebug($origToken, 'Internal error while processing element', 2012);

                    return @emptyList;
                }

                # Return the equivalent Axmud style tag, e.g. 'italics'
                if ($keyword ne 'B' && $keyword ne 'H') {

                    push (@tagList, $axmud::CLIENT->ivShow('constMxpModalOnHash', $keyword));

                } else {

                    # Create a dummy style tag that GA::Obj::TextView->applyColourStyleTags can
                    #   interpret, e.g. 'mxpf_monospace_bold_12'
                    push (@tagList, $self->createMxpFontTag($textViewObj->mxpModalStackHash));
                }

                return @tagList;

            } else {

                # Cancel the MXP text attribute
                return $self->popMxpStack($keyword);
            }
        }
    }

    sub processMxpHtmlElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP HTML element: <SMALL> <TT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            @emptyList, @tagList,
            %stackHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpHtmlElement', @_);
            return @emptyList;
        }

        # HTML elements don't have arguments
        if ($tagMode eq 'defn' || @argList) {

            $self->mxpDebug($origToken, 'Malformed element', 2101);

            return @emptyList;
        }

        # Ignore these tags if the client flag is set
        if (! $axmud::CLIENT->allowMxpFontFlag) {

            return @emptyList;
        }

        # <SMALL>
        # <TT>
        if ($tagMode eq 'open') {

            if ($keyword eq 'SMALL') {

                # Use same size as <H6>
                $stackHash{'font_size'} = $axmud::CLIENT->ivShow('constHeadingSizeHash', 6)
                                            * $axmud::CLIENT->constFontSize;
                $stackHash{'spacing'} = $axmud::CLIENT->ivShow('constHeadingSpacingHash', 6)
                                            * $axmud::CLIENT->constFontSize;
                $stackHash{'font_name'} = $axmud::CLIENT->constFont;
                $stackHash{'bold_flag'} = FALSE;

            } elsif ($keyword eq 'TT') {

                # Use plain old monospace for a teletype text tag
                $stackHash{'font_size'}
                    = $self->currentTabObj->textViewObj->ivShow('mxpModalStackHash', 'font_size');
                $stackHash{'spacing'}
                    = $self->currentTabObj->textViewObj->ivShow('mxpModalStackHash', 'spacing');
                $stackHash{'font_name'} = $axmud::CLIENT->constFont;
                $stackHash{'bold_flag'} = FALSE;
            }

            # Create a dummy style tag that GA::Obj::TextView->applyColourStyleTags can interpret,
            #   e.g. 'mxpf_monospace_bold_12'
            push (@tagList, $self->createMxpFontTag(%stackHash));

            # Create a new MXP stack object and store it in the current textview object,
            #   updating the latter's IVs
            if (
                ! $self->currentTabObj->textViewObj->createMxpStackObj(
                    $self,
                    $keyword,
                    %stackHash,
                )
            ) {
                $self->mxpDebug($origToken, 'Internal error while processing element', 2102);

                return @emptyList;

            } else {

                # Operation complete
                return @tagList;
            }

        # </SMALL>
        # </TT>
        } else {

            # Cancel the MXP text attribute
            return $self->popMxpStack($keyword);
        }
    }

    sub processMxpElementDefn {

        # Called by $self->processMxpElement
        #
        # Process an MXP element definition: <!ELEMENT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $elementObj,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpElementDefn', @_);
            return @emptyList;
        }

        # <!ELEMENT element-name [definition] [ATT=attribute-list] [TAG=tag] [FLAG=flags] [OPEN]
        #   [DELETE] [EMPTY]>
        # <!ELEMENT>
        if ($tagMode ne 'defn') {

            $self->mxpDebug($origToken, 'Malformed element', 2201);

            return @emptyList;
        }

        # Process @argList
        @origList = @checkList = ('name', 'defn', 'att', 'tag', 'flag', 'open', 'delete', 'empty');
        # Hash of argument names which don't take a corresponding value
        %checkHash = (
            'open'      => undef,
            'delete'    => undef,
            'empty'     => undef,
        );
        # Default argument values
        %ivHash = (
            'name'      => undef,
            'defn'      => undef,
            'att'       => undef,
            'tag'       => undef,
            'flag'      => undef,
            'open'      => FALSE,
            'delete'    => FALSE,
            'empty'     => FALSE,
        );

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 2202);

                    return @emptyList;

                } elsif ($argName eq 'open' || $argName eq 'delete' || $argName eq 'empty') {

                    $ivHash{$argName} = TRUE;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Check the validity of the compulsory 'element-name' argument
        if (! $ivHash{'name'}) {

            $self->mxpDebug($origToken, 'Malformed element', 2211);

            return @emptyList;
        }

        # <!ELEMENT> tags can't be used to redefine standard elements, e.g. this is not allowed:
        #       <!ELEMENT expire '<COLOR red><B>'>
        if ($axmud::CLIENT->ivExists('constMxpOfficialHash', uc($ivHash{'name'}))) {

            $self->mxpDebug($origToken, 'Can\'t redefine MXP standard element', 2212);

            return @emptyList;
        }

        # If the 'DELETE' argument was present, then the other arguments are irrelevant
        if ($ivHash{'delete'}) {

            if (! $self->ivExists('mxpElementHash', lc($ivHash{'name'}))) {

                $self->mxpDebug(
                    $origToken,
                    'Can\'t delete non-existent element \'' . $ivHash{'name'} . '\'',
                    2213,
                );

            } else {

                $self->ivDelete('mxpElementHash', lc($ivHash{'name'}));
            }

            return @emptyList;
        }

        # Otherwise, create a new element object
        $elementObj = Games::Axmud::Mxp::Element->new($self, lc($ivHash{'name'}));
        if (! $elementObj) {

            # Improper arguments
            $self->mxpDebug($origToken, 'Internal error while processing element', 2214);

            return @emptyList;
        }

        # Set the element object's remaining IVs
        if (defined $ivHash{'defn'}) {

            # (Sets both ->defnArg and ->defnList)
            if (! $elementObj->setDefn($self, $ivHash{'defn'})) {

                $self->mxpDebug($origToken, 'Invalid element definition', 2215);

                return @emptyList;
            }
        }

        if (defined $ivHash{'att'}) {

            if (! $elementObj->setAttList($self, $ivHash{'att'})) {

                $self->mxpDebug($origToken, 'Invalid attribute list argument', 2216);

                return @emptyList;
            }
        }

        $elementObj->ivPoke('tagArg', $ivHash{'tag'});
        $elementObj->ivPoke('flagArg', $ivHash{'flag'});
        $elementObj->ivPoke('openFlag', $ivHash{'open'});
        $elementObj->ivPoke('emptyFlag', $ivHash{'empty'});

        # No problems encountered, so we can store the element object, replacing any existing
        #   element object with the same name
        $self->ivAdd('mxpElementHash', lc($ivHash{'name'}), $elementObj);

        # There are no Axmud colour/style tags to return
        return @emptyList;
    }

    sub processMxpAttElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP attlist element: <!ATTLIST>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $eleName, $blank, $elementObj, $string,
            @emptyList, @newList,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpAttElement', @_);
        }

        # <!ATTLIST element-name attribute-list>
        # <!ATTLIST>
        if ($tagMode ne 'defn') {

            $self->mxpDebug($origToken, 'Malformed element', 2301);

            return @emptyList;
        }

        # Get 'element-name'. Axmud stores user-defined elements in lower-case, since they are
        #   case-insensitive (and stores keywords in upper case, to easily tell them apart)
        $eleName = lc(shift @argList);
        $blank = shift @argList;
        if (! $eleName || $blank) {

            $self->mxpDebug($origToken, 'Malformed element', 2302);

            return @emptyList;

        } elsif (! $self->ivExists('mxpElementHash', $eleName)) {

            $self->mxpDebug($origToken, 'Unrecognised custom element', 2303);

            return @emptyList;

        } else {

            $elementObj = $self->ivShow('mxpElementHash', $eleName);
        }

        # In '<!ATTLIST element-name attribute-list>', the MXP spec gives two forms for
        #   'attribute-list':
        #
        #       <!ATTLIST boldtext 'color=red background=white flags'>
        #       <!ATTLIST Sound FName V=100 L=1 P=50 T U>

        # In the first example, if @argList contains a single pair, in the form (argument, 'undef'),
        #   and if the argument is surrounded by quotes, expand it
        if (
            scalar @argList == 2
            && ! defined $argList[0]
            && $argList[0] =~ m/^[\'\"].*[\'\"]$/
        ) {
            # Expanded the quoted 'attribute-list'
            $string = $argList[0];
            do {

                my ($attName, $attValue);

                ($string, $attName, $attValue) = $self->extractMxpArgument($string);
                if (! defined $string) {

                    $self->mxpDebug($origToken, 'Invalid attribute list argument', 2304);

                    return @emptyList;

                } else {

                    # If the argument is not in the form 'argument_name=argument_value', then
                    #   $argValue is 'undef', and $argName contains the whole argument
                    push (@newList, $attName, $attValue);

                    # After removing the 'argument' or the 'argument_name=argument_value'
                    #   construction, if there's anything left in $token, it must start with a
                    #   whitespace character
                    # This ensures that there are whitespace character(s) between each argument,
                    #   and prevents constructions like: name='value'name='value'
                    if ($string && $string =~ m/^\S/) {

                        $self->mxpDebug($origToken, 'Malformed element', 2305);

                        return @emptyList;
                    }
                }

            } until (! $string);

            # Expansion complete
            @argList = @newList;
        }

        # @argList is now in the form (attrib_name, default_value, attrib_name, default_value...)
        #   where 'default_value' is 'undef', if the attribute has no default value
        if (@argList) {

            do {

                my ($attName, $attValue);

                $attName = shift @argList;
                $attValue = shift @argList;
                # The GA::Mxp::Element object uses empty strings for arguments with no default
                #   values
                if (! defined $attValue) {

                    $attValue = '';
                }

                # If the attribute already exists, replace its default value
                if ($elementObj->ivExists('attHash', $attName)) {

                    $elementObj->ivAdd('attHash', $attName, $attValue);

                # Otherwise, add a new attribute to the end of the ordered list of attributes
                } else {

                    $elementObj->ivAdd('attHash', $attName, $attValue);
                    $elementObj->ivPush('attList', $attName);
                }

            } until (! @argList);
        }

        # There are no Axmud colour/style tags to return
        return @emptyList;
    }

    sub processMxpEntElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP entity element: <!ENTITY>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $entityObj, $string, $newValue,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpEntElement', @_);
            return @emptyList;
        }

        # <!ENTITY Name Value [DESC=description] [PRIVATE] [PUBLISH] [DELETE] [ADD] [REMOVE]>
        # <!ENTITY>
        if ($tagMode ne 'defn') {

            $self->mxpDebug($origToken, 'Malformed element', 2401);

            return @emptyList;
        }

        # Process @argList
        @origList = @checkList = (
            'name', 'value', 'desc', 'private', 'publish', 'delete', 'add', 'remove',
        );

        # Hash of argument names which don't take a corresponding value
        %checkHash = (
            'private'   => undef,
            'publish'   => undef,
            'delete'    => undef,
            'add'       => undef,
            'remove'    => undef,
        );
        # Default argument values
        %ivHash = (
            'name'      => undef,
            'value'     => undef,
            'desc'      => undef,
            'private'   => FALSE,
            'publish'   => FALSE,
            'delete'    => FALSE,
            'add'       => FALSE,
            'remove'    => FALSE,
        );

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 2402);

                    return @emptyList;

                } elsif ($argName ne 'name' && $argName ne 'value' && $argName ne 'descrip') {

                    $ivHash{$argName} = TRUE;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Check the validity of the compulsory 'Name' and 'Value' arguments
        if (! $ivHash{'name'} || ! defined $ivHash{'value'}) {

            $self->mxpDebug($origToken, 'Malformed element', 2411);

            return @emptyList;
        }

        # If the 'DELETE' argument was present, then the other arguments are irrelevant
        if ($ivHash{'DELETE'}) {

            # Delete this entity
            $self->ivDelete('mxpEntityHash', $ivHash{'name'});
            # Remove any MXP gauges that depend on this entity
            $self->removeMxpGauges($ivHash{'name'});

            # There are no Axmud colour/style tags to return
            return @emptyList;
        }

        # Get the existing entity object or, if there isn't one, create it
        $entityObj = $self->ivShow('mxpEntityHash', $ivHash{'name'});
        if (! $entityObj) {

            # Create a new entity object
            $entityObj = Games::Axmud::Mxp::Entity->new($self, $ivHash{'name'});
            if (! $entityObj) {

                # Improper arguments
                $self->mxpDebug($origToken, 'Internal error while processing element', 2412);

                return @emptyList;

            } else {

                # Store the entity object, replacing any existing entity object with the same name
                $self->ivAdd('mxpEntityHash', $ivHash{'name'}, $entityObj);
            }
        }

        # Store 'Value' in the appropriate place. If the 'ADD' and 'REMOVE' arguments are both
        #   present (for some unfathomable reason), then the 'Value' isn't stored anywhere
        if (! ($ivHash{'add'} && $ivHash{'remove'})) {

            # Store $'Value' in the appropriate place
            if ($ivHash{'add'}) {

                # Add the value to the entity's existing value list
                $entityObj->ivPoke('value', $entityObj->value . '|' . $ivHash{'value'});

            } elsif ($ivHash{'remove'}) {

                # Delete the value from the entity's existing value list
                $string = $entityObj->value;
                $newValue = $ivHash{'value'};
                if (! ($string =~ m/$newValue/)) {

                    $self->mxpDebug($origToken, 'Entity value not found', 2413);

                    return @emptyList;

                } else {

                    $string =~ s/$newValue//;
                    # Replace any || sequence, which contained the  with a single |
                    $string =~ s/\|\|/\|/;
                    # Remove any |s from the beginning/end of the string
                    $string =~ s/^\|//;
                    $string =~ s/\|$//;

                    $entityObj->ivPoke('value', $string);
                }

            } else {

                # Store 'Value' as it was received
                $entityObj->ivPoke('value', $ivHash{'value'});
            }
        }

        # Set the entity object's remaining IVs
        $entityObj->ivPoke('descArg', $ivHash{'descrip'});
        $entityObj->ivPoke('privateFlag', $ivHash{'private'});
        $entityObj->ivPoke('publishFlag', $ivHash{'publish'});

        # Mark any corresponding gauges to be updated
        $self->ivAdd('mxpGaugeUpdateHash', $ivHash{'name'}, undef);

        # There are no Axmud colour/style tags to return
        return @emptyList;
    }

    sub processMxpVarElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP variable element: <V>...</V>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $entityObj, $varObj, $value, $string,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpVarElement', @_);
            return @emptyList;
        }

        # <V Name [DESC=description] [PRIVATE] [PUBLISH] [DELETE] [ADD] [REMOVE]>Value</V>
        # <VAR Name [DESC=description] [PRIVATE] [PUBLISH] [DELETE] [ADD] [REMOVE]>Value</VAR>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
            || $tagMode eq 'defn'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 2501);

            return @emptyList;
        }

        # Process the <V> tag
        if ($tagMode eq 'open') {

            # Watch out for a <V> ... <V> ... </V> construction
            if ($self->mxpCurrentVar) {

                # This a second <V> tag after an earlier </V> tag. Remove the earlier variable
                #   object, and show an error (ignoring both tags)
                $self->ivUndef('mxpCurrentVar');

                $self->mxpDebug($origToken, '<VAR> tag after earlier unclosed <VAR> tag', 2502);

                return @emptyList;
            }

            # Process @argList
            @origList = @checkList = (
                'name', 'desc', 'private', 'publish', 'delete', 'add', 'remove',
            );

            # Hash of argument names which don't take a corresponding value
            %checkHash = (
                'private'   => undef,
                'publish'   => undef,
                'delete'    => undef,
                'add'       => undef,
                'remove'    => undef,
            );
            # Default argument values
            %ivHash = (
                'name'      => undef,
                'desc'      => undef,
                'private'   => FALSE,
                'publish'   => FALSE,
                'delete'    => FALSE,
                'add'       => FALSE,
                'remove'    => FALSE,
            );

            if (@argList) {

                do {

                    my ($argName, $argValue) = $self->findMxpArgsByPosn(
                        \@origList,
                        \@checkList,
                        \%ivHash,
                        \%checkHash,
                        shift @argList,     # not 'undef'
                        shift @argList,     # might be 'undef'
                    );

                    if (! defined $argName) {

                        # Unrecognised argument name, or repeating argument name
                        $self->mxpDebug($origToken, 'Malformed element', 2503);

                        return @emptyList;

                    } elsif ($argName ne 'name' && $argName ne 'descrip') {

                        $ivHash{$argName} = TRUE;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check the validity of the compulsory 'Name' argument
            if (! $ivHash{'name'}) {

                $self->mxpDebug($origToken, 'Malformed element', 2511);

                return @emptyList;
            }

            # Find the corresponding entity object or, if there isn't one, create it (entity names
            #   are case-sensitive)
            $entityObj = $self->ivShow('mxpEntityHash', $ivHash{'name'});
            if (! $entityObj) {

                # Create a new entity object, whose (emergency default) value is an empty string
                $entityObj = Games::Axmud::Mxp::Entity->new($self, $ivHash{'name'}, '');
                if (! $entityObj) {

                    # Improper arguments
                    $self->mxpDebug($origToken, 'Internal error while processing variable', 2512);

                    return @emptyList;

                } else {

                    # Store the entity object, replacing any existing entity object with the same
                    #   name
                    $self->ivAdd('mxpEntityHash', $ivHash{'name'}, $entityObj);
                }
            }

            # We can't modify the entity's IVs until the closing </V> tag is found, so store
            #   arguments temporarily in a GA::Mxp::Var object
            $varObj = Games::Axmud::Mxp::Var->new($self, $ivHash{'name'});
            if (! $varObj) {

                $self->mxpDebug($origToken, 'Internal error while processing variable', 2513);

                return @emptyList;
            }

            # Set the variable object's IVs
            $varObj->ivPoke('descArg', $ivHash{'descrip'});
            $varObj->ivPoke('privateFlag', $ivHash{'private'});
            $varObj->ivPoke('publishFlag', $ivHash{'publish'});
            $varObj->ivPoke('deleteFlag', $ivHash{'delete'});
            $varObj->ivPoke('addFlag', $ivHash{'add'});
            $varObj->ivPoke('removeFlag', $ivHash{'remove'});

            # Store the current variable object
            $self->ivPoke('mxpCurrentVar', $varObj);

            # There are no Axmud colour/style tags to return
            return @emptyList;

        # Process the <\V> tag
        } else {

            # Watch out for a <V> ... </V> ... </V> construction
            if (! $self->mxpCurrentVar) {

                # This a second </V> tag after an earlier </V> tag
                $self->mxpDebug($origToken, '</VAR> tag does not match earlier <VAR> tag', 2521);

                return @emptyList;

            } else {

                $varObj = $self->mxpCurrentVar;
                $self->ivUndef('mxpCurrentVar');
            }

            if (! $varObj->value) {

                # There were no valid text tokens between the <V>...</V> tags
                $self->mxpDebug($origToken, 'Invalid <VAR>...</VAR> construction', 2522);

                return @emptyList;

            } else {

                $value = $varObj->value;
            }

            # Find the equivalent GA::Mxp::Entity object
            $entityObj = $self->ivShow('mxpEntityHash', $varObj->name);
            if (! $entityObj) {

                $self->mxpDebug(
                    $origToken,
                    'Can\'t find entity matching variable \'' . $varObj->name . '\'',
                    2523,
                );

                return @emptyList;
            }

            # If the DELETE argument was used, the entity gets removed altogether (and we don't need
            #   to know what was between the <V>...</V> tabs)
            if ($varObj->deleteFlag) {

                # Delete this entity
                $self->ivDelete('mxpEntityHash', $entityObj->name);
                # Remove any MXP gauges that depend on this entity
                $self->removeMxpGauges($ivHash{'name'});

                # (There are no Axmud colour/style tags to return)
                return @emptyList;
            }

            # Update the entity's value. If the 'ADD' and 'REMOVE' arguments are both present (for
            #   some unfathomable reason), then the variable object's ->value isn't stored anywhere
            if (! ($varObj->addFlag && $varObj->deleteFlag)) {

                if ($varObj->addFlag) {

                    $entityObj->ivPoke('value', $entityObj->value . '|' . $value);

                } elsif ($varObj->removeFlag) {

                    $string = $entityObj->value;
                    if (! ($string =~ m/$value/)) {

                        $self->mxpDebug(
                            $origToken,
                            'Entity \'' . $entityObj->name . '\' value \'' . $value
                            . '\' not found',
                            2524,
                        );

                        return @emptyList;

                    } else {

                        $string =~ s/$value//;
                        # Replace any || sequence, which contained the  with a single |
                        $string =~ s/\|\|/\|/;
                        # Remove any |s from the beginning/end of the string
                        $string =~ s/^\|//;
                        $string =~ s/\|$//;

                        $entityObj->ivPoke('value', $string);
                    }

                } else {

                    $entityObj->ivPoke('value', $value);
                }
            }

            # Process the other arguments
            $entityObj->ivPoke('descArg', $varObj->descArg);
            $entityObj->ivPoke('privateFlag', $varObj->privateFlag);
            $entityObj->ivPoke('publishFlag', $varObj->publishFlag);

            # Mark any corresponding gauges to be updated
            $self->ivAdd('mxpGaugeUpdateHash', $ivHash{'name'}, undef);

            # There are no Axmud colour/style tags to return
            return @emptyList;
        }
    }

    sub processMxpSupportElement {

        # Called by $self->processSupportElement (and also by GA::Client->set_allowMxpFlag and
        #   ->set_allowSoundFlag)
        #
        # Process an MXP support element: <SUPPORT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (will always
        #       be an empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $msg,
            @emptyList,
            %tagHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpSupportElement', @_);
            return @emptyList;
        }

        # <SUPPORT>
        # <SUPPORT [arg] [arg] [arg]...>
        if ($tagMode ne 'open') {

            $self->mxpDebug($origToken, 'Malformed element', 2601);

            return @emptyList;
        }

        # Axmud offers support for all MXP tags, but not all of them may be available at the moment
        #   (e.g. if sound is turned off, the world shouldn't bother sending <MUSIC> tags
        # Import the constant hash of MXP tags and their attributes, and remove both tags and
        #   attributes which aren't available at the moment
        %tagHash = $axmud::CLIENT->constMxpAttribHash;

        if (! $axmud::CLIENT->allowMxpFontFlag) {

            delete $tagHash{'h1'};
            delete $tagHash{'h2'};
            delete $tagHash{'h3'};
            delete $tagHash{'h4'};
            delete $tagHash{'h5'};
            delete $tagHash{'h6'};
            delete $tagHash{'small'};
            delete $tagHash{'tt'};

            # If FALSE, <FONT> tags can still change the text colour
            %tagHash = $self->deleteMxpAttrib('font', 'face', %tagHash);
            %tagHash = $self->deleteMxpAttrib('font', 'size', %tagHash);
        }

        if (! $axmud::CLIENT->allowMxpImageFlag) {

            delete $tagHash{'image'};
        }

        if (! $axmud::CLIENT->allowMxpLoadImageFlag) {

            %tagHash = $self->deleteMxpAttrib('image', 'url', %tagHash);
        }

        if (! $axmud::CLIENT->allowMxpFilterImageFlag) {

            delete $tagHash{'filter'};
        }

        if (! $axmud::CLIENT->allowSoundFlag || ! $axmud::CLIENT->allowMxpSoundFlag) {

            delete $tagHash{'sound'};
            delete $tagHash{'music'};
        }

        # (No need to use GA::Client->allowMxpLoadSoundFlag here)

        if (
            ! $axmud::CLIENT->allowMxpGaugeFlag
            || ! $self->mainWin->ivShow('firstStripHash', 'Games::Axmud::Strip::GaugeBox')
        ) {
            delete $tagHash{'gauge'};
            delete $tagHash{'stat'};
        }

        if (
            $axmud::BLIND_MODE_FLAG
            || ! $axmud::CLIENT->allowMxpFrameFlag
            || $self->mxpDisableFrameFlag
        ) {
            delete $tagHash{'frame'};
            # (Axmud chooses to ignore <DEST> tags if frames have been disabled generally; even
            #   though some world might want cursor control in the main MUD window, that's not very
            #   practical if it's scrolling)
            delete $tagHash{'dest'};
        }

        if ($axmud::CLIENT->shareMainWinFlag || ! $axmud::CLIENT->allowMxpInteriorFlag) {

            %tagHash = $self->deleteMxpAttrib('frame', 'internal', %tagHash);
        }

        if (! $axmud::CLIENT->allowMxpCrosslinkFlag) {

            delete $tagHash{'relocate'};
        }

        # Process the <SUPPORT> tag

        if (! @argList) {

            # Return a list of all supported tags
            $msg = '<SUPPORTS';

            foreach my $key (sort {$a cmp $b} (keys %tagHash)) {

                $msg .= ' +' . uc($key);
            }

            $msg .= '>';

            # The response must be sent securely
            $self->optSendMxpSecure($msg);

        } else {

            # Respond to every item in @argList
            $msg = '<SUPPORTS';

            do {

                my ($argName, $argValue, $tag, $attrib, $listRef);

                $argName = shift @argList;
                $argValue = shift @argList;     # Should be 'undef'; ignored, in any case

                # Remove the initial/final quotation marks, if present
                $argName =~ s/^\"//;
                $argName =~ s/\"$//;

                # Split the item into its component parts
                #   e.g. <SUPPORT color.*>          > ('color', '*')
                #   e.g. <SUPPORT send.expire>      > ('send', 'expire')
                if ($argName =~ m/(\w+)\.(.*)/) {

                    $tag = lc($1);
                    $attrib = lc($2);

                #   e.g. <SUPPORT image>            > ('image')
                } else {

                    $tag = lc($argName);
                }

                # Convert a long tag to its abbreviation (e.g. <DESTINATION> to <DEST>)
                if ($axmud::CLIENT->ivExists('constMxpConvertHash', uc($tag))) {

                    $tag = lc($axmud::CLIENT->ivShow('constMxpConvertHash', uc($tag)));
                }

                # Check the MXP is both recognised and currently supported
                if (! exists $tagHash{$tag}) {

                    $msg .= ' -' . $tag;

                } elsif (! defined $attrib) {

                    # e.g. <SUPPORT image>
                    $msg .= ' +' . $tag;

                } elsif ($attrib eq '*') {

                    # e.g. <SUPPORT color.*>
                    $listRef = $tagHash{$tag};
                    # MXP tags without attributes, such as <B>, appear as keys in %tagHash, but
                    #   their corresponding value is 'undef' rather than a list reference
                    if (defined $listRef) {

                        foreach my $thisAttrib (@$listRef) {

                            $msg .= ' +' . $tag . '.' . $thisAttrib;
                        }
                    }

                } else {

                    # e.g. <SUPPORT send.expire>
                    $listRef = $tagHash{$tag};
                    if (defined $listRef) {

                        INNER: foreach my $thisAttrib (@$listRef) {

                            if ($thisAttrib eq lc($attrib)) {

                                $msg .= ' +' . $tag . '.' . $thisAttrib;
                                last INNER;
                            }
                        }
                    }
                }

            } until (! @argList);

            $msg .= '>';

            # The response must be sent securely
            $self->optSendMxpSecure($msg);
        }

        # There are no Axmud colour/style tags to return
        return @emptyList;
    }

    sub processMxpFrameElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP frame element: <FRAME>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (will always
        #       be an empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $origFrameObj, $frameObj, $left, $right, $top, $bottom, $newFlag, $gridObj, $zonemapObj,
            $width, $height, $zoneModelObj, $taskObj, $currentLeft, $currentRight, $currentTop,
            $currentBottom, $xFactor, $yFactor, $currentWidth, $currentHeight, $reduceWidth,
            $reduceHeight, $resizeLeft, $resizeRight, $resizeTop, $resizeBottom, $newLeft,
            $newRight, $newTop, $newBottom, $newPaneObj, $tabObj, $paneObj, $spinFlag,
            $noTempGridFlag,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash, %reservedHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpFrameElement', @_);
            return @emptyList;
        }

        # <FRAME NAME=name [ACTION=action] [TITLE=title] [PARENT=parent] [INTERNAL] [ALIGN=align]
        #   [LEFT=left] [TOP=top] [WIDTH=width] [HEIGHT=height] [SCROLLING=scrolling] [FLOATING]>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 2702);

            return @emptyList;
        }

        # Ignore this tag if the client/session flags are set
        if (
            $axmud::BLIND_MODE_FLAG
            || ! $axmud::CLIENT->allowMxpFrameFlag
            || $self->mxpDisableFrameFlag
        ) {
            return @emptyList;
        }

        # Process the <FRAME> tag

        # Process @argList
        @origList = @checkList = (
            'name', 'action', 'title', 'internal', 'align', 'left', 'top', 'width',
            'height', 'scrolling', 'floating',
            # Pueblo option, ignored in MXP
            'fit',
        );
        # Hash of argument names which don't take a corresponding value
        %checkHash = (
            'internal'  => undef,
            'floating'  => undef,
        );
        # Default argument values
        %ivHash = (
            'name'      => undef,
            'action'    => 'open',
            'title'     => undef,
            'parent'    => undef,       # Removed in MXP v1.0; ignored if specified
            'internal'  => FALSE,
            'align'     => 'top',
            'left'      => 0,
            'top'       => 0,
            'width'     => '50%',
            'height'    => '50%',
            'scrolling' => FALSE,
            'floating'  => FALSE,
            # Pueblo option, ignored in MXP
            'fit'       => FALSE,       # Partially implemented
        );

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 2702);

                    return @emptyList;

                } elsif ($argName eq 'fit' && $self->puebloMode ne 'client_agree') {

                    # Pueblo option, ignored in MXP
                    $self->mxpDebug($origToken, 'Malformed element', 2703);

                } elsif ($argName eq 'action') {

                    $ivHash{$argName} = lc($argValue);

                } elsif ($argName eq 'internal') {

                    if (
                        ! $axmud::CLIENT->shareMainWinFlag
                        && $axmud::CLIENT->allowMxpInteriorFlag
                    ) {
                        # Internal frames can be created
                        $ivHash{$argName} = TRUE;

                    } else {

                        # Internal frames can't be created, so create an external frame instead
                        # In addition, the size and position of the internal frame (if specified),
                        #   or the default size and position (if not specified), is now irrelevant,
                        #   and there is no need to create a temporary workspace grid - just use the
                        #   existing one, setting the size and position of the frame as we would for
                        #   any task window
                        $noTempGridFlag = TRUE;
                    }

                } elsif ($argName eq 'scrolling') {

                    # Default is 'no'
                    if ($argValue && $argValue eq 'yes') {

                        $ivHash{$argName} = TRUE;
                    }

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # If an internal frame was requested, but cannot be provided, the specified size and
        #   position is irrelevant
        if ($noTempGridFlag) {

            # Reset those values to their defaults
            $ivHash{'align'} = 'top';
            $ivHash{'left'} = 0;
            $ivHash{'top'} = 0;
            $ivHash{'width'} = '50%';
            $ivHash{'height'} = '50%';
        }

        # Check the validity of the compulsory 'Name' argument
        if (! $ivHash{'name'}) {

            $self->mxpDebug($origToken, 'Malformed element', 2711);

            return @emptyList;
        }

        # Check the validity of optional arguments
        if (
            $ivHash{'action'} ne 'open'
            && $ivHash{'action'} ne 'close'
            && $ivHash{'action'} ne 'redirect'
        ) {
            # Invalid value
            $self->mxpDebug($origToken, 'Invalid frame action \'' . $ivHash{'action'} . '\'', 2712);

            return @emptyList;
        }

        if (
            $ivHash{'align'} ne 'left'
            && $ivHash{'align'} ne 'right'
            && $ivHash{'align'} ne 'bottom'
            && $ivHash{'align'} ne 'top'
        ) {
            # Invalid value
            $self->mxpDebug($origToken, 'Invalid frame align \'' . $ivHash{'align'} . '\'', 2713);

            return @emptyList;
        }

        foreach my $att ('left', 'top', 'width', 'height') {

            my $value = $ivHash{$att};

            if (! ($value =~ m/^\-?\d+[\%c]?/)) {

                $self->mxpDebug(
                    $origToken,
                    'Invalid frame ' . $att . ' coordinate \'' . $value . '\'',
                    2714,
                );

                return @emptyList;
            }
        }

        # Any <FRAME> tag abnormally terminates a <DEST>...</DEST> construction
        if ($self->mxpCurrentDest) {

            # Process a fake </DEST> tag to reset IVs correctly
            $self->processMxpDestElement('</DEST>', 'close', 'DEST');

            # Display error, if allowed
            $self->mxpDebug($origToken, '<FRAME> tag within <DEST> tags are not acceptable', 2715);

            return @emptyList;
        }

        # The <FRAME> and <DEST> tags can change $self->currentTabObj, corresponding to the frame in
        #   which text received from the world is displayed. The calling function may have processed
        #   some text tokens which haven't been displayed yet; if so, display them now so that
        #   they're displayed in the right frame
        if ($self->processStripLine) {

            $self->respondIncomingData(
                'part',
                $self->processOrigLine,
                $self->processStripLine,
                $self->processTagHash,
            );
        }

        # Apply any links created by MXP <A> and <SEND> tags to the current textview (if the current
        #   textview was changed during the call to this function, any links for other textviews
        #   have already been applied)
        foreach my $linkObj ($self->mxpTempLinkList) {

            $self->currentTabObj->textViewObj->add_incompleteLink($linkObj);
        }

        $self->ivEmpty('mxpTempLinkList');

        # Find the specified frame object
        $frameObj = $self->getMxpFrame($ivHash{'name'});

        # If there are no frame objects at all, before creating a new frame, we need to create a
        #   frame object for this session's default tab, and assign it to the default tab's pane
        #   object
        # (If the world actually specified a frame named '_top', then create a frame object for that
        #   frame only)
        if (
            ! $self->mxpFrameHash
            && ($ivHash{'action'} eq 'open' || $ivHash{'action'} eq 'redirect')
        ) {
            $origFrameObj = Games::Axmud::Mxp::Frame->new($self, '_top');
            if (! $origFrameObj) {

                # Improper arguments
                $self->mxpDebug($origToken, 'Internal error while processing frame', 2716);

                return @emptyList;

            } else {

                # Store the frame object and assign it to the default tab's pane object
                $self->ivAdd('mxpFrameHash', $origFrameObj->name, $origFrameObj);

                $origFrameObj->ivPoke('tabObj', $self->defaultTabObj);
                $origFrameObj->ivPoke('paneObj', $self->defaultTabObj->paneObj);
                $origFrameObj->ivPoke('textViewObj', $self->defaultTabObj->textViewObj);

                # Set other session IVs
                $self->ivPoke('mxpCurrentFrame', '_top');
                $self->ivPoke('mxpPrevFrame', '_top');

                ($left, $right, $top, $bottom) = $self->defaultTabObj->paneObj->stripObj->getPosn(
                    $self->defaultTabObj->paneObj,
                );

                # (The size of all additional frames is a factor of the size of the default frame,
                #   when the first additional frame is added)
                $self->ivPoke('mxpFrameWidth', $right - $left + 1);
                $self->ivPoke('mxpFrameHeight', $bottom - $top + 1);

                if ($ivHash{'name'} eq '_top') {

                    $frameObj = $origFrameObj;
                }
            }
        }

        # If the specified frame doesn't exist, create it (unless we created one just above)
        if (! $frameObj) {

            if ($ivHash{'action'} eq 'close') {

                $self->mxpDebug(
                    $origToken,
                    'Unrecognised frame name \'' . $ivHash{'name'} . '\'',
                    2717,
                );

                return @emptyList;

            } else {

                # Create a new frame object for the specified frame
                $frameObj = Games::Axmud::Mxp::Frame->new($self, $ivHash{'name'});
                if (! $frameObj) {

                    # Improper arguments
                    $self->mxpDebug($origToken, 'Internal error while processing frame', 2718);

                    return @emptyList;

                } else {

                    # Store the frame object, replacing any existing frame object with the same name
                    $self->ivAdd('mxpFrameHash', $frameObj->name, $frameObj);
                    $newFlag = TRUE;
                }
            }

        } elsif ($ivHash{'action'} eq 'open') {

            $self->mxpDebug(
                $origToken,
                'Can\'t open frame \'' . $ivHash{'name'} . '\', it already exists',
                2719,
            );

            return @emptyList;
        }

        # Set the frame object's IVs
        $frameObj->ivPoke('align', $ivHash{'align'});
        $frameObj->ivPoke('left', $ivHash{'left'});
        $frameObj->ivPoke('top', $ivHash{'top'});
        $frameObj->ivPoke('width', $ivHash{'width'});
        $frameObj->ivPoke('height', $ivHash{'height'});
        $frameObj->ivPoke('title', $ivHash{'title'});
        $frameObj->ivPoke('internalFlag', $ivHash{'internal'});
        $frameObj->ivPoke('scrollingFlag', $ivHash{'scrolling'});
        $frameObj->ivPoke('floatingFlag', $ivHash{'floating'});

        # If a new frame was specified, create it
        if ($newFlag) {

            # Implement an external frame as a new Frame task, placed on a temporary zonemap (if
            #   workspace grids are in use)
            if (! $frameObj->internalFlag) {

                # If workspace grids are available and the workspace containing the session's 'main'
                #   window isn't using a temporary zonemap, then create a temporary zonemap (so
                #   that all frames can be placed at their specified size and position)
                $gridObj = $self->mainWin->workspaceObj->findWorkspaceGrid($self);
                if ($gridObj && $gridObj->zonemap && ! $noTempGridFlag) {

                    $zonemapObj = $axmud::CLIENT->ivShow('zonemapHash', $gridObj->zonemap);
                    if ($zonemapObj && ! $zonemapObj->tempFlag) {

                        $zonemapObj = $axmud::CLIENT->createTempZonemap($self);
                        if ($zonemapObj) {

                            $gridObj->applyZonemap($zonemapObj);
                        }
                    }
                }

                if ($frameObj->name ne '_top') {

                    # Create the new Frame task
                    $taskObj = Games::Axmud::Task::Frame->new($self, 'current');
                    if (! $taskObj) {

                        $self->mxpDebug($origToken, 'Internal error while processing link', 2720);

                        return @emptyList;
                    }

                    # If the world has specified a size and position for this window, work out the
                    #   equivalent size and position in pixels on the same workspace that the
                    #   session's 'main' window is using, and store this size/position in the Frame
                    #   task so that the task window can be opened using them
                    if (! $noTempGridFlag) {

                        $taskObj->set_winPosn($self->convertMxpWinSize($frameObj));
                    }

                    # Update IVs. The frame object's ->tabObj, ->paneObj and ->textViewObj IVs are
                    #   set when the task window is opened
                    $frameObj->ivPoke('taskObj', $taskObj);
                    $taskObj->set_frameObj($frameObj);

                    # Give the task window a chance to open immediately by spinning the task loop
                    #   (in case the next token processed by $self->processIncomingData wants to
                    #   display text in the task window)
                    # Normally the task loop won't spin if the incoming data loop (which was
                    #   ultimately responsible for calling this function) is spinning; so we have
                    #   to cheat, and temporarily reset a flag
                    if ($self->childLoopSpinFlag) {

                        $spinFlag = TRUE;
                        $self->ivPoke('childLoopSpinFlag', FALSE);
                    }

                    $self->spinTaskLoop();

                    if ($spinFlag) {

                        $self->ivPoke('childLoopSpinFlag', TRUE);
                    }
                }

            # Implement an internal frame as a new pane object in the session's 'main' window
            } else {

                # Reduce the size of the default tab's pane object (the original frame) to make
                #   way for a new frame
                # On the assumption that the world won't create endless frames with the same
                #   alignment, reduce the original frame's current size by a factor of it original
                #   size (e.g. original width = 60, current width = 40, new width = 20)
                # If this would make the original frame's width/height too small (e.g. original
                #   width = 60, current width = 20, new width = 0), halve the size instead
                #   (e.g. new width = 10)
                $origFrameObj = $self->ivShow('mxpFrameHash', '_top');
                ($currentLeft, $currentRight, $currentTop, $currentBottom)
                    = $self->defaultTabObj->paneObj->stripObj->getPosn(
                        $self->defaultTabObj->paneObj,
                    );

                # In Pueblo, the 'fit' option is only partially implemented. We can't (easily) make
                #   the frame exactly the right size for the text it contains, but based on the
                #   assumption that worlds are using the 'fit' option to display a single line of
                #   text, or maybe two (most of the time), reduce the size of the frame by half
                #   (e.g. reduce height from 25% to 12.5%)
                $xFactor = $self->mxpFrameXFactor;
                $yFactor = $self->mxpFrameYFactor;

                if ($ivHash{'fit'}) {

                    $xFactor /= 2;
                    $yFactor /= 2;
                }

                # Original frame's current size
                $currentWidth = $currentRight - $currentLeft + 1;
                $currentHeight = $currentBottom - $currentTop + 1;
                # Original frame should be reduced by this much
                $reduceWidth = int($self->mxpFrameWidth * $xFactor);
                $reduceHeight = int($self->mxpFrameHeight * $yFactor);

                if ($currentWidth <= $reduceWidth) {

                    $reduceWidth = int($currentWidth / 2);
                }

                if ($currentHeight <= $reduceHeight) {

                    $reduceHeight = int($currentHeight / 2);
                }

                # Reduce the original frame on one axis only
                $newLeft = $resizeLeft = $currentLeft;
                $newRight = $resizeRight = $currentRight;
                $newTop = $resizeTop = $currentTop;
                $newBottom = $resizeBottom = $currentBottom;
                if ($frameObj->align eq 'left') {

                    # (The size/position of the new frame we're about to create)
                    $newLeft = $resizeLeft;
                    $newRight = $resizeLeft + $reduceWidth - 1;

                    # (The size/position of the original frame)
                    $resizeLeft += $reduceWidth;

                } elsif ($frameObj->align eq 'right') {

                    $resizeRight -= $reduceWidth;

                    $newLeft = $resizeRight + 1;
                    $newRight = $newLeft + $reduceWidth - 1;

                } elsif ($frameObj->align eq 'top') {

                    $newTop = $resizeTop;
                    $newBottom = $resizeTop + $reduceHeight - 1;

                    $resizeTop += $reduceHeight;

                } elsif ($frameObj->align eq 'bottom') {

                    $resizeBottom -= $reduceHeight;

                    $newTop = $resizeBottom + 1;
                    $newBottom = $newTop + $reduceHeight - 1;
                }

                $self->defaultTabObj->paneObj->stripObj->resizeTableObj(
                    $self->defaultTabObj->paneObj,
                    $resizeLeft,
                    $resizeRight,
                    $resizeTop,
                    $resizeBottom,
                );

                # Add a new pane object in the newly-available space
                $newPaneObj = $self->defaultTabObj->paneObj->stripObj->addTableObj(
                    'Games::Axmud::Table::Pane',
                    $newLeft,
                    $newRight,
                    $newTop,
                    $newBottom,
                    'mxp_frame_' . $frameObj->name,
                    # Configuration hash
                    'frame_title'       => $ivHash{'name'},
                );

                if (! $newPaneObj) {

                    $self->mxpDebug(
                        $origToken,
                        'Internal error creating frame \'' . $ivHash{'name'} . '\'',
                        2721,
                    );

                    return @emptyList;
                }

                # Add a tab
                $tabObj = $newPaneObj->addSimpleTab($self);
                if (! $tabObj) {

                    $self->mxpDebug(
                        $origToken,
                        'Internal error creating frame \'' . $ivHash{'name'} . '\'',
                        2722,
                    );

                    return @emptyList;
                }

                # This call makes the original frame's textview scroll to the bottom, as it's
                #   supposed to
                $axmud::CLIENT->desktopObj->updateWidgets(
                    $self->_objClass . '->processMxpFrameElement',
                );

                # Update IVs
                $frameObj->ivPoke('tabObj', $tabObj);
                $frameObj->ivPoke('paneObj', $tabObj->paneObj);
                $frameObj->ivPoke('textViewObj', $tabObj->textViewObj);
            }

        # Close an existing frame, if specified
        } elsif ($ivHash{'action'} eq 'close') {

            # Do not close the default tab's pane object, even if the world wants to
            # (The MXP spec doesn't specify what to do, but Axmud will not allow it)
            if ($ivHash{'name'} eq '_top') {

                $self->mxpDebug(
                    $origToken,
                    'Cannot close MXP frame corresponding to the default tab',
                    2723,
                );

                return @emptyList;
            }

            # Close the frame
            if ($frameObj->internalFlag) {

                # Remove an internal frame
                $self->defaultTabObj->paneObj->stripObj->removeTableObj(
                    $self->defaultTabObj->paneObj,
                );

            } else {

                # Halt the Frame task
                $frameObj->taskObj->set_shutdownFlag(TRUE);
            }

            # Update IVs
            $self->ivDelete('mxpFrameHash', $frameObj->name);
            if ($self->mxpCurrentFrame eq $frameObj->name) {

                # If the current frame is deleted, resume using the original frame
                # (The MXP spec doesn't specify what to do, so Axmud will do this)
                $self->ivPoke('mxpCurrentFrame', '_top');
                $self->ivPoke('currentTabObj', $self->defaultTabObj);
            }

            if ($self->mxpPrevFrame eq $frameObj->name) {

                # Same applies to the previous frame
                $self->ivPoke('mxpPrevFrame', '_top');
            }
        }

        # Redirect text received from the world to the frame
        if ($ivHash{'action'} eq 'redirect') {

            $self->ivPoke('mxpPrevFrame', $self->mxpCurrentFrame);
            $self->ivPoke('mxpCurrentFrame', $frameObj->name);
            $self->ivPoke('currentTabObj', $frameObj->tabObj);
        }

        # There are no Axmud colour/style tags to return
        return @emptyList;
    }

    sub processMxpDestElement {

        # Called by $self->processMxpElement (and by $self->processMxpFrameElement to process a
        #   fake </DEST> tag)
        #
        # Process an MXP destination element: <DEST>...</DEST>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $frameObj, $destObj, $textViewObj, $mark,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpDestElement', @_);
            return @emptyList;
        }

        # If the user manually closed an exterior frame (implemented as a Frame task window), ignore
        #   <DEST> tags
        if ($self->mxpDisableFrameFlag) {

            return @emptyList;
        }

        # <DEST Frame_name [X=int] [Y=int] [EOL] [EOF]>text</DEST>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
            || $tagMode eq 'defn'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 2801);

            return @emptyList;
        }

        # The <FRAME> and <DEST> tags can change $self->currentTabObj, corresponding to the frame in
        #   which text received from the world is displayed. The calling function may have processed
        #   some text tokens which haven't been displayed yet; if so, display them now so that
        #   they're displayed in the right frame
        if ($self->processStripLine) {

            $self->respondIncomingData(
                'part',
                $self->processOrigLine,
                $self->processStripLine,
                $self->processTagHash,
            );
        }

        # Apply any links created by MXP <A> and <SEND> tags to the current textview (if the current
        #   textview was changed during the call to this function, any links for other textviews
        #   have already been applied)
        foreach my $linkObj ($self->mxpTempLinkList) {

            $self->currentTabObj->textViewObj->add_incompleteLink($linkObj);
        }

        $self->ivEmpty('mxpTempLinkList');

        # Process the <DEST> tag
        if ($tagMode eq 'open') {

            # Watch out for a <DEST> ... <DEST> ... </DEST> construction
            if ($self->mxpCurrentDest) {

                # This a second <DEST> tag after an earlier </DEST> tag. Remove the earlier
                #   destination object, and show an error (ignoring both tags)
                $self->ivUndef('mxpCurrentDest');

                $self->mxpDebug($origToken, '<DEST> tag after earlier unclosed <DEST> tag', 2802);

                return @emptyList;
            }

            # Process @argList
            @origList = @checkList = ('name', 'x', 'y', 'eol', 'eof');
            # Hash of argument names which don't take a corresponding value
            %checkHash = (
                'eol'       => undef,
                'eof'       => undef,
            );
            # Default argument values
            %ivHash = (
                'name'      => undef,
                'x'         => undef,
                'y'         => undef,
                'eol'       => FALSE,
                'eof'       => FALSE,
            );

            if (@argList) {

                do {

                    my ($argName, $argValue) = $self->findMxpArgsByPosn(
                        \@origList,
                        \@checkList,
                        \%ivHash,
                        \%checkHash,
                        shift @argList,     # not 'undef'
                        shift @argList,     # might be 'undef'
                    );

                    if (! defined $argName) {

                        # Unrecognised argument name, or repeating argument name
                        $self->mxpDebug($origToken, 'Malformed element', 2803);

                        return @emptyList;

                    } elsif ($argName eq 'eol' || $argName eq 'eof') {

                        $ivHash{$argName} = TRUE;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check the validity of the compulsory 'Name' argument
            if (! $ivHash{'name'}) {

                # MXP spec says to use the main MUD window in this situation
                $ivHash{'name'} = '_top';
            }

            # Check the validity of the x/y coordinates, if specified
            if (
                (defined $ivHash{'x'} && ! $axmud::CLIENT->intCheck($ivHash{'x'}, 0))
                || (defined $ivHash{'y'} && ! $axmud::CLIENT->intCheck($ivHash{'y'}, 0))
            ) {
                $self->mxpDebug($origToken, 'Malformed element', 2812);

                return @emptyList;
            }

            # Find the specified frame object. '_top' and '_previous' are recognised, but this
            #   function won't create a new frame as $self->processMxpFrameElement would
            $frameObj = $self->getMxpFrame($ivHash{'name'});

            if (! $frameObj) {

                $self->mxpDebug($origToken, 'Unrecognised frame name in <DEST> tag', 2813);

                return @emptyList;
            }

            # In case an external frame is attached to a Frame task whose window hasn't opened yet,
            #   display the text in the session's default tab instead
            if (! $frameObj->tabObj) {

                $frameObj = $self->ivShow('mxpFrameHash', '_top');
            }

            # Store the arguments temporarily in a GA::Mxp::Dest object until the closing </DEST>
            #   tag is found
            $destObj = Games::Axmud::Mxp::Dest->new($self, $ivHash{'name'});
            if (! $destObj) {

                $self->mxpDebug(
                    $origToken,
                    'Internal error while processing destination tag',
                    2814,
                );

                return @emptyList;
            }

            # Set the destination object's IVs
            $destObj->ivPoke('xPos', $ivHash{'x'});
            $destObj->ivPoke('yPos', $ivHash{'y'});
            $destObj->ivPoke('eolFlag', $ivHash{'eol'});
            $destObj->ivPoke('eofFlag', $ivHash{'eof'});

            # Store the current destination object
            $self->ivPoke('mxpCurrentDest', $destObj);

            # The MXP spec suggests that text inside a <DEST>...</DEST> construction shouldn't be
            #   displayed until the final </DEST> tag is received, but makes no mention of what
            #   should be done with any tags inside the construction
            # The spec doesn't even specify what should be done with a newline character (for
            #   example, should it reset ->mxpLineMode, or not?)
            # Because of this, and because of they way Axmud handles incoming text token by token,
            #   Axmud handles a <DEST> tag as a special kind kind of <FRAME name REDIRECT> tag. Text
            #   is redirected into the specified frame until the </DEST> tag is processed, at which
            #   time text is redirected into whichever frame was used before the <DEST> tag
            # (NB In actual fact, if there are no newline characters inside the <DEST>...</DEST>
            #   construction, any text tokens inside won't be displayed until just before the
            #   </DEST> tag is processed; it's only if the construction contains newline tokens that
            #   the tokens might not be displayed all at the same time)
            # To keep things simple, a <FRAME> tag abnormally terminates the <DEST>...</DEST>
            #   construction

            # Store the frame being used at the moment, so it can be restored when the </DEST> tag
            #   is processed
            $destObj->ivPoke('mxpPrevFrame', $self->mxpPrevFrame);
            $destObj->ivPoke('mxpCurrentFrame', $self->mxpCurrentFrame);
            # Redirect text to the new frame
            $self->ivPoke('mxpPrevFrame', $self->mxpCurrentFrame);
            $self->ivPoke('mxpCurrentFrame', $frameObj->name);
            $self->ivPoke('currentTabObj', $frameObj->tabObj);

            # Put the textview(s) into overwrite mode (we do this here, rather than when the frame
            #   is created, so that if the world uses <DEST> tags in the session's current tab,
            #   overwrite mode is applied to that tab, too)
            $frameObj->tabObj->textViewObj->enableOverwrite();
            # If cursor control was specified, apply it now
            if (defined $destObj->xPos || defined $destObj->yPos) {

                $frameObj->tabObj->textViewObj->setInsertPosn($destObj->yPos, $destObj->xPos);
            }

            # There are no Axmud colour/style tags to return
            return @emptyList;

        # Process the <\DEST> tag
        } else {

            # Watch out for a <DEST> ... </DEST> ... </DEST> construction
            if (! $self->mxpCurrentDest) {

                # This a second </DEST> tag after an earlier </DEST> tag
                $self->mxpDebug(
                    $origToken,
                    '</DEST> tag does not match earlier <DEST> tag',
                    2815,
                );

                return @emptyList;

            } else {

                $destObj = $self->mxpCurrentDest;
                $self->ivUndef('mxpCurrentDest');
            }

            # Restore the insert position in the current tab's textview object to that which was
            #   in use before the opening <DEST> tag
            if (defined $destObj->xPos || defined $destObj->yPos) {

                $textViewObj = $self->currentTabObj->textViewObj;
                $mark = $textViewObj->insertMark;
                $textViewObj->resetInsertPosn();

                # Empty the buffer at the point immediately after the last part of the text from
                #   inside the <DEST>...</DEST> construction was displayed
                # (If both EOF and EOL were specified, just apply EOF)
                if ($destObj->eofFlag) {
                    $textViewObj->clearBufferAfterMark($mark);
                } elsif ($destObj->eolFlag) {
                    $textViewObj->clearLineAfterMark($mark);
                }
            }

            # Go back to using the frame that was in use before the <DEST> tag was processed, first
            #   making sure it still exists
            $frameObj = $self->ivShow('mxpFrameHash', $destObj->mxpCurrentFrame);
            if (! $frameObj) {

                $self->mxpDebug(
                    $origToken,
                    'Missing frame \'' . $frameObj->name . ' after </DEST> tag processed',
                    2816,
                );

                # (Use the original frame as an emergency default)
                $frameObj = $self->ivShow('mxpFrameHash', '_top');
            }

            $self->ivPoke('mxpPrevFrame', $destObj->mxpPrevFrame);
            $self->ivPoke('mxpCurrentFrame', $destObj->mxpCurrentFrame);
            $self->ivPoke('currentTabObj', $frameObj->tabObj);

            # There are no Axmud colour/style tags to return
            return @emptyList;
        }
    }

    sub processMxpLinkElement {

        # Called by $self->processMxpElement or ->popMxpStack
        #
        # Process an MXP link element: <A>...</A>
        # NB GA::Obj::Link objects are used to store both <A>..</A> and <SEND>..</SEND>
        #   constructions
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #   $noPopFlag  - Set to TRUE when called by $self->popMxpStack, in which case we don't
        #                   need to call ->popMxpStack again. Set to FALSE otherwise
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, $noPopFlag, @argList) = @_;

        # Local variables
        my (
            $textViewObj, $line, $offset, $linkObj, $type, $stackObj,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (
            ! defined $origToken || ! defined $tagMode || ! defined $keyword
            || ! defined $noPopFlag
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpLinkElement', @_);
            return @emptyList;
        }

        # <A href=URL [hint=text] [expire=name]>Text</A>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
            || $tagMode eq 'defn'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 2901);

            return @emptyList;
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # Process the <A> tag
        if ($tagMode eq 'open') {

            # Watch out for an <A> ... <A> ... </A> construction
            if ($self->mxpCurrentLink) {

                # This a second <A> tag after an earlier </A> tag. Remove the earlier link object,
                #   and show an error (ignoring both tags)
                $self->ivUndef('mxpCurrentLink');
                $self->mxpDebug($origToken, '<A> tag after earlier unclosed <A> tag', 2902);

                return @emptyList;

            # Also watch out for a <SEND> ... <A> ... </SEND> construction
            } elsif ($self->mxpCurrentSend) {

                $self->ivUndef('mxpCurrentSend');
                $self->mxpDebug($origToken, '<A> tag after earlier unclosed <SEND> tag', 2903);

                return @emptyList;
            }

            # Process @argList
            @origList = @checkList = ('href', 'hint', 'expire');
            # Hash of argument names which don't take a corresponding value
            %checkHash = ();
            # Default argument values
            %ivHash = (
                'href'      => undef,
                'hint'      => undef,
                'expire'    => undef,
            );

            if (@argList) {

                do {

                    my ($argName, $argValue) = $self->findMxpArgsByPosn(
                        \@origList,
                        \@checkList,
                        \%ivHash,
                        \%checkHash,
                        shift @argList,     # not 'undef'
                        shift @argList,     # might be 'undef'
                    );

                    if (! defined $argName) {

                        # Unrecognised argument name, or repeating argument name
                        $self->mxpDebug($origToken, 'Malformed element', 2904);

                        return @emptyList;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check the validity of the compulsory 'href=URL' argument
            if (! $ivHash{'href'}) {

                $self->mxpDebug($origToken, 'Malformed element', 2911);

                return @emptyList;
            }

            if (
                index (lc($ivHash{'href'}), 'http://') > -1
                || index (lc($ivHash{'href'}), 'https://') > -1
            ) {
                $type = 'www';
            } elsif (index (lc($ivHash{'href'}), 'telnet://') > -1) {
                $type = 'telnet';
            } elsif (index (lc($ivHash{'href'}), 'mailto://') > -1) {
                $type = 'mail';
            } else {

                $self->mxpDebug($origToken, 'Invalid URL', 2912);

                return @emptyList;
            }

            # We can't process the link until the closing </A> tag is found, so store arguments
            #   temporarily in a GA::Obj::Link object
            ($line, $offset) = $textViewObj->getInsertPosn();
            if (! defined $line) {

                $self->mxpDebug($origToken, 'Internal error while processing link', 2913);

                return @emptyList;

            } else {

                # Take account of any lines removed from the textview's Gtk3::TextBuffer
                $line += $textViewObj->nextDeleteLine;
            }

            # The position of the link will be the textview's current insert position, plus the
            #   length of any already-processed text, that hasn't been displayed in the textview
            #   object yet, which has been stored for us in ->processStripLine
            # (The -1 argument means this is an incomplete link object, not yet applied to the
            #   current textview and not yet stored in the textview object's registries)
            if ($self->processOrigLine ne '') {

                $offset += length($self->processStripLine);
            }

            $linkObj = Games::Axmud::Obj::Link->new(
                -1,
                $textViewObj,
                $line,
                $offset,
                $type,
            );

            if (! $linkObj) {

                $self->mxpDebug($origToken, 'Internal error while processing link', 2914);

                return @emptyList;
            }

            # Set the link object's IVs
            $linkObj->ivPoke('href', $ivHash{'href'});          # Compulsory arg, never 'undef'
            $linkObj->ivPoke('hint', $ivHash{'hint'});          # May be 'undef'
            $linkObj->ivPoke('expireName', $ivHash{'expire'});  # May be 'undef'
            $linkObj->ivPoke('mxpFlag', TRUE);

            # Store the current link object (the link isn't applied to the textview until the
            #   corresponding </A> tag is processed, although any text inside the <A>...</A> is
            #   displayed immediately, as normal)
            $self->ivPoke('mxpCurrentLink', $linkObj);

            # Also need to create a new MXP stack object and store it in the current textview
            #   object, updating the latter's IVs
            if (! $textViewObj->createMxpStackObj($self, 'A')) {

                $self->mxpDebug($origToken, 'Internal error while processing element', 2915);

                return @emptyList;

            } else {

                # Operation complete
                return @emptyList;
            }

        # Process the <\A> tag
        } else {

            # Watch out for an <A> ... </A> ... </A> construction
            if (! $self->mxpCurrentLink) {

                # This a second </A> tag after an earlier </A> tag
                $self->mxpDebug($origToken, '</A> tag does not match earlier <A> tag', 2916);

                return @emptyList;

            } else {

                $linkObj = $self->mxpCurrentLink;
                $self->ivUndef('mxpCurrentLink');
            }

            if (! $linkObj->text) {

                # There were no valid text tokens between the <A>...</A> tags
                $self->mxpDebug($origToken, 'Invalid <A>...</A> construction', 2917);

                return @emptyList;

            } else {

                # The incomplete link object is now complete, but we can't apply the link to the
                #   current textview yet, as there may be some text before the link which hasn't
                #   been displayed yet
                # Instead, store it temporarily in an IV, and let $self->processIncomingData call
                #   GA::Obj::TextView->add_incompleteLink as soon as it's ready
                $self->ivPush('mxpTempLinkList', $linkObj);
            }

            # Close the <A>..</A> construction, if the calling function isn't doing that (the TRUE
            #   argument means don't call this function back, as you normally would)
            if (! $noPopFlag) {

               return $self->popMxpStack($keyword, TRUE);

            } else {

                # Operation complete
                return @emptyList;
            }
        }
    }

    sub processMxpSendElement {

        # Called by $self->processMxpElement or ->popMxpStack
        #
        # Process an MXP send element: <SEND>...</SEND>
        # NB GA::Obj::Link objects are used to store both <A>..</A> and <SEND>..</SEND>
        #   constructions
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #   $noPopFlag  - Set to TRUE when called by $self->popMxpStack, in which case we don't
        #                   need to call ->popMxpStack again. Set to FALSE otherwise
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, $noPopFlag, @argList) = @_;

        # Local variables
        my (
            $textViewObj, $line, $offset, $linkObj, $stackObj, $href, $hint, $text,
            @emptyList, @origList, @checkList, @cmdList, @optionList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (
            ! defined $origToken || ! defined $tagMode || ! defined $keyword
            || ! defined $noPopFlag
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpSendElement', @_);
            return @emptyList;
        }

        # <SEND [href=command] [hint=text] [prompt] [expire=name]>Text</SEND>
        if (
            ($tagMode eq 'close' && @argList)
            || $tagMode eq 'defn'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3001);

            return @emptyList;
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # Process the <SEND> tag
        if ($tagMode eq 'open') {

            # Watch out for a <SEND> ... <SEND> ... </SEND> construction
            if ($self->mxpCurrentSend) {

                # This a second <SEND> tag after an earlier </SEND> tag. Remove the earlier link
                #   object, and show an error (ignoring both tags)
                $self->ivUndef('mxpCurrentSend');
                $self->mxpDebug($origToken, '<SEND> tag after earlier unclosed <SEND> tag', 3002);

                return @emptyList;

            # Also watch out for an <A> ... <SEND> ... </A> construction
            } elsif ($self->mxpCurrentLink) {

                $self->ivUndef('mxpCurrentSend');
                $self->mxpDebug($origToken, '<A> tag after earlier unclosed <SEND> tag', 3003);

                return @emptyList;
            }

            # Process @argList

            # List of argument names, in the expected order
            @origList = @checkList = ('href', 'hint', 'prompt', 'expire');
            # Hash of argument names which don't take a corresponding value
            %checkHash = (
                'prompt'    => undef,
            );
            # Default argument values
            %ivHash = (
                'href'      => undef,
                'hint'      => undef,
                'prompt'    => FALSE,
                'expire'    => undef,
            );

            if (@argList) {

                do {

                    my ($argName, $argValue) = $self->findMxpArgsByPosn(
                        \@origList,
                        \@checkList,
                        \%ivHash,
                        \%checkHash,
                        shift @argList,     # not 'undef'
                        shift @argList,     # might be 'undef'
                    );

                    if (! defined $argName) {

                        # Unrecognised argument name, or repeating argument name
                        $self->mxpDebug($origToken, 'Malformed element', 3011);

                        return @emptyList;

                    } elsif ($argName eq 'prompt') {

                        $ivHash{$argName} = TRUE;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # We can't process the link until the closing </SEND> tag is found, so store arguments
            #   temporarily in a GA::Obj::Link object
            ($line, $offset) = $textViewObj->getInsertPosn();
            if (! defined $line) {

                $self->mxpDebug($origToken, 'Internal error while processing send', 3012);

                return @emptyList;

            } else {

                # Take account of any lines removed from the textview's Gtk3::TextBuffer
                $line += $textViewObj->nextDeleteLine;
            }

            # The position of the link will be the textview's current insert position, plus the
            #   length of any already-processed text, that hasn't been displayed in the textview
            #   object yet, which has been stored for us in ->processStripLine
            # (The -1 argument means this is an incomplete link object, not yet applied to the
            #   current textview and not yet stored in the textview object's registries)
            if ($self->processOrigLine ne '') {

                $offset += length($self->processStripLine);
            }

            $linkObj = Games::Axmud::Obj::Link->new(
                -1,
                $textViewObj,
                $line,
                $offset,
                'cmd',
            );

            if (! $linkObj) {

                $self->mxpDebug($origToken, 'Internal error while processing send', 3013);

                return @emptyList;
            }

            # Set the link object's IVs
            $linkObj->ivPoke('href', $ivHash{'href'});              # May be 'undef'
            $linkObj->ivPoke('hint', $ivHash{'hint'});              # May be 'undef'
            $linkObj->ivPoke('mxpPromptFlag', $ivHash{'prompt'});   # Never 'undef'
            $linkObj->ivPoke('expireName', $ivHash{'expire'});      # May be 'undef'
            $linkObj->ivPoke('mxpFlag', TRUE);

            $href = $linkObj->href;
            $hint = $linkObj->hint;
            if (
                $href
                && (! $hint || $href =~ m/.\|./ || $hint =~ m/.\|./)
            ) {
                # When the link is clicked, a popup menu should be displayed. Set IVs to prepare
                #   for that
                $linkObj->ivPoke('popupFlag', TRUE);

                @cmdList = split('\|', $href);
                if ($hint) {

                    @optionList = split('\|', $hint);

                    if (((scalar @cmdList) + 1) == (scalar @optionList)) {

                        # The first item in @optionList is the hint; the rest are menu items
                        $linkObj->ivPoke('hint', shift @optionList);

                    } elsif ((scalar @cmdList) > 1 && (scalar @optionList) == 1) {

                        # The only item in @optionList must be a hint, as there are more commands
                        #   than hints
                        $linkObj->ivPoke('hint', shift @optionList);

                    } elsif ((scalar @cmdList) != (scalar @optionList)) {

                        $self->mxpDebug($origToken, 'Invalid SEND menu option format', 3014);

                        return @emptyList;

                    } else {

                        $linkObj->ivPoke('hint', undef);
                    }

                } else {

                    $linkObj->ivPoke('hint', undef);
                }

                $linkObj->ivPoke('popupCmdList', @cmdList);
                $linkObj->ivPoke('popupItemList', @optionList);
            }

            # Store the current link object (the link isn't applied to the textview until the
            #   corresponding </SEND> tag is processed, although any text inside the
            #   <SEND>...</SEND> is displayed immediately, as normal)
            $self->ivPoke('mxpCurrentSend', $linkObj);

            # Also need to create a new MXP stack object and store it in the current textview
            #   object, updating the latter's IVs
            if (! $textViewObj->createMxpStackObj($self, 'SEND')) {

                $self->mxpDebug($origToken, 'Internal error while processing element', 3015);

                return @emptyList;

            } else {

                # Operation complete
                return @emptyList;
            }

        # Process the <\SEND> tag
        } else {

            # Watch out for a <SEND> ... </SEND> ... </SEND> construction
            if (! $self->mxpCurrentSend) {

                # This a second </SEND> tag after an earlier </SEND> tag
                $self->mxpDebug($origToken, '</SEND> tag does not match earlier <SEND> tag', 3021);

                return @emptyList;

            } else {

                $linkObj = $self->mxpCurrentSend;
                $self->ivUndef('mxpCurrentSend');
            }

            if (! $linkObj->text && $linkObj->type ne 'image') {

                # There were no valid text tokens (or clickable images) between the <SEND>...</SEND>
                #   tags
                $self->mxpDebug($origToken, 'Invalid <SEND>...</SEND> construction', 3022);

                return @emptyList;

            } else {

                # The incomplete link object is now complete, but we can't apply the link to the
                #   current textview yet, as there may be some text before the link which hasn't
                #   been displayed yet
                # Instead, store it temporarily in an IV, and let $self->processIncomingData call
                #   GA::Obj::TextView->add_incompleteLink as soon as it's ready
                $self->ivPush('mxpTempLinkList', $linkObj);

                # Process any entities. The MXP documentation documents the use of &text; but some
                #   MUDs might choose to use any entity in their <SEND>...</SEND> constructions
                # Don't process entities for clickable images
                $href = $linkObj->href;
                $text = $linkObj->text;
                if ($href && $text && $linkObj->type ne 'image') {

                    $href =~ s/\&text\;/$text/g;
                    $href = $self->replaceMxpEntities($href);

                    $linkObj->ivPoke('href', $href);
                }

                # Do the same, if a popup menu is to be created
                foreach my $item ($linkObj->popupCmdList) {

                    $item =~ s/\&text\;/$text/g;
                    $item = $self->replaceMxpEntities($item);

                    push (@cmdList, $item);
                }

                foreach my $item ($linkObj->popupItemList) {

                    $item =~ s/\&text\;/$text/g;
                    $item = $self->replaceMxpEntities($item);

                    push (@optionList, $item);
                }

                $linkObj->ivPoke('popupCmdList', @cmdList);
                $linkObj->ivPoke('popupItemList', @optionList);
            }

            # Close the <SEND>...</SEND> construction, if the calling function isn't doing that (the
            #   TRUE function means don't call this function back, as you normally would)
            if (! $noPopFlag) {

                return $self->popMxpStack($keyword, TRUE);

            } else {

                # Operation complete
                return @emptyList;
            }
        }
    }

    sub processMxpSoundElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP sound element: <SOUND> or <MUSIC>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $token,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpSoundElement', @_);
            return @emptyList;
        }

        # <SOUND FName V=volume L=repeats P=priority C=continue T=type U=url>
        # <MUSIC FName V=volume L=repeats P=priority C=continue T=type U=url>
        # <SOUND Off>
        # <SOUND Off U=default_url>
        # <MUSIC Off>
        # <MUSIC Off U=default_url>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3101);

            return @emptyList;
        }

        # Ignore this tag if the client flag is set
        if (! $axmud::CLIENT->allowMxpSoundFlag) {

            return @emptyList;
        }

        # The @argList should be identical to the one used in an MSP sound token
        # Instead of processing the @argList twice, once here and again in
        #   $self->processMspSoundTrigger, we'll just create a fake MSP sound token and pass it
        #   straight to $self->processMspSoundTrigger
        if ($keyword eq 'SOUND') {
            $token = '!!SOUND(';
        } else {
            $token = '!!MUSIC(';
        }

        # @argList is now in the form
        #   (FName, <undef>, param_name, param_value, param_name, param_value...)
        $token .= shift @argList;
        shift @argList;

        if (@argList) {

            do {
                my $name = shift @argList;
                my $value = shift @argList;

                $token .= ' ' . $name . '=' . $value;
            } until (! @argList);
        }

        $token .= ')';

        # Use MSP code to deal with playing the sound. (The TRUE argument means that this is an
        #   MXP sound, not an MSP one, and that MXP file filters can be used)
        if (! $self->processMspSoundTrigger($token, TRUE)) {

            $self->mxpDebug($origToken, 'Malformed element', 3102);
        }

        # (No Axmud colour/style tags involved)
        return @emptyList;
    }

    sub processMxpImageElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP image element: <IMAGE>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $textViewObj, $url, $urlRegex, $height, $width, $align, $path, $file, $dir, $ext,
            $convertFlag, $fetchObj, $pixbuf, $pixbuf2, $pbw, $pbh, $horiz, $vert, $hFactor,
            $vFactor, $moveX, $moveY, $rgba, $padWidth, $padHeight, $charWidth, $charHeight,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpImageElement', @_);
            return @emptyList;
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # The calling function may have processed some text tokens which haven't been displayed yet;
        #   if so, display them now so that they're displayed either side of the image
        if ($self->processStripLine) {

            $self->respondIncomingData(
                'part',
                $self->processOrigLine,
                $self->processStripLine,
                $self->processTagHash,
            );
        }

        # <IMAGE FName URL=url T=type H=height W=width HSPACE=hspace VSPACE=vspace
        #       ALIGN=left|right|top|middle|bottom ISMAP>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3201);

            return @emptyList;
        }

        # Ignore this tag if the client flag is set
        if (! $axmud::CLIENT->allowMxpImageFlag) {

            return @emptyList;
        }

        # Process @argList

        # List of argument names, in the expected order
        @origList = @checkList = (
            'fname', 'url', 't', 'h', 'w', 'hspace', 'vspace', 'align', 'ismap',
        );

        # Hash of argument names which don't take a corresponding value
        %checkHash = (
            'url'       => undef,
            'hspace'    => undef,
            'vspace'    => undef,
            'ismap'     => undef,
        );
        # Default argument values
        %ivHash = (
            'fname'     => undef,
            'url'       => undef,
            't'         => undef,
            'h'         => undef,
            'w'         => undef,
            'hspace'    => undef,
            'vspace'    => undef,
            'align'     => undef,       # (Cannot be implemented in Gtk3 - text tags broken)
            'ismap'     => FALSE,
        );

        # The default padding size (when required) is the size of a character
        ($charWidth, $charHeight) = $textViewObj->getCharSize();

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 3202);

                    return @emptyList;

                } elsif ($argName eq 'hspace' || $argName eq 'vspace') {

                    # It's not clear from the documentation, whether hspace and vspace should take
                    #   an argument value, or not. Cover both eventualities; if no argument value
                    #   is specified, use a default one
                    if (defined $argValue) {
                        $ivHash{$argName} = $argValue;
                    } elsif ($argName eq 'hspace') {
                        $ivHash{$argName} = $charWidth;
                    } else {
                        $ivHash{$argName} = $charHeight;
                    }

                } elsif ($argName eq 'ismap') {

                    $ivHash{$argName} = TRUE;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # The fname argument is compulsory
        if (! $ivHash{'fname'}) {

            $self->mxpDebug($origToken, 'Malformed element', 3203);

            return @emptyList;
        }

        # Check any specified argument values are valid. In some cases, give up; in other cases,
        #   use default values
        $url = $ivHash{'url'};
        $urlRegex = $axmud::CLIENT->constUrlRegex;
        if (defined $url) {

            if (! ($url =~ m/$urlRegex/)) {

                return @emptyList;

            } else {

                # File::Fetch doesn't support https:// addresses, and it does not seem likely that
                #   the module will ever be updated
                # Therefore, substitute in http:// and hope for the best
                $url =~ s/^https\:/http\:/;
            }
        }

        # The height and width attributes can be in the form of integers (size in pixels), in the
        #   form of 'nc' (n character heights) or in the form 'n%' (percentage of the available
        #   space)
        # If specified, convert these values into a size in pixels
        $height = $ivHash{'h'};
        if (defined $height && $height =~ m/^\d+[\%c]?/) {

            $height = $self->convertMxpImageSize($height, 'height');
        }

        $width = $ivHash{'w'};
        if (defined $width && $width =~ m/^\d+[\%c]?/) {

            $width = $self->convertMxpImageSize($width, 'width');
        }

        # Now do the same for the hspace and vspace attributes
        $padHeight = $ivHash{'vspace'};
        if (defined $padHeight && $padHeight =~ m/^\d+[\%c]?/) {

            $padHeight = $self->convertMxpImageSize($padHeight, 'height');
        }

        $padWidth = $ivHash{'hspace'};
        if (defined $padWidth && $padWidth =~ m/^\d+[\%c]?/) {

            $padWidth = $self->convertMxpImageSize($padWidth, 'width');
        }

        # (Cannot be implemented in Gtk3 - text tags broken)
#        $align = $ivHash{'align'};
#        if (defined $align) {
#
#            $align = lc($align);
#            if (
#                $align ne 'left'
#                && $align ne 'right'
#                && $align ne 'top'
#                && $align ne 'middle'
#                && $align ne 'bottom'
#            ) {
#                $align = undef;
#            }
#        }

        # Convert FName into a full filepath (must a relative filepath, pointing at a directory used
        #   to store images for the current world)
        if ($ivHash{'t'}) {

            $path = $axmud::DATA_DIR . '/mxp/' . $self->currentWorld->name
                        . '/' . $ivHash{'t'} . '/' . $ivHash{'fname'};

        } else {

            $path = $axmud::DATA_DIR . '/mxp/' . $self->currentWorld->name . '/' . $ivHash{'fname'};
        }

        # If this file actually exists, use it. Otherwise, try downloading it from the specified
        #   URL (if any)
        if (! -e $path) {

            # If a partial URL was not specified, or if we're not allowed to download, then there's
            #   no image to display
            # NB The MXP spec doesn't specify wildcards, as the MSP spec does, so wildcards are not
            #   implemented here
            if (! $url || ! $axmud::CLIENT->allowMxpLoadImageFlag) {

                return @emptyList;

            } else {

                # Compile the full URL from which to download
                if (substr($url, -1, 1) ne '/') {

                    $url .= '/';
                }

                $url .= $ivHash{'fname'};

                # Attempt to download the file
                ($file, $dir) = File::Basename::fileparse($path);
                $fetchObj = File::Fetch->new(uri => $url);
                if (! $fetchObj->fetch(to => $dir)) {

                    # Download error - display nothing
                    return @emptyList;
                }
            }
        }

        # For image files, Axmud only supports file extensions in GA::Client->constMxpFormatHash
        ($file, $dir, $ext) = File::Basename::fileparse($path, qr/\.[^.]*/);
        if (! $ext) {

            # Ignore any files with no extension at all
            return @emptyList;
        }

        $ext =~ s/^\.//;
        if (! $axmud::CLIENT->ivExists('constMxpFormatHash', $ext)) {

            # For any other file extension, apply the MXP filter (if the world has specified one)
            $path = $self->applyMxpFileFilter($path);
            if (! $path) {

                # No MXP file filter supplied, or file conversion failed
                return @emptyList;
            }

            # Check the file format of the converted file is supported
            ($file, $dir, $ext) = File::Basename::fileparse($path, qr/\.[^.]*/);
            $ext =~ s/^\.//;
            if (! $axmud::CLIENT->ivExists('constMxpFormatHash', $ext)) {

                # File format not supported
                return @emptyList;

            } else {

                $convertFlag = TRUE;
            }
        }

        # Convert the image file to a pixbuf
        if ($path =~ m/\.gif$/) {

            # Unfortunately, .gif images cannot be easily scaled, so ignore the width and height
            #   arguments
            $pixbuf = Gtk3::Gdk::PixbufAnimation->new_from_file($path);

        } elsif (! defined $width || ! defined $height) {

            $pixbuf = Gtk3::Gdk::Pixbuf->new_from_file($path);
            if ($pixbuf) {

                $width = $pixbuf->get_width();
                $height = $pixbuf->get_height();
            }

        } else {

            $pixbuf = Gtk3::Gdk::Pixbuf->new_from_file_at_scale(
                $path,
                $width,
                $height,
                FALSE,                          # Don't preserve aspect ratio
            );
        }

        if ($pixbuf) {

            # Handle clickable images. Ignore ISMAP if the <IMAGE> tag doesn't appear between
            #   <SEND>...</SEND>, or if anything else (such as some text) appears between
            #   <SEND>...</SEND>
            if (
                $ivHash{'ismap'}
                && $self->mxpCurrentSend
                && ! $self->mxpCurrentSend->text
            ) {
                $textViewObj->showImage(
                    $pixbuf,
                    $self->mxpCurrentSend,
                    $padWidth,
                    $padHeight,
                    'echo',
                );

                # Update the GA::Obj::Link object, setting its type to 'image' so that
                #   $self->processMxpSendElement knows not to complain that there's no clickable
                #   text
                $self->mxpCurrentSend->ivPoke('type', 'image');
                # The world command should be sent invisibly
                $self->mxpCurrentSend->ivPoke('mxpInvisFlag', TRUE);
                # For <SEND showmap><IMAGE map.jpg ISMAP></SEND>, ->href is currently set to
                #   'showmap'. The MXP spec requires that we append the position of the click to the
                #   world command sent. GA::Obj::TextView->showImage substitutes %x and %y for the
                #   click coordinates
                $self->mxpCurrentSend->ivPoke(
                    'href',
                    $self->mxpCurrentSend->href . '?%x,%y',
                );

            # Non-clickable images
            } else {

                $textViewObj->showImage($pixbuf, undef, $padWidth, $padHeight, 'echo');
            }

            # $self->processImageLine must be updated for all processed images
            $self->ivPoke('processImageLine', $self->processImageLine . '[' . $file . ']');
        }

        # Delete any converted file (leaving the original in place)
        if ($convertFlag) {

            unlink $path;
        }

        # (No Axmud colour/style tags involved)
        return @emptyList;
    }

    sub processMxpFilterElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP file filter element: <FILTER>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $filterObj,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpFilterElement', @_);
            return @emptyList;
        }

        # <FILTER SRC=ext DEST=ext NAME=plugin PROC=num>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3301);

            return @emptyList;
        }

        # Ignore this tag if the client flag is set
        if (! $axmud::CLIENT->allowMxpFilterImageFlag) {

            return @emptyList;
        }

        # Process @argList

        # List of argument names, in the expected order
        @origList = @checkList = ('src', 'dest', 'name', 'proc');
        # Hash of argument names which don't take a corresponding value
        %checkHash = ();
        # Default argument values
        %ivHash = (
            'src'     => undef,
            'dest'    => 'bmp',
            'name'    => undef,
            'proc'    => 0,
        );

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 3302);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # All arguments except PROC=num are compulsory
        if (! $ivHash{'src'} || ! $ivHash{'dest'} || ! $ivHash{'name'}) {

            $self->mxpDebug($origToken, 'Malformed element', 3311);

            return @emptyList;
        }

        # MXP supports .gif and .bmp images, so ignore any attempt to create a file filter for
        #   them
        if (lc($ivHash{'src'}) eq 'gif' || lc($ivHash{'src'}) eq 'bmp') {

            return @emptyList;
        }

        # Create a GA::Mxp::Filter to store the details until they're required
        $filterObj = Games::Axmud::Mxp::Filter->new(
            $self,
            $ivHash{'src'},
            $ivHash{'dest'},
            $ivHash{'name'},
            $ivHash{'proc'},
        );

        # Update the registry. If (for some reason) the world has already specified a file filter
        #   with the same source file extention, replace it with this one
        if ($filterObj) {

            $self->ivAdd('mxpFilterHash', $filterObj->src, $filterObj);
        }

        # (No Axmud colour/style tags involved)
        return @emptyList;
    }

    sub processMxpGaugeElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP gauge element: <GAUGE>, <STAT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments or if there is no GA::Strip::GaugeBox in this
        #       sessiOn's 'main' window in which to draw gauges
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $entityObj, $maxEntityObj, $stripObj, $level, $gaugeObj, $fullCol, $emptyCol, $maxValue,
            $label, $labelCol, $labelFlag,
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpGaugeElement', @_);
            return @emptyList;
        }

        # <GAUGE EntityName [Max=EntityName] [Caption=text] [Color=colour]>
        # <STAT EntityName [Max=EntityName] [Caption=text]>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3401);

            return @emptyList;
        }

        # Ignore these tags if the client flag is set
        if (! $axmud::CLIENT->allowMxpGaugeFlag) {

            return @emptyList;
        }

        # Process @argList
        if ($keyword eq 'GAUGE') {

            @origList = @checkList = ('name', 'max', 'caption', 'color');
            # Hash of argument names which don't take a corresponding value
            %checkHash = (
                'name'      => undef,
            );
            # Default argument values
            %ivHash = (
                'name'      => undef,
                'max'       => undef,
                'caption'   => undef,
                'color'     => undef,
            );

        } else {

            @origList = @checkList = ('name', 'max', 'caption');
            # Hash of argument names which don't take a corresponding value
            %checkHash = (
                'name'      => undef,
            );
            # Default argument values
            %ivHash = (
                'name'      => undef,
                'max'       => undef,
                'caption'   => undef,
            );
        }

        if (@argList) {

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 3402);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Check the validity of the compulsory 'EntityName' argument
        if (! $ivHash{'name'}) {

            $self->mxpDebug($origToken, 'Malformed element', 3411);

            return @emptyList;
        }

        # Check that the specified entities exist. Discworld sends <STAT> tags for entities that
        #   don't exist yet, so create an entity using a fake <!ENTITY> tag as well as displaying an
        #   error
        $entityObj = $self->ivShow('mxpEntityHash', $ivHash{'name'});
        if (! $entityObj) {

            $self->processMxpEntElement(
                '<!ENTITY ' . $ivHash{'name'} . ' 0>',
                'defn',
                'EN',
                'Name',
                $ivHash{'name'},
                'Value',
                0,
            );

            # (Check the entity was created)
            $entityObj = $self->ivShow('mxpEntityHash', $ivHash{'name'});
            if (! $entityObj) {

                # (This was the original error message, when Axmud didn't accept <GAUGE>/<STAT> tags
                #   with non-existent entities)
                $self->mxpDebug($origToken, 'Entity not found', 3412);

                return @emptyList;
            }
        }

        if ($ivHash{'max'}) {

            $maxEntityObj = $self->ivShow('mxpEntityHash', $ivHash{'max'});
            if (! $maxEntityObj) {

                $self->processMxpEntElement(
                    '<!ENTITY ' . $ivHash{'max'} . ' 0>',
                    'defn',
                    'EN',
                    'Name',
                    $ivHash{'max'},
                    'Value',
                    0,
                );

                $maxEntityObj = $self->ivShow('mxpEntityHash', $ivHash{'max'});
                if (! $maxEntityObj) {

                    $self->mxpDebug($origToken, 'Entity not found', 3413);

                    return @emptyList;
                }
            }
        }

        # Only one gauge per entity is allowed. Ignore repeat requests
        if ($self->ivExists('mxpGaugeHash', $ivHash{'name'})) {

            return @emptyList;
        }

        # Find the GA::Strip::GaugeBox object for this session's 'main' window
        if (! $self->mxpGaugeStripObj) {

            $stripObj = $self->mainWin->ivShow(
                'firstStripHash',
                'Games::Axmud::Strip::GaugeBox',
            );

            if (! $stripObj) {

                return @emptyList;

            } else {

                $self->ivPoke('mxpGaugeStripObj', $stripObj);
            }
        }

        # All gauges created by MXP are drawn on their own gauge level
        if (! defined $self->mxpGaugeLevel) {

            $level = $self->mxpGaugeStripObj->addGaugeLevel($self);
            if (! defined $level) {

                # Error, or gauge box is full up, so draw nothing
                return @emptyList;

            } else {

                $self->ivPoke('mxpGaugeLevel', $level);
            }

        } else {

            $level = $self->mxpGaugeLevel;
        }

        # If specified, convert the colour to RGB (otherwise, the gauge uses default colours)
        if ($ivHash{'color'}) {

            ($fullCol) = $self->convertMxpColour($ivHash{'color'}, FALSE);
            if ($fullCol) {

                $emptyCol = '#000000';
            }
        }

        # Set the maximum value
        # For graphical gauges, when no entity provides a maximum value, the gauge is drawn full
        # For text gauges, when no entity provides a maximum value, then only a single value is
        #   displayed (i.e. 'xp: 0' rather than 'xp: 0/?'). This is accomplished by leaving
        #   $maxValue undefined, a situation that GA::Strip::GaugeBox->drawGauges looks out for
        if ($maxEntityObj) {

            $maxValue = $maxEntityObj->value;

        } elsif ($keyword eq 'GAUGE') {

            # Gauge is always full
            $maxValue = $entityObj->value;
        }

        # Set the gauge label to use. If none was specified, the call to ->addGauge needs 'undef'
        #   arguments
        if ($ivHash{'caption'}) {

            $label = $ivHash{'caption'};
            $labelFlag = TRUE;

            # GA::Client->constHtmlContrastHash provides a list of label colours that fit well with
            #   standard HTML 4 colours. If $fullCol is one of those, consult that hash. Otherwise
            #   use white as the label colour - not perfect, but at least it can be seen over the
            #   black empty portion of the gauge
            if ($fullCol) {

                $labelCol = $axmud::CLIENT->ivShow('constHtmlContrastHash', $ivHash{'color'});
            }

            if (! $labelCol) {

                $labelCol = '#FFFFFF';
            }
        }

        # Draw the gauge
        if ($keyword eq 'GAUGE') {

            $gaugeObj = $self->mxpGaugeStripObj->addGauge(
                $self,
                $level,
                $entityObj->value,
                $maxValue,
                FALSE,                  # Total size is $maxValue
                $label,
                $fullCol,
                $emptyCol,
                $labelCol,
                $labelFlag,
            );

        } else {

            $gaugeObj = $self->mxpGaugeStripObj->addTextGauge(
                $self,
                $level,
                $entityObj->value,
                $maxValue,
                FALSE,                  # Total size is $maxValue
                $label,
            );
        }

        if ($gaugeObj) {

            # Add this GA::Obj::Gauge object to a registry, so that other parts of the code know
            #   that the gauge must be redrawn when either entity's value is updated
            $self->ivAdd('mxpGaugeHash', $entityObj->name, $gaugeObj);
            $gaugeObj->ivPoke('mxpEntity', $ivHash{'name'});

            if ($maxEntityObj) {

                $self->ivAdd('mxpGaugeHash', $maxEntityObj->name, $gaugeObj);
                $gaugeObj->ivPoke('mxpMaxEntity', $ivHash{'max'});
            }
        }

        return @emptyList;
    }

    sub processMxpCrosslinkElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP crosslink element: <RELOCATE>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            @emptyList, @origList, @checkList,
            %checkHash, %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpCrosslinkElement', @_);
            return @emptyList;
        }

        # <RELOCATE hostname port>
        # </RELOCATE>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
            || $tagMode eq 'defn'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3501);

            return @emptyList;
        }

        # Ignore this tag if the client flag is set
        if (! $axmud::CLIENT->allowMxpCrosslinkFlag) {

            return @emptyList;
        }

        if ($tagMode eq 'open') {

            # Process @argList
            @origList = @checkList = ('hostname', 'port');
            # Hash of argument names which don't take a corresponding value
            %checkHash = ();
            # Default argument values
            %ivHash = (
                'hostname'  => undef,
                'port'      => undef,
            );

            do {

                my ($argName, $argValue) = $self->findMxpArgsByPosn(
                    \@origList,
                    \@checkList,
                    \%ivHash,
                    \%checkHash,
                    shift @argList,     # not 'undef'
                    shift @argList,     # might be 'undef'
                );

                if (! defined $argName) {

                    # Unrecognised argument name, or repeating argument name
                    $self->mxpDebug($origToken, 'Malformed element', 3502);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);

            # We'll let the hostname be anything, but the port should at least be a valid integer
            #   in the usual range
            if (
                ! $ivHash{'hostname'}
                || ! defined $ivHash{'port'}
                || ! $axmud::CLIENT->floatCheck($ivHash{'port'}, 0, 65535)
            ) {
                $self->mxpDebug($origToken, 'Invalid relocation hostname and/or port', 3511);

                return @emptyList;
            }

            # Don't allow a crosslink operation if one is already in progress, or if a delayed
            #   quit has been set up
            if ($self->mxpRelocateMode eq 'none' && ! defined $self->delayedQuitTime) {

                # The crosslinking process will start on the next incoming data loop (allowing the
                #   server to send a <QUIET> tag right after this one
                $self->ivPoke('mxpRelocateMode', 'wait_start');
                $self->ivPoke('mxpRelocateHost', $ivHash{'hostname'});
                $self->ivPoke('mxpRelocatePort', $ivHash{'port'});
            }

        } else {

            # Mark the character as logged in, if it isn't already (this sets
            #   $self->mxpRelocateMode to 'none', which terminates the crosslinking operation)
            $self->doLogin();
        }

        return @emptyList;
    }

    sub processMxpLoginElement {

        # Called by $self->processMxpElement
        #
        # Process an MXP login element: <USER>, <PASSWORD>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my @emptyList;

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpLoginElement', @_);
            return @emptyList;
        }

        # <USER>
        # <PASSWORD>
        if (
            ($tagMode eq 'open' && @argList)
            || $tagMode ne 'open'
        ) {
            $self->mxpDebug($origToken, 'Malformed element', 3601);

            return @emptyList;
        }

        # If the current character's password was not specified by the call to $self->new, don't use
        #   either tag (even if the user has subsequently typed ';setchar', which may make the
        #   correct password available. This makes sure that, if the user opted to prevent
        #   auto-logins, an automatic login won't happen as a response to these tags)
        if ($self->initChar && $self->initPass) {

            if ($keyword eq 'USER') {

                $self->send($self->initChar);
                $self->ivPoke('mxpLoginMode', 'user_tag');

            } elsif ($keyword eq 'PASSWORD') {

                $self->send($self->initPass);

                if ($self->mxpLoginMode eq 'user_tag') {

                    $self->ivPoke('mxpLoginMode', 'pwd_tag');
                    # During an MXP crosslinking operation, wait for the </RELOCATE> tag to mark
                    #   the character as 'logged in'
                    if ($self->mxpRelocateMode eq 'none') {

                        $self->doLogin();
                    }
                }
            }
        }

        return @emptyList;
    }

    sub processMxpOfficialElement {

        # Called by $self->processMxpElement
        #
        # Process an official MXP element, not covered by other functions (<EXPIRE>, <VERSION> and
        #   <QUIET>)
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $argName, $argValue, $string, $termTypeMode, $customClientName, $customClientVersion,
            @emptyList, @tagList, @textViewList,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper(
                $self->_objClass . '->processMxpOfficialElement',
                @_,
            );

            return @emptyList;
        }

        # <EXPIRE [Name]>
        # <VERSION [styleversion]>
        # <QUIET>
        if ($tagMode ne 'open') {

            $self->mxpDebug($origToken, 'Malformed element', 3701);

            return @emptyList;
        }


        # <EXPIRE [Name]>
        if ($keyword eq 'EXPIRE') {

            $argName = shift @argList;
            $argValue = shift @argList;     # Must be 'undef'

            if (@argList || defined $argValue) {

                $self->mxpDebug($origToken, 'Malformed element', 3711);

                return @emptyList;
            }

            # Compile a list of textview objects used by MXP frames. If $self->mxpFrameHash is
            #   empty (because no frames have been added), use the default tab's textview which
            #   would be added to that hash with the name of '_top')
            if (! $self->mxpFrameHash) {

                push (@textViewList, $self->defaultTabObj->textViewObj);

            } else {

                foreach my $frameObj ($self->ivValues('mxpFrameHash')) {

                    push (@textViewList, $frameObj->textViewObj);
                }
            }

            # Check all GA::Obj::Link objects, and expire any MXP links which match this expiry
            #   name (or if $name was not supplied, any MXP links that match any expiry name, but
            #   not those which have no expire name at all)
            foreach my $textViewObj (@textViewList) {

                foreach my $linkObj ($textViewObj->ivValues('linkObjHash')) {

                    if (
                        $linkObj->mxpFlag
                        && $linkObj->expireName
                        && (! $argName || $argName eq $linkObj->expireName)
                    ) {
                        $linkObj->ivPoke('expiredFlag', TRUE);

                        if (
                            $textViewObj->currentLinkObj
                            && $textViewObj->currentLinkObj eq $linkObj
                        ) {
                            # Hide tooltips, reset the cursor, close any 'dialogue' windows
                            $textViewObj->resetCurrentLink();
                        }
                    }
                }
            }

        # <VERSION [styleversion]>
        } elsif ($keyword eq 'VERSION') {

            $argName = shift @argList;
            $argValue = shift @argList;     # Must be 'undef'

            if (@argList || defined $argValue) {

                $self->mxpDebug($origToken, 'Malformed element', 3721);

                return @emptyList;
            }

            if (defined $argName) {

                # MXP can send a style sheet number, indicating "the current version of the optional
                #   style sheet" which seems to be something to do with the subset of elements and
                #   entities currently in use. The MXP spec only requires us to store this number,
                #   and to return it in response to a VERSION tag
                $self->ivPoke('mxpStyleSheetNum', $argName);

            } else {

                # Return version information to the world, in the form
                #   <VERSION MXP=mxpversion STYLE=styleversion CLIENT=clientname
                #   VERSION=clientversion REGISTERED=yes/no>
                $string = '<VERSION';
                $string .= ' MXP=' . $axmud::CLIENT->constMxpVersion;

                if (defined $self->mxpStyleSheetNum) {

                    $string .= ' STYLE=' . $self->mxpStyleSheetNum;
                }

                # (Use the same rules about disclosing this client's identity, as are used in
                #   TTYPE negotiations)
                if ($self->currentWorld->ivExists('termOverrideHash', 'termTypeMode')) {
                    $termTypeMode = $self->currentWorld->ivShow('termOverrideHash', 'termTypeMode');
                } else {
                    $termTypeMode = $axmud::CLIENT->termTypeMode;
                }

                if ($self->currentWorld->ivExists('termOverrideHash', 'customClientName')) {

                    $customClientName
                        = $self->currentWorld->ivShow('termOverrideHash', 'customClientName');

                } else {

                    $customClientName = $axmud::CLIENT->customClientName;
                }

                if ($self->currentWorld->ivExists('termOverrideHash', 'customClientVersion')) {

                    $customClientVersion
                        = $self->currentWorld->ivShow('termOverrideHash', 'customClientVersion');

                } else {

                    $customClientVersion = $axmud::CLIENT->customClientVersion;
                }

                if ($termTypeMode eq 'send_client') {

                    $string .= ' CLIENT=' . $axmud::NAME_SHORT;

                } elsif ($termTypeMode eq 'send_client_version') {

                    $string .= ' CLIENT=' . $axmud::NAME_SHORT;
                    $string .= ' VERSION=' . $axmud::VERSION;

                } elsif ($termTypeMode eq 'send_custom_client') {

                    if ($customClientName) {

                        $string .= ' CLIENT=' . $customClientName;

                        if ($customClientVersion) {

                            $string .= ' VERSION=' . $customClientVersion;
                        }
                    }
                }

                # (All version of Axmud are registered ;) )
                $string .= ' REGISTERED=YES>';

                $self->optSendMxpSecure($string);
            }

        # <QUIET>
        } elsif ($keyword eq 'QUIET') {

            if (@argList) {

                $self->mxpDebug($origToken, 'Malformed element', 3731);

                return @emptyList;

            # Ignore <QUIET> tags outside of <RELOCATE>...</RELOCATE> constructions
            } elsif ($self->mxpRelocateMode ne 'none') {

                $self->ivPoke('mxpRelocateQuietFlag', TRUE);
            }

        } else {

            $self->mxpDebug($origToken, 'Internal error while processing element', 3741);

            return @emptyList;
        }

        return @tagList,
    }

    sub processMxpCustomElement {

        # Called by $self->processMxpElement
        #
        # Process a custom MXP element, created by an earlier <!ELEMENT> tag
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements, 'defn' for <!..>
        #                   elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $elementObj, $attCount, $recogniseFlag,
            @emptyList, @tagList, @miniTagList,
            %attHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpCustomElement', @_);
            return @emptyList;
        }

        if (($tagMode eq 'close' && @argList) || $tagMode eq 'defn') {

            $self->mxpDebug($origToken, 'Malformed element', 3801);

            return @emptyList;
        }

        # Get the stored element object
        $elementObj = $self->ivShow('mxpElementHash', lc($keyword));

        # <some_element>
        if ($tagMode eq 'open') {

            # We need to check that </some_element> we receive in the future is matched by an
            #   earlier <some_element> tag (i.e. the tag we're processing now), so we create a new
            #   MXP stack object for this purpose
            # (If the element has a definition (e.g. '<COLOR red><B>'), the definition is used as a
            #   macro expansion for the element, and stack objects are also created for those tags)

            # Create a new MXP stack object and store it in the current textview object,
            #   updating the latter's IVs
            if (! $self->currentTabObj->textViewObj->createMxpStackObj($self, $keyword)) {

                $self->mxpDebug($origToken, 'Internal error while processing element', 3811);

                return @emptyList;
            }

            # If the custom element defines any tag properties, start storing text between the
            #   opening tag we're processing now, and the matching closing tag we haven't processed
            #   yet
            # (The hash is updated with every call to $self->updateTextToken)
            if (
                $elementObj->flagArg
                && ! $self->ivExists('mxpFlagTextHash', $elementObj->flagArg)
            ) {
                $self->ivAdd('mxpFlagTextHash', $elementObj->flagArg, '');
            }

            # Deal with supplied attributes, e.g. in elements like these:
            #   <colorbold col=blue>This text is in bold blue</colortext>
            #   <colorbold blue>This text is in bold blue</colortext>
            # First, import this element's hash of attributes and their default values
            %attHash = $elementObj->attHash;
            # Next, modify the hash, using any attributes values supplied by this tag
            if (@argList) {

                $attCount = -1;

                do {

                    my ($attName, $attValue, $thisAtt);

                    $attName = shift @argList;
                    $attValue = shift @argList;

                    # e.g. <colorbold col=blue>
                    if (defined $attValue) {

                        if (! exists $attHash{$attName}) {

                            $self->mxpDebug(
                                $origToken,
                                'Element specified unrecognised attribute \''
                                . $attName . '\'',
                                3812,
                            );

                        } else {

                            # Use the specified value, instead of the default value
                            $attHash{$attName} = $attValue;
                        }

                    # e.g. <colorbold blue>
                    } else {

                        # We expect that this will set the attribute in $elementObj->attList, whose
                        #   index is $attCount
                        $attCount++;
                        $thisAtt = $elementObj->ivIndex('attList', $attCount);
                        if (! defined $thisAtt) {

                            $self->mxpDebug($origToken, 'Attribute \'' . $attName . '\'', 3813);

                        } else {

                            # Here, $attName is actually an attribute value, not an attribute name
                            $attHash{$thisAtt} = $attName;
                        }
                    }

                } until (! @argList);
            }

            # The macro expansion is stored as a list of MXP tags in $elementObj->defnList
            # Process each one in turn by calling ->processMxpElement recursively;
            #   GA::Obj::TextView->mxpModalStackHash will be modified by them
            foreach my $thisTag ($elementObj->defnList) {

                # Mode 1 - Only allow simple atomic elements like <B> (so don't allow <!ELEMENT>
                #   for example), and don't allow closing elements like </B>
                # NB ->processMxpElement returns a flag indicating whether this tag should be
                #   treated as MXP, or ordinary text, but this function can ignore it
                ($recogniseFlag, @miniTagList)
                    = $self->processMxpElement($thisTag, undef, 'simple', %attHash);
                push (@tagList, @miniTagList);
            }

        # </some_element>
        } else {

            # Remove the corresponding stack object from the stack; any tags in the macro expansion
            #   will also get popped, returning text attributes to their previous state
            return $self->popMxpStack($keyword);
        }

        return @tagList;
    }

    # (Process Pueblo tokens)

    sub processPuebloColourElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo font element: <COLOR>...</COLOUR>
        # (NB Specified at http://www.gammon.com.au/forum/?id=281, but not specified in the spec for
        #   Pueblo 2.50)
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            @emptyList, @tagList,
            %ivHash, %stackHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloColourElement', @_);
            return @emptyList;
        }

        # <COLOR [FORE=foreground] [BACK=background]>
        # </COLOR>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
        ) {
            $self->puebloDebug($origToken, 'Malformed element', 6701);

            return @emptyList;

        # <COLOR [FORE=foreground] [BACK=background]>
        } elsif ($tagMode eq 'open') {

            # Process @argList

            # Default argument values
            %ivHash = (
                'fore'      => undef,
                'back'      => undef,
            );

            if (@argList) {

                do {

                    my $argName = lc(shift @argList);
                    my $argValue = shift @argList;

                    if (! defined $argName || ! exists $ivHash{$argName}) {

                        # Unrecognised argument name
                        $self->puebloDebug($origToken, 'Malformed element', 6711);

                        return @emptyList;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check any specified argument values are valid. In some cases, give up; in other cases,
            #   use default values
            if (defined $ivHash{'fore'} && ! ($ivHash{'fore'} =~ m/^#[A-Fa-f0-9]{6}$/)) {

                # Invalid foreground colour
                $self->puebloDebug($origToken, 'Invalid foreground colour', 6721);

                return @emptyList;

            } elsif (defined $ivHash{'back'} && ! ($ivHash{'back'} =~ m/^#[A-Fa-f0-9]{6}$/)) {

                # Invalid background colour
                $self->puebloDebug($origToken, 'Invalid background colour', 6722);

                return @emptyList;
            }

            # Prepare the colour, using a hash containing a subset of key-value pairs from
            #   GA::Obj::TextView->mxpModalStackHash, and containing only changes specified by
            #   this Pueblo tag
            if ($ivHash{'fore'}) {

                $stackHash{'colour_foreground'} = $ivHash{'fore'};
            }

            if ($ivHash{'back'}) {

                $stackHash{'colour_background'} = $ivHash{'back'};
            }

            # Create a new MXP stack object and store it in the current textview object, updating
            #   the latter's IVs
            if (
                ! $self->currentTabObj->textViewObj->createMxpStackObj($self, $keyword, %stackHash)
            ) {
                $self->puebloDebug($origToken, 'Internal error while processing element', 6723);

                return @emptyList;

            } else {

                # Operation complete
                return @tagList;
            }

        # </COLOR>
        } else {

            # Cancel the MXP text attribute
            return $self->popMxpStack($keyword);
        }
    }

    sub processPuebloFontElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo font element: <FONT>...</FONT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $token, $sign, $num,
            @emptyList, @tagList,
            %ivHash, %stackHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloFontElement', @_);
            return @emptyList;
        }

        # <FONT [SIZE=[+|-]size] [FGCOLOR=foreground] [COLOR=foreground] [TEXT=foreground]
        #   [BGCOLOR=background]>
        # </FONT>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
        ) {
            $self->puebloDebug($origToken, 'Malformed element', 6801);

            return @emptyList;

        # <FONT [SIZE=[+|-]size] [FGCOLOR=foreground] [COLOR=foreground] [TEXT=foreground]
        #   [BGCOLOR=background]>
        } elsif ($tagMode eq 'open') {

            # Process @argList

            # Default argument values
            %ivHash = (
                'size'      => $self->puebloBaseFontSize,
                'fgcolor'   => undef,
                'color'     => undef,
                'text'      => undef,
                'bgcolor'   => undef,
            );

            if (@argList) {

                do {

                    my $argName = lc(shift @argList);
                    my $argValue = shift @argList;

                    if (! defined $argName || ! exists $ivHash{$argName}) {

                        # Unrecognised argument name
                        $self->puebloDebug($origToken, 'Malformed element', 6811);

                        return @emptyList;

                    } elsif ($argName eq 'color' || $argName eq 'text') {

                        $ivHash{'fgcolor'} = $argValue;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check any specified argument values are valid. In some cases, give up; in other cases,
            #   use default values
            if ($ivHash{'size'} =~ m/^([\+\-]?)([1-7])$/) {

                $sign = $1;
                $num = $2;

                if ($sign) {

                    if ($sign eq '+') {
                        $ivHash{'size'} = $ivHash{'size'} + $num;
                    } else {
                        $ivHash{'size'} = $ivHash{'size'} - $num;
                    }

                    # Permitted size range is 1-7
                    if ($ivHash{'size'} < 1) {
                        $ivHash{'size'} = 1;
                    } elsif ($ivHash{'size'} > 7) {
                        $ivHash{'size'} = 7;
                    }
                }

            } else {

                # Invalid font size
                $self->puebloDebug($origToken, 'Invalid font size', 6821);
            }

            if (defined $ivHash{'fgcolor'} && ! ($ivHash{'fgcolor'} =~ m/^#[A-Fa-f0-9]{6}$/)) {

                # Invalid foreground colour
                $self->puebloDebug($origToken, 'Invalid foreground colour', 6822);

                return @emptyList;

            } elsif (defined $ivHash{'bgcolor'} && ! ($ivHash{'bgcolor'} =~ m/^#[A-Fa-f0-9]{6}$/)) {

                # Invalid background colour
                $self->puebloDebug($origToken, 'Invalid background colour', 6823);

                return @emptyList;
            }

            # Prepare the font, using a hash containing a subset of key-value pairs from
            #   GA::Obj::TextView->mxpModalStackHash, and containing only changes specified by this
            #   Pueblo tag
            # Use similar sizes/spacings used by GA::Client->constHeadingSizeHash and
            #   ->constHeadingSpacingHash. I don't know which sizes web browsers actually use
            if ($ivHash{'size'} == 1) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 0.67;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize * 1.67;

            } elsif ($ivHash{'size'} == 2) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 0.83;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize * 1.67;

            } elsif ($ivHash{'size'} == 3) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize * 1.33;

            } elsif ($ivHash{'size'} == 4) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 1.17;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize;

            } elsif ($ivHash{'size'} == 5) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 1.33;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize;

            } elsif ($ivHash{'size'} == 6) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 1.5;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize * 0.83;

            } elsif ($ivHash{'size'} == 7) {

                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize * 2;
                $stackHash{'spacing'} = $axmud::CLIENT->constFontSize * 0.67;
            }

            if ($ivHash{'fgcolor'}) {

                $stackHash{'colour_foreground'} = $ivHash{'fgcolor'};
            }

            if ($ivHash{'bgcolor'}) {

                $stackHash{'colour_background'} = $ivHash{'bgcolor'};
            }

            # Create a dummy style tag that $self->applyColouStyleTags can interpret
            #   e.g. 'mxpf_monospace_bold_12'
            push (@tagList, $self->createMxpFontTag(%stackHash));

            # Create a new MXP stack object and store it in the current textview object, updating
            #   the latter's IVs
            if (
                ! $self->currentTabObj->textViewObj->createMxpStackObj($self, $keyword, %stackHash)
            ) {
                $self->puebloDebug($origToken, 'Internal error while processing element', 6824);

                return @emptyList;

            } else {

                # Operation complete
                return @tagList;
            }

        # </FONT>
        } else {

            # Cancel the MXP text attribute
            return $self->popMxpStack($keyword);
        }
    }

    sub processPuebloBaseFontElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo base font element: <BASEFONT>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            @emptyList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloBaseFontElement', @_);
            return @emptyList;
        }

        # <BASEFONT [SIZE=[+|-]size]>
        if (
            ($tagMode eq 'open' && ! @argList)
            || $tagMode eq 'close'
        ) {
            $self->puebloDebug($origToken, 'Malformed element', 6901);

            return @emptyList;
        }

        # <BASEFONT [SIZE=[+|-]size]>

        # Process @argList

        # Default argument values
        %ivHash = (
            'size'      => $self->puebloBaseFontSize,
        );

        if (@argList) {

            do {

                my $argName = lc(shift @argList);
                my $argValue = shift @argList;

                if (! defined $argName || ! exists $ivHash{$argName}) {

                    # Unrecognised argument name
                    $self->puebloDebug($origToken, 'Malformed element', 6911);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Check any specified argument values are valid. In some cases, give up; in other cases,
        #   use default values
        if (! $ivHash{'size'} =~ m/^[1-7]$/) {

            # Invalid font size
            $self->puebloDebug($origToken, 'Invalid base font size', 6921);

        } else {

            # Update IVs
            $self->ivPoke('puebloBaseFontSize', $ivHash{'size'});

            return @emptyList;
        }
    }

    sub processPuebloLinkElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo font element: <A>...</A>
        # Partially implemented. <A HREF=...> and <A XCH_CMD=...> are implemented, but (for example)
        #   <A NAME=...> is not
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #   $noPopFlag  - Set to TRUE when called by $self->popMxpStack, in which case we don't
        #                   need to call ->popMxpStack again. Set to FALSE otherwise
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, $noPopupFlag, @argList) = @_;

        # Local variables
        my (
            $textViewObj, $type, $offset, $line, $linkObj,
            @emptyList,
            %ivHash,
        );

        # Check for improper arguments
        if (
            ! defined $origToken || ! defined $tagMode || ! defined $keyword
            || ! defined $noPopupFlag
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloLinkElement', @_);
            return @emptyList;
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # <A [HREF=href] [XCH_CMD=command] [XCH_HINT=hint]
        # </A>
        if (
            ($tagMode eq 'open' && ! @argList)
            || ($tagMode eq 'close' && @argList)
        ) {
            $self->puebloDebug($origToken, 'Malformed element', 7001);

            return @emptyList;

        # <A [HREF=href] [XCH_CMD=command] [XCH_HINT=hint]
        } elsif ($tagMode eq 'open') {

            # Process @argList

            # Default argument values
            %ivHash = (
                'href'      => undef,
                'name'      => undef,   # not implemented
                'title'     => undef,   # not implemented
                'rel'       => undef,   # not implemented
                'rev'       => undef,   # not implemented
                'urn'       => undef,   # not implemented
                'methods'   => undef,   # not implemented
                'xch_cmd'   => undef,
                'xch_hint'  => undef,
            );

            if (@argList) {

                do {

                    my $argName = lc(shift @argList);
                    my $argValue = shift @argList;

                    if (! defined $argName || ! exists $ivHash{$argName}) {

                        # Unrecognised argument name
                        $self->puebloDebug($origToken, 'Malformed element', 7011);

                        return @emptyList;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check any specified argument values are valid. In some cases, give up; in other cases,
            #   use default values
            if (
                (! $ivHash{'href'} && ! $ivHash{'xch_cmd'})
                || ($ivHash{'href'} && $ivHash{'xch_cmd'})
            ) {
                # Missing or multiple hyperlinks
                $self->puebloDebug($origToken, 'Malformed element', 7021);
            }

            # Pueblo <A HREF=...> is implemented like MXP <A>. Pueblo <A XCH_CMD=...> is implemented
            #   like MXP <SEND>.
            if ($ivHash{'href'}) {

                # The // appears to be optional in Pueblo
                if (lc($ivHash{'href'}) =~ m/telnet\:/) {
                    $type = 'telnet';
                } elsif (lc($ivHash{'href'}) =~ m/mailto\:/) {
                    $type = 'mail';
                } else {

                    # Otherwise assume it's a hyperlink in the form '[http[s]://]website.com'
                    $type = 'www';
                }

            } else {

                $type = 'cmd';
            }

            # We can't process the link until the closing </A> tag is found, so store arguments
            #   temporarily in a GA::Obj::Link object
            ($line, $offset) = $textViewObj->getInsertPosn();
            if (! defined $line) {

                $self->puebloDebug($origToken, 'Internal error while processing hyperlink', 7022);

                return @emptyList;
            }

            # The position of the link will be the textview's current insert position, plus the
            #   length of any already-processed text, that hasn't been displayed in the textview
            #   object yet, which has been stored for us in ->processStripLine
            # (The -1 argument means this is an incomplete link object, not yet applied to the
            #   current textview and not yet stored in the textview object's registries)
            if ($self->processOrigLine ne '') {

                $offset += length($self->processStripLine);
            }

            $linkObj = Games::Axmud::Obj::Link->new(
                -1,
                $textViewObj,
                $line,
                $offset,
                $type,
            );

            if (! $linkObj) {

                $self->puebloDebug($origToken, 'Internal error while processing hyperlink', 7023);

                return @emptyList;
            }

            # Set the link object's IVs
            if ($ivHash{'href'}) {

                $linkObj->ivPoke('href', $ivHash{'href'});
                $linkObj->ivPoke('puebloMode', 'href_link');

            } else {

                $linkObj->ivPoke('href', $ivHash{'xch_cmd'});
                $linkObj->ivPoke('hint', $ivHash{'xch_hint'});
                $linkObj->ivPoke('puebloMode', 'cmd_link');
            }

            # Store the current link object (the link isn't applied to the textview until the
            #   corresponding </A> tag is processed, although any text inside the <A>...</A> is
            #   displayed immediately, as normal)
            $self->ivPoke('mxpCurrentLink', $linkObj);

            # Also need to create a new MXP stack object and store it in the current textview
            #   object, updating the latter's IVs
            if (! $textViewObj->createMxpStackObj($self, 'A')) {

                $self->puebloDebug($origToken, 'Internal error while processing element', 7024);

                return @emptyList;

            } else {

                # Operation complete
                return @emptyList;
            }

        # </A>
        } elsif ($tagMode eq 'close') {

            # Watch out for an <A> ... </A> ... </A> construction
            if (! $self->mxpCurrentLink) {

#                # This a second </A> tag after an earlier </A> tag
#                $self->puebloDebug($origToken, '</A> tag does not match earlier <A> tag', 7031);

                # For some reason I can't explain, many Pueblo-enhanced worlds are spamming </A>
                #   tags; so rather than display an error message, simply ignore the tag

                return @emptyList;

            } else {

                $linkObj = $self->mxpCurrentLink;
                $self->ivUndef('mxpCurrentLink');
            }

            if (! $linkObj->text) {

#                # There were no valid text tokens between the <A>...</A> tags
#                $self->puebloDebug($origToken, 'Invalid <A>...</A> construction', 7032);

                # Same complaint as just above; ignore this tag, too

                return @emptyList;

            } else {

                # The incomplete link object is now complete, but we can't apply the link to the
                #   current textview yet, as there may be some text before the link which hasn't
                #   been displayed yet
                # Instead, store it temporarily in an IV, and let $self->processIncomingData call
                #   GA::Obj::TextView->add_incompleteLink as soon as it's ready
                $self->ivPush('mxpTempLinkList', $linkObj);
            }

            # Close the <A>..</A> construction, if the calling function isn't doing that (the TRUE
            #   argument means don't call this function back, as you normally would)
            if (! $noPopupFlag) {

               return $self->popMxpStack($keyword, TRUE);

            } else {

                # Operation complete
                return @emptyList;
            }
        }
    }

    sub processPuebloListElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo list element: <UL>...</UL>, <OL>...</OL>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $start, $type, $listObj,
            @emptyList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloListElement', @_);
            return @emptyList;
        }

        # <UL [TYPE=disc|circle|square]>
        # </UL>
        # <OL [START=number] [TYPE=A|a|I|i|1]>
        # </OL>
        if ($tagMode eq 'close' && @argList) {

            $self->puebloDebug($origToken, 'Malformed element', 7101);

            return @emptyList;

        # <UL [TYPE=disc|circle|square]>
        # <OL [START=number] [TYPE=A|a|I|i|1]>
        } elsif ($tagMode eq 'open') {

            # Process @argList

            # Default argument values
            if ($keyword eq 'UL') {

                %ivHash = (
                    'type'      => 'disc',
                );

            } else {

                %ivHash = (
                    'compact'   => undef,       # not implemented
                    'start'     => 1,
                    'type'      => 1,
                );
            }

            if (@argList) {

                do {

                    my $argName = lc(shift @argList);
                    my $argValue = shift @argList;

                    if (! defined $argName || ! exists $ivHash{$argName}) {

                        # Unrecognised argument name
                        $self->puebloDebug($origToken, 'Malformed element', 7111);

                        return @emptyList;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check any specified argument values are valid. In some cases, give up; in other cases,
            #   use default values
            $start = $ivHash{'start'};
            # Assume that the specified number must be a positive integer
            if (defined $start && ! $axmud::CLIENT->intCheck($start, 1)) {

                # Invalid start number
                $self->puebloDebug($origToken, 'Invalid start number in list', 7121);

                return @emptyList;
            }

            $type = $ivHash{'type'};
            if (
                defined $type
                && (
                    (
                        $keyword eq 'UL' && $type ne 'disc' && $type ne 'circle'
                        && $type ne 'square'
                    ) || (
                        $keyword eq 'OL' && $type ne 'A' && $type ne 'a' && $type ne 'I'
                        && $type ne 'i' && $type ne '1'
                    )
                )
            ) {
                # Invalid type
                $self->puebloDebug($origToken, 'Invalid type in list', 7122);

                return @emptyList;
            }

            # Create an object to store details from the list
            $listObj = Games::Axmud::Pueblo::List->new($self, lc($keyword));
            if (! $listObj) {

                $self->puebloDebug($origToken, 'Internal error while processing element', 7123);

                return @emptyList;
            }

            # Add it to a stack of such objects (we don't use $self->mxpModalStackList, as we do for
            #   some similar tags)
            $self->ivPush('puebloStackList', $listObj);

            # Update remaining IVs
            if ($keyword eq 'UL') {

                $listObj->ivPoke('bulletType', $type);

            } else {

                $listObj->ivPoke('itemCount', $start);
                $listObj->ivPoke('itemType', $type);
            }

            # Operation complete
            return @emptyList;

        # </UL>
        # </OL>
        } else {

            # (If the stack is empty, do nothing)
            if ($self->puebloStackList) {

                $self->ivShift('puebloStackList');

                # Force a line break by inserting an artificial newline token
                $self->ivUnshift('currentTokenList', 'go', undef);
            }

            # Operation complete
            return @emptyList;
        }
    }

    sub processPuebloListItemElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo list item element: <LI>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $listObj, $listType, $bulletType, $value, $string, $bullet,
            @emptyList, @stackList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloListItemElement', @_);
            return @emptyList;
        }

        # <LI [type=disc|circle|square|A|a|I|i|1>
        if ($tagMode eq 'close') {

            $self->puebloDebug($origToken, 'Malformed element', 7201);

            return @emptyList;
        }

        # <LI>

        # Process @argList

        # Default argument values
        %ivHash = (
            'type'      => undef,
            'value'     => undef,
        );

        if (@argList) {

            do {

                my $argName = lc(shift @argList);
                my $argValue = shift @argList;

                if (! defined $argName || ! exists $ivHash{$argName}) {

                    # Unrecognised argument name
                    $self->puebloDebug($origToken, 'Malformed element', 7211);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Retrieve the currently processed list object GA::Pueblo::List
        if (! $self->puebloStackList) {

            # The stack is empty, so this list item occurs outside an unordered/ordered list.
            #   Behave as though it were inside an unordered list
            $listType = 'ul';

        } else {

            @stackList = $self->puebloStackList;
            $listObj = shift @stackList;
            $listType = $listObj->listType;
        }

        # Check any specified argument values are valid. In some cases, give up; in other cases,
        #   use default values
        if (defined $ivHash{'type'}) {

            # <LI> attribute overrules the bullet type specified by the earlier <UL>/<OL> tag
            $bulletType = $ivHash{'type'};

        } elsif ($listObj) {

            if ($listType eq 'ul') {
                $bulletType = $listObj->bulletType;
            } else {
                $bulletType = $listObj->itemType;
            }

        } else {

            $bulletType = 'disc';
        }

        if ($listType eq 'ul') {

            if ($bulletType ne 'disc' && $bulletType ne 'circle' && $bulletType ne 'square') {

                # Default
                $bulletType = 'disc';
            }

        } else {

            if (
                $bulletType ne 'A' && $bulletType ne 'a' && $bulletType ne 'I' && $bulletType ne 'i'
                && $bulletType ne '1'
            ) {
                # Default
                $bulletType = '1';
            }
        }

        $value = $ivHash{'value'};
        if (! defined $value || ! $axmud::CLIENT->intCheck($value, 1)) {

            $value = $listObj->itemCount;
        }

        $listObj->ivPoke('itemCount', $value + 1);

        # If the <LI> tag appears outside a <UL>...</UL> or <OL>...</OL> construction, its text is
        #   displayed with no gap on the left
        $string = ' ' x $self->puebloColumnSize;            # Default - 3 characters
        # Set the bullet character to use, and update IVs as appropriate
        if ($bulletType eq 'disc' || $bulletType eq 'circle') {

            $bullet = chr(149) . ' ';

        } elsif ($bulletType eq 'square') {

            $bullet = chr(164) . ' ';

        } else {

            # Numbered (or lettered) bullet type
            if ($bulletType eq '1') {

                $bullet = $value . '. ';

            } elsif ($bulletType eq 'A' || $bulletType eq 'a') {

                # After 26 letters, go back to A
                $value = $value % 26;
                if ($bulletType eq 'A') {
                    $bullet = chr($value + 64) . '. ';
                } else {
                    $bullet = chr($value + 96) . '. ';
                }

            } else {

                # Roman numerals, for some reason. After 4000, go back to 1
                $value = $value % 4000;
                if ($bulletType eq 'I') {
                    $bullet = $axmud::CLIENT->convertRoman($value) . '. ';
                } else {
                    $bullet = lc($axmud::CLIENT->convertRoman($value)) . '. ';
                }
            }
        }

        # Artificially insert some tokens into the list being processed by
        #   $self->processIncomingData
        $self->ivUnshift(
            'currentTokenList',
                'go',
                undef,
                'text',
                $string x scalar ($self->puebloStackList) . $bullet,
        );

        # Operation complete
        return @emptyList;
    }

    sub processPuebloJustifyElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo text justification element: <CENTER>...</CENTER>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my @emptyList;

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloJustifyElement', @_);
            return @emptyList;
        }

        # <CENTER>
        # </CENTER>
        if (@argList) {

            $self->puebloDebug($origToken, 'Malformed element', 7301);

            return @emptyList;

        # <CENTER>
        } elsif ($tagMode eq 'open') {

            # The Axmud style tag is applied to the current line
            return 'justify_centre';

        # </CENTER>
        } else {

            # The Axmud style tag 'justify_default' must be applied to the next line
            $self->ivPoke('puebloJustifyMode', 'wait_newline');

            return @emptyList;
        }
    }

    sub processPuebloLiteralElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo literal text element: <CODE>...</CODE>, <PRE>...</PRE>, <SAMP>...</SAMP>
        # NB <CODE> and <PRE> are implemented identically by Axmud. <SAMP> uses the same font as
        #   <CODE> and <PRE>, but doesn't cause Axmud to ignore line breaks/reduce whitespace
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            @emptyList, @tagList,
            %stackHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloLiteralElement', @_);
            return @emptyList;
        }

        # <CODE>
        # </CODE>
        # <PRE>
        # </PRE>
        # <SAMP>
        # </SAMP>
        if ($keyword eq 'CODE' && @argList) {

            # (Ignore attributes in <PRE> tags)
            $self->puebloDebug($origToken, 'Malformed element', 7401);

            return @emptyList;
        }

        if ($keyword eq 'CODE' || $keyword eq 'PRE' || $keyword eq 'SAMP') {

            # <CODE>
            # <PRE>
            # <SAMP>
            if ($tagMode eq 'open') {

                # In successive <CODE>...<CODE> tags, the second <CODE> tag is treated as though
                #   it were preceded by a closing <CODE> tag
                # (Same applies to <PRE>, but not <SAMP>
                if ($keyword eq 'SAMP' && ! $self->puebloLiteralSampFlag) {

                    $self->ivPoke('puebloLiteralSampFlag', TRUE);

                } elsif ($keyword ne 'SAMP' && ! $self->puebloLiteralFlag) {

                    $self->ivPoke('puebloLiteralFlag', TRUE);
                }

                # Prepare a monospaced font
                $stackHash{'font_name'} = $axmud::CLIENT->constFont;
                $stackHash{'font_size'} = $axmud::CLIENT->constFontSize;
                $stackHash{'bold_flag'} = FALSE;

                # Create a dummy style tag that GA::Obj::TextView->applyColourStyleTags can
                #   interpret, e.g. 'mxpf_monospace_bold_12'
                push (@tagList, $self->createMxpFontTag(%stackHash));

                # Create a new MXP stack object and store it in the current textview object,
                #   updating the latter's IVs
                if (
                    ! $self->currentTabObj->textViewObj->createMxpStackObj(
                        $self,
                        $keyword,
                        %stackHash,
                    )
                ) {
                    $self->puebloDebug($origToken, 'Internal error while processing element', 7411);

                    return @emptyList;
                }

            } else {

                # </CODE>
                # </PRE>
                # </SAMP>
                if ($keyword eq 'SAMP') {

                    if (! $self->puebloLiteralSampFlag) {

                        # An invalid </SAMP>...</SAMP> construction
                        $self->puebloDebug(
                            $origToken,
                            '</SAMP> tag after earlier closing </SAMP> tag',
                            7421,
                        );

                        return @emptyList;
                    }

                    # End of <SAMP> block
                    $self->ivPoke('puebloLiteralSampFlag', FALSE);

                } else {

                    if (! $self->puebloLiteralFlag) {

                        # An invalid </CODE>...</CODE> or <PRE>...</PRE> construction
                        $self->puebloDebug(
                            $origToken,
                            '</CODE> or </PRE> tag after earlier closing </CODE> or </PRE> tag',
                            7422,
                        );

                        return @emptyList;
                    }

                    # End of <CODE> or <PRE> block
                    $self->ivPoke('puebloLiteralFlag', FALSE);
                }

                # Restore the previous font
                return $self->popMxpStack($keyword);
            }
        }

        return @tagList;
    }

    sub processPuebloImageElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo image element: <IMG>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $tag, $href, $file, $dir, $ext,
            @emptyList, @attList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloImageElement', @_);
            return @emptyList;
        }

        # <IMG [ISMAP] [SRC|HREF] [WIDTH=width] [HEIGHT=height] VSPACE HSPACE>
        # <IMG XCH_MODE=text|html|purehtml>
        # <IMG XCH_SOUND=play|loop|stop|stoploop HREF=url EVENT=complete MD5=md5 ACTION=action
        #   OPTIONS=queue>
        if (($tagMode eq 'open' && ! @argList) || $tagMode eq 'close') {

            $self->puebloDebug($origToken, 'Malformed element', 7501);

            return @emptyList;
        }

        # Process @argList

        # Default argument values
        %ivHash = (
            'align'     => undef,       # not implemented
            'ismap'     => FALSE,
            'src'       => undef,
            'href'      => undef,
            'border'    => undef,       # not implemented
            'width'     => undef,
            'height'    => undef,
            'vspace'    => FALSE,
            'hspace'    => FALSE,
            'alt'       => undef,       # not implemented
            'lowsrc'    => undef,       # not implemented
            'xch_mode'  => undef,
            'xch_sound' => undef,       # only xch_sound=play implemented
            'complete'  => undef,       # not implemented
            'md5'       => undef,       # not implemented
            'action'    => undef,       # not implemented
            'options'   => undef,       # not implemented
        );

        if (@argList) {

            do {

                my $argName = lc(shift @argList);
                my $argValue = shift @argList;

                if (! defined $argName || ! exists $ivHash{$argName}) {

                    # Unrecognised argument name
                    $self->puebloDebug($origToken, 'Malformed element', 7511);

                    return @emptyList;

                } elsif ($argName eq 'ismap' || $argName eq 'vspace' || $argName eq 'hspace') {

                    $ivHash{$argName} = TRUE;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Deal with <IMG XCH_MODE=text|html|purehtml> (implemented the same way as <XCH_MUDTEXT>
        if ($ivHash{'xch_mode'}) {

            # Don't bother checking the usual <IMG> attributes, but we will check the value
            if ($ivHash{'xch_mode'} eq 'text') {

                $self->ivPoke('puebloActiveFlag', FALSE);

            } elsif ( $ivHash{'xch_mode'} eq 'html' || $ivHash{'xch_mode'} eq 'purehtml') {

                $self->ivPoke('puebloActiveFlag', TRUE);

            } else {

                $self->puebloDebug($origToken, 'Invalid Pueblo interpret mode', 7521);
            }

            return @emptyList;
        }

        # Deal with <IMG XCH_SOUND=play|loop|stop|stoploop HREF=url EVENT=complete MD5=md5
        #   ACTION=action OPTIONS=queue>
        # Partially implemented. <IMG XCH_SOUND=play HREF=url> will play the specified sound file
        if ($ivHash{'xch_sound'}) {

            # Don't bother checking the usual <IMG> attributes, but we will check the URL
            if ($ivHash{'xch_sound'} ne 'play') {

                # Ignore this sound tag
                return @emptyList;
            }

            ($file, $dir, $ext) = File::Basename::fileparse($ivHash{'href'}, qr/\.[^.]*/);
            if (! $file) {

                # Unreadable sound source
                $self->puebloDebug($origToken, 'Unreadable sound source', 7531);

                return @emptyList;
            }

            # Convert the tag to an MXP tag, and let the MXP code handle it
            $tag = '<SOUND ' . $file . $ext . ' U="' . $dir . '>';
            push (@attList, 'fname', $file . $ext);
            push (@attList, 'u', $dir);

            $self->processMxpImageElement(
                $tag,
                'open',
                'SOUND',
                @attList,
            );

            return @emptyList;
        }

        # Check any specified argument values are valid
        if (defined $ivHash{'href'}) {
            $href = $ivHash{'href'};
        } else {
            $href = $ivHash{'src'};
        }

        if (! defined $href) {

            # Invalid foreground colour
            $self->puebloDebug($origToken, 'Image tag with no image source', 7532);

            return @emptyList;
        }

        # Need to convert the source URL into its components
        ($file, $dir, $ext) = File::Basename::fileparse($href, qr/\.[^.]*/);
        if (! $file) {

            # Unreadable image source
            $self->puebloDebug($origToken, 'Unreadable image source', 7533);

            return @emptyList;
        }

        # Convert the tag to MXP tag(s), and let the MXP code handle it
        if ($ivHash{'ismap'}) {

            $self->processMxpSendElement(
                '<SEND showmap>',
                'open',
                'SEND',
                FALSE,
                'href',
                'showmap',
            );
        }

        $tag = '<IMAGE ' . $file . $ext . ' url="' . $dir;
        push (@attList, 'fname', $file . $ext);
        push (@attList, 'url', $dir);

        if (defined $ivHash{'width'}) {

            $tag .= ' w=' . $ivHash{'width'};
            push (@attList, 'w', $ivHash{'width'});
        }

        if (defined $ivHash{'height'}) {

            $tag .= ' h=' . $ivHash{'height'};
            push (@attList, 'h', $ivHash{'height'});
        }

        if ($ivHash{'hspace'}) {

            $tag .= ' hspace';
            push (@attList, 'hspace', undef);
        }

        if ($ivHash{'vspace'}) {

            $tag .= ' vspace';
            push (@attList, 'vspace', undef);
        }

        if ($ivHash{'ismap'}) {

            $tag .= ' ismap';
            push (@attList, 'ismap', undef);
        }

        $tag .= '>';

        $self->processMxpImageElement(
            $tag,
            'open',
            'IMAGE',
            @attList,
        );

        if ($ivHash{'ismap'}) {

            $self->processMxpSendElement(
                '</SEND>',
                'close',
                'SEND',
                FALSE,
            );
        }

        return @emptyList;
    }

    sub processPuebloPaneElement {

        # Called by $self->processPuebloElement
        #
        # Process a Pueblo pane element: <XCH_PANE>
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $tag, $action, $name, $count, $href, $imgTag, $scrolling, $internalFlag, $align,
            $frameObj, $fetchObj, $fetchPath, $fileHandle, $tokenString,
            @emptyList, @attList, @fetchList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processPuebloPaneElement', @_);
            return @emptyList;
        }

        # <XCH_PANE ACTION=open|close|redirect [HREF=url] [NAME=name] [PANETITLE=title]
        #   [WIDTH=width] [HEIGHT=height] [MINWIDTH=width] [MINHEIGHT=height] [VSPACE=pixels]
        #   [HSPACE=pixels] [SCROLLING=yes|no|auto]
        #   [OPTIONS=
        #       "overlapped|floating|internal|browser|nonsizeable|noclose|small_title|fit|persistent
        #       |force|viewbottom|webtracker"
        #   ]
        #   [ALIGN=left|right|top|bottom]
        #
        # Implementation notes:
        #   - ACTION=open - the Pueblo spec says "The HTML string must include a href attribute
        #       indicating what should be displayed in the pane.". If the href attribute is a
        #       .gif, .bmp or .jpg URL, that image file is loaded and displayed in the new pane;
        #       otherwise a new pane is created with a clickable link inside it. Axmud creates the
        #       pane even if href= is not specified at all
        #   - NAME= - "If this attribute contains no value, then the pane will be created without a
        #       name and cannot be closed by the world author.". Unnamed panes or panes named
        #       '_blank' are given the internal name pueblo_n, where n is a positive integer, and
        #       can be closed at any time by the user (just like any other internal/external window)
        #   - The MINWIDTH=, MINHEIGHT=, VSPACE= and HSPACE= attributes are not implemented
        #   - The only OPTIONS= values which are implemented are 'floating', 'internal' and 'fit'.
        #       If both 'floating' and 'internal', are specified, only 'internal' is used. 'fit' is
        #       only partially implemented: we don't try to make the frame fit the text exactly, as
        #       Pueblo demands, but on the assumption that most worlds are using 'fit' for a frame
        #       containing 1 or 2 lines of text, we simply reduce the size of the frame by half
        #   - For ACTION=redirect is handled in the same way as ACTION=open, following the behaviour
        #       of the corresponding MXP <FRAME> tag attributes
        #   - The ALIGN option is only obeyed when OPTIONS=internal is specified
        #
        if (($tagMode eq 'open' && ! @argList) || $tagMode eq 'close') {

            $self->puebloDebug($origToken, 'Malformed element', 7601);

            return @emptyList;
        }

        # The <XCH_PANE> tag can change $self->currentTabObj, corresponding to the frame in which
        #   text received from the world is displayed. The calling function may have processed some
        #   text tokens which haven't been displayed yet; if so, display them now so that they're
        #   displayed in the right frame
        if ($self->processStripLine) {

            $self->respondIncomingData(
                'part',
                $self->processOrigLine,
                $self->processStripLine,
                $self->processTagHash,
            );
        }

        # Apply any links created by Pueblo <A> tags to the current textview (if the current
        #   textview was changed during the call to this function, any links for other textviews
        #   have already been applied)
        foreach my $linkObj ($self->mxpTempLinkList) {

            $self->currentTabObj->textViewObj->add_incompleteLink($linkObj);
        }

        $self->ivEmpty('mxpTempLinkList');

        # Process @argList

        # Default argument values
        %ivHash = (
            'action'    => 'open',
            'href'      => '',
            'name'      => '',
            'panetitle' => '',
            'width'     => undef,
            'height'    => undef,
            'minwidth'  => undef,       # not implemented
            'minheight' => undef,       # not implemented
            'vspace'    => undef,
            'hspace'    => undef,
            'scrolling' => 'auto',
            'options'   => undef,
            'align'     => undef,       # implemented only when OPTIONS=internal is used
        );

        if (@argList) {

            do {

                my $argName = lc(shift @argList);
                my $argValue = shift @argList;

                if (! defined $argName || ! exists $ivHash{$argName}) {

                    # Unrecognised argument name
                    $self->puebloDebug($origToken, 'Malformed element', 7611);

                    return @emptyList;

                } else {

                    $ivHash{$argName} = $argValue;
                }

            } until (! @argList);
        }

        # Check any specified argument values are valid
        $action = $ivHash{'action'};
        if ($action ne 'open' && $action ne 'close' && $action ne 'redirect') {

            $self->puebloDebug($origToken, 'Invalid action attribute', 7621);

            return @emptyList;

        } elsif ($action eq 'close' && $name eq '') {

            # Must specify name= attribute, too
            $self->puebloDebug($origToken, 'Invalid close attribute', 7622);

            return @emptyList;
        }

        $name = $ivHash{'name'};
        if ($name eq '') {

            # Choose a name for the unnamed pane. To avoid infinite loops, max number is 1000
            $count = 0;
            do {

                $count++;
                $name = 'pueblo_' . $count;

            } until (! $self->ivExists('mxpFrameHash', $name) || $count > 1000);

            if (! $name) {

                $self->puebloDebug($origToken, 'Maximum number of Pueblo panes exceeded', 7623);

                return @emptyList;
            }
        }

        $scrolling = $ivHash{'scrolling'};
        if ($scrolling ne 'yes' && $scrolling ne 'no' && $scrolling ne 'auto') {

            $self->puebloDebug($origToken, 'Invalid scrolling attribute', 7624);

            return @emptyList;
        }

        # Convert the tag to MXP tag(s), and let the MXP code handle it
        if ($action eq 'open' || $action eq 'redirect') {

            $tag = '<FRAME name="' . $name . '"';
            push (@attList, 'name', $name);

            $tag .= ' action=' . $action;
            push (@attList, 'action', $action);

            if ($ivHash{'panetitle'}) {

                $tag .= ' title="' . $ivHash{'panetitle'} . '"';
                push (@attList, 'title', $ivHash{'panetitle'});
            }

            if (defined $ivHash{'options'} && $ivHash{'options'} =~ m/internal/) {

                $tag .= ' internal';
                push (@attList, 'internal', undef);
                $internalFlag = TRUE;

                $align = $ivHash{'align'};
                if (defined $align) {

                    if (
                        $align ne 'left' && $align ne 'right' && $align ne 'top'
                        && $align ne 'bottom'
                    ) {
                        $self->puebloDebug($origToken, 'Invalid align attribute', 7625);
                        return @emptyList;

                    } else {

                        push(@attList, 'align', $align);
                    }
                }
            }

            if (defined $ivHash{'options'} && $ivHash{'options'} =~ m/fit/ && $internalFlag) {

                push (@attList, 'fit', TRUE);
            }

            if (defined $ivHash{'width'}) {

                $tag .= ' width=' . $ivHash{'width'};
                push (@attList, 'width', $ivHash{'width'});
            }

            if (defined $ivHash{'height'}) {

                $tag .= ' height=' . $ivHash{'height'};
                push (@attList, 'height', $ivHash{'height'});
            }

            if ($scrolling eq 'yes' || $scrolling eq 'no') {

                $tag .= ' scrolling=' . $scrolling;
                push (@attList, 'scrolling', $scrolling);
            }

            if (
                ! $internalFlag
                && defined $ivHash{'options'}
                && $ivHash{'options'} =~ m/floating/
            ) {
                $tag .= ' floating';
                push (@attList, 'floating', undef);
            }

            $tag .= '>';

            $self->processMxpFrameElement(
                $tag,
                'open',
                'FRAME',
                @attList,
            );

            # Make sure the pane actually exists now
            $frameObj = $self->getMxpFrame($name);
            if (! $frameObj) {

                $self->puebloDebug($origToken, 'Internal error while creating MXP frame', 7626);

                return @emptyList;

            } elsif (! $frameObj->paneObj) {

                # The pane isn't visible yet, for some reason, so don't try to apply the href=
                #   attribute
                return @emptyList;
            }

            # Apply the href= attribute, if specified
            $href = $ivHash{'href'};
            if ($href ne '') {

                if ($href =~ m/(\.gif|\.bmp|\.jpg)$/) {

                    # Display an online image
                    if ($action eq 'open') {

                        $self->processMxpFrameElement(
                            '<FRAME ' . $name . ' REDIRECT>',
                            'open',
                            'FRAME',
                            'name'      => $name,
                            'action'    => 'REDIRECT',
                        );
                    }

                    $self->processPuebloImageElement(
                        '<IMG src=' . $href . '>',
                        'open',
                        'IMG',
                        'src'       => $href,
                    );

                    if ($action eq 'open') {

                        $self->processMxpFrameElement(
                            '<FRAME _previous REDIRECT>',
                            'open',
                            'FRAME',
                            'name'      => '_previous',
                            'action'    => 'REDIRECT',
                        );
                    }

                } else {

                    # Attempt to download the online file into a temporary directory
                    $fetchObj = File::Fetch->new(uri => $href);
                    if ($fetchObj) {

                        $fetchPath = $fetchObj->fetch(
                            to => $axmud::DEFAULT_DATA_DIR . '/tmp/pueblo',
                        );
                    }

                    # Open the file and read its contents
                    if ($fetchPath && open($fileHandle, "<$fetchPath")) {

                        while (my $row = <$fileHandle>) {

                            push (@fetchList, $row);
                        }

                        close $fileHandle;
                    }

                    if (! @fetchList) {

                        # If the file could not be downloaded or read (for whatever reason), or if
                        #   the file was empty, just display a clickable link to it
                        $tokenString = '<XCH_PANE ACTION=redirect NAME="' . $name . '">'
                            . '<A href="' . $href . '">' . $href . '</A>'
                            . '<XCH_PANE ACTION=redirect NAME=_previous>';

                    } else {

                        # File was downloaded
                        $tokenString = '<XCH_PANE ACTION=redirect NAME="' . $name . '">'
                            . join('', @fetchList)
                            . '<XCH_PANE ACTION=redirect NAME=_previous>';
                    }

                    # Tokenise the contents of the file, as if the server had sent it over the
                    #   telnet/SSH/SSL connection
                    # The TRUE flag instructs the function to add the tokens to the beginning of
                    #   $self->currentTokenList, before any tokens that are already stored there (in
                    #   other words, we process the contents of this file before continuing to
                    #   process data actually received over the telnet/SSH/SSL connection)
                    $self->tokeniseIncomingData($tokenString, TRUE);
                }
            }

        } elsif ($action eq 'close') {

            $tag = '<FRAME name=' . $name;
            push (@attList, 'name', $name);

            $tag .= ' action=' . $action;
            push (@attList, 'action', $action);

            $tag .= '>';

            $self->processMxpFrameElement(
                $tag,
                'open',
                'FRAME',
                @attList,
            );
        }

        return @emptyList;
    }

    sub processPuebloImplementedElement {

        # Called by $self->processMxpElement
        #
        # Process an implemented Pueblo element, not covered by other functions (<XCH_MUDTEXT>,
        #   <XCH_PAGE>, <XCH_ALERT>)
        #
        # Expected arguments
        #   $origToken  - The original token text, before anything was extracted
        #   $tagMode    - 'open' for <..> elements, 'close' for </..> elements
        #   $keyword    - The element keyword (already converted to upper case)
        #
        # Optional arguments
        #   @argList    - If the element has arguments, a list in the form
        #                    (arg_name, arg_value, arg_name, arg_value...)
        #                 ...where each 'arg_value' is 'undef' is the argument wasn't a name=value
        #                   construction
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns an equivalent list of Axmud colour/style tags otherwise (may be an
        #       empty list)

        my ($self, $origToken, $tagMode, $keyword, @argList) = @_;

        # Local variables
        my (
            $clear,
            @emptyList,
            %ivHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $tagMode || ! defined $keyword) {

            $axmud::CLIENT->writeImproper(
                $self->_objClass . '->processPuebloImplementedElement',
                @_,
            );

            return @emptyList;
        }

        # <XCH_MUDTEXT>
        # </XCH_MUDTEXT>
        # <XCH_PAGE CLEAR=links|plugins|images|text FGCOLOR|TEXT="#rrggbb">
        # <XCH_ALERT>
        if (
            (($keyword eq 'XCH_MUDTEST' || $keyword eq 'XCH_ALERT') && @argList)
            || (($keyword eq 'XCH_PAGE' || $keyword eq 'XCH_ALERT') && $tagMode eq 'close')
        ) {
            $self->puebloDebug($origToken, 'Malformed element', 7701);

            return @emptyList;
        }

        if ($keyword eq 'XCH_MUDTEXT') {

            # <XCH_MUDTEXT>
            if ($tagMode eq 'close') {

                $self->ivPoke('puebloActiveFlag', FALSE);

            # </XCH_MUDTEXT>
            } else {

                $self->ivPoke('puebloActiveFlag', TRUE);
            }

            return @emptyList;

        } elsif ($keyword eq 'XCH_PAGE') {

            # Process @argList

            # Default argument values
            %ivHash = (
                'clear'     => 'links',     # 'links'/'text' implemented; 'plugins'/'images' not
                'fgcolor'   => undef,       # not implemented
                'text'      => undef,       # same as 'fgcolor', not implemented
            );

            if (@argList) {

                do {

                    my $argName = lc(shift @argList);
                    my $argValue = shift @argList;

                    if (! defined $argName || ! exists $ivHash{$argName}) {

                        # Unrecognised argument name
                        $self->puebloDebug($origToken, 'Malformed element', 7711);

                        return @emptyList;

                    } else {

                        $ivHash{$argName} = $argValue;
                    }

                } until (! @argList);
            }

            # Check any specified argument values are valid
            $clear = $ivHash{'clear'};
            if (
                defined $clear && $clear ne 'links' && $clear ne 'plugins' && $clear ne 'images'
                && $clear ne 'text'
            ) {
                # Unrecognised argument name
                $self->puebloDebug($origToken, 'Invalid <XCH_PAGE> attribute', 7721);

                return @emptyList;
            }

            # Process the tag. The 'plugins' value is not implemented
            if ($clear eq 'links') {

                foreach my $linkObj ($self->currentTabObj->textViewObj->ivValues('linkObjHash')) {

                    $linkObj->expiredFlag = TRUE;
                }

            } elsif ($clear eq 'text') {

                $self->currentTabObj->textViewObj->clearBuffer();
            }

            return @emptyList;

        # <XCH_ALERT>
        } elsif ($keyword eq 'XCH_ALERT') {

            $axmud::CLIENT->playSound('beep');

            return @emptyList;
        }
    }

    # (Process MCP messages)

    sub processMcpMsg {

        # Called by $self->processIncomingData
        # Also called by $self->spinMaintainLoop, if the time limit for waiting for an
        #   'mcp-negotiate-end' message has expire, in which case the calling function calls this
        #   function with an artificial MCP token
        #
        # Process the MCP message
        # The Axmud-supported packages 'mcp-negotiate' and 'mcp-cord' are handled entirely by this
        #   function; for all other packages, a valid MCP message is redirected towards the MCP
        #   package object (inheriting from Games::Axmud::Generic::Mcp)
        #
        # Expected arguments
        #   $origToken  - The original token text, containing the whole out-of-band line (MCP
        #                   message), including any newline characters that follow it
        #
        # Return values
        #   'undef' on improper arguments, if the MCP message is invalid or if the MCP package
        #       object rejects the message
        #   1 if the MCP package object accepts a valid MCP message

        my ($self, $origToken, $check) = @_;

        # Local variables
        my (
            $line, $firstChar, $dataTag, $multiObj, $msg, $packageName, $packageObj, $authKey,
            $mode, $otherObj, $funcRef, $id, $cordMsg,
            %checkHash, %argHash, %useHash, %newHash,
        );

        # Check for improper arguments
        if (! defined $origToken || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMcpMsg', @_);
        }

        # MCP messages consist of three parts: the name of the message, the authentication key, and
        #   a set of keyword-value pairs

        # First remove the initial #$# and the final newline character(s)
        # ($line should not begin with space characters, but we'll strip any we find)
        $line = $origToken;
        $line =~ s/^\#\$\#\s*//;
        $line =~ s/[\n\r]*$//;

        $firstChar = substr($line, 0, 1);
        if ($firstChar eq '*') {

            # If the remaining portion of $line begins with an asterisk, it's a multiline
            #   continuation message

            # $line is now in the form '<space><data tag><space><key>:<space><value>'
            return $self->processMcpContinuation($origToken, $line);

        } elsif ($firstChar eq ':') {

            # If the remaining portion of $line begins with a colon, it's a multiline termination
            #   message

            # $line is now in the form '<space><data tag><optional spaces>'
            # Extract the data tag
            if ($line =~ m/^\s*([A-Za-z][A-Za-z0-9\-]*)\s*$/) {

                $dataTag = $1;

            } else {

                # Invalid termination message
                $self->mcpDebug($origToken, 'Invalid multiline termination message', 8100);
                return undef;
            }

            # Check that the data tag was specified by a previous MCP message with multiline values
            $multiObj = $self->ivShow('mcpMultiObjHash', $dataTag);
            if (! $multiObj) {

                $self->mcpDebug($origToken, 'Unrecognised multiline data tag', 8101);
                return undef;
            }

            # Set up values for the rest of this function

            # MCP packages are generally in the form 'some-package-name', and MCP messages are
            #   generally in the form 'some-package-name-message' (but a null message in the form
            #   'some-package-name' is also allowed)
            # From the list of MCP packages used by this session (in $self->mcpPackageHash), find
            #   the longest name that matches the message, and use that MCP package object
            foreach my $testName ($self->ivKeys('mcpPackageHash')) {

                if (
                    ! defined $packageName
                    || (
                        index($multiObj->msg, $testName) == 0
                        && length($testName) > length($packageName)
                    )
                ) {
                    $packageName = $testName;
                }
            }

            # If the message name refers to an MCP package not used by this session, ignore the
            #   message name
            if (! defined $packageName) {

                $self->mcpDebug($origToken, 'MCP package not in use by this session', 8102);
                return undef;

            } else {

                $packageObj = $self->ivShow('mcpPackageHash', $packageName);
            }

            # Get the combined hash of key/value pairs
            %argHash = ($multiObj->normalHash, $multiObj->multiHash);

        } else {

            # If the remaining portion of $line begins with (almost any) character besides an
            #   asterisk or colon, it's a normal MCP message

            # Extract the message name, and remove any trailing spaces after it
            if ($line =~ m/^(([^\s]+)\s*)/) {

                $msg = $2;
                $line = substr($line, length($1));
            }

            # We can ignore the message name 'mpc', because $self->detectMcp already dealt with it
            if ($msg eq 'mcp') {

                return 1;
            }

            # MCP packages are generally in the form 'some-package-name', and MCP messages are
            #   generally in the form 'some-package-name-message' (but a null message in the form
            #   'some-package-name' is also allowed)
            # From the list of MCP packages used by this session (in $self->mcpPackageHash), find
            #   the longest name that matches the message, and use that MCP package object
            foreach my $testName ($self->ivKeys('mcpPackageHash')) {

                if (
                    ! defined $packageName
                    || (index($msg, $testName) == 0 && length($testName) > length($packageName))
                ) {
                    $packageName = $testName;
                }
            }

            # If the message name refers to an MCP package not used by this session, ignore the
            #   message name
            if (! defined $packageName) {

                # (Same error code as above)
                $self->mcpDebug($origToken, 'MCP package not in use by this session', 8102);

            } else {

                $packageObj = $self->ivShow('mcpPackageHash', $packageName);
            }

            # Extract the authentification key, and compare it against the one that was created by
            #   $self->generateMcpKey
            if ($line =~ m/^(([^\s]+)\s*)/) {

                $authKey = $2;
                $line = substr($line, length($1));
            }

            # If the authentification key is wrong, ignore the message
            if (! defined $authKey || $authKey ne $self->mcpAuthKey) {

                $self->mcpDebug(
                    $origToken,
                    'Invalid authentification key (possible spoodfed message)',
                    8110,
                );

                return undef;
            }

            # Extract key-value pairs (order is not important)
            if ($line ne '') {

                ($mode, %argHash) = $self->extractMcpArgs($origToken, $msg, $line);
                if (! defined $mode) {

                    # Error extracting the key-value pairs. A call to $self->mcpDebug has already
                    #   been made
                    return undef;

                } elsif ($mode eq 'multi') {

                    # Multiline values detected; wait until the rest of the multiline data has been
                    #   received, before passing the message on to an MCP package
                    return 1;
                }
            }
        }

        # Now process the MCP message

        # Messages for the MCP package 'mcp-negotiate' and 'mcp-cord' are handled by this function,
        #   and not by GA::Mcp::NegotiateCan itself
        if ($packageName eq 'mcp-negotiate') {

            # After a 'mcp-negotiate-end' message has been received, we don't accept any more
            #   messages for the 'mcp-negotiate' package
            if (defined $packageObj->get_scalar('endFlag')) {

                $self->mcpDebug(
                    $origToken,
                    'Message for \'mcp-negotiate\' package ignored after earlier \'mcp-negotiate'
                    . '-end\' message',
                    8200,
                );

                return undef;
            }

            if ($msg eq 'mcp-negotiate-can') {

                # Check that the expected keys are present in %argHash (ignoring any unexpected
                #   keys)
                if (
                    ! exists $argHash{'package'}
                    || ! exists $argHash{'min-version'}
                    || ! exists $argHash{'max-version'}
                ) {
                    $self->mcpDebug(
                        $origToken,
                        'Missing key-value pairs in \'mcp-negotiate-can\' message',
                        8210,
                    );

                    return undef;
                }

                # Find the corresponding MCP package object, and set its ->useVersion IV
                $otherObj = $self->ivShow('mcpPackageHash', $argHash{'package'});
                if (! $otherObj) {

                    $otherObj = $self->ivShow('mcpWaitHash', $argHash{'package'});
                }

                if (! $otherObj) {

                    # MCP package object not supported by Axmud, or not defined by a plugin
                    $self->mcpDebug(
                        $origToken,
                        'MCP Package not supported by ' . $axmud::SCRIPT,
                        8211,
                    );

                    return undef;
                }

                # Use the highest package version supported by both the world and Axmud
                if (
                    $otherObj->maxVersion >= $argHash{'min-version'}
                    && $argHash{'max-version'} >= $otherObj->minVersion
                ) {
                    if ($argHash{'max-version'} < $otherObj->maxVersion) {
                        $otherObj->set_useVersion($argHash{'max-version'});
                    } else {
                        $otherObj->set_useVersion($otherObj->maxVersion);
                    }

                } else {

                    # The version numbers supported by the world and Axmud don't overlap, so we
                    #   can't use this MCP package object
                    $self->ivDelete('mcpPackageHash', $otherObj->name);
                    $self->ivDelete('mcpWaitHash', $otherObj->name);
                }

            } elsif ($msg eq 'mcp-negotiate-end') {

                # Don't accept any more messages to this MCP package
                $packageObj->add_scalar('endFlag', TRUE);
                # The time limit is no longer required
                $self->ivUndef('mcpCheckTime');

                # Some MCP package objects specify a ->supplantList - a list of MCP packages which
                #   should be supplanted in favour of that one
                # All such MCP package objects, and any package objects that they supplant, are
                #   currently stored in $self->mcpWaitHash
                # Work out which of several alternative MCP packages should be used by this session.
                #   Add those that should be used to $self->mcpPackageHash, and delete the rest

                %useHash = $self->mcpWaitHash;

                # (A pair of packages could, in theory, specify that they supplant each other. To
                #   get consistent results, in this situation the package with the longer name
                #   supplants the package with the shorter name, so 'edit' is supplanted by
                #   'special-edit')
                foreach my $otherObj (sort {length($b) <=> length($a)} (values %useHash)) {

                    foreach my $supplantName ($otherObj->supplantList) {

                        # (A package doesn't supplant anything, if it's already been supplanted by
                        #   some other package)
                        if (exists $useHash{$otherObj->name} && exists $useHash{$supplantName}) {

                            delete $useHash{$supplantName};
                        }
                    }
                }

                # All surviving packages can be used by this session if the world supports them and
                #   the server and client versions are compatible
                foreach my $otherObj (sort {length($b) <=> length($a)} (values %useHash)) {

                    $self->ivAdd('mcpPackageHash', $otherObj->name, $otherObj);

                    $self->mcpSendMsg(
                        'mcp-negotiate-can',
                            'package',
                            $otherObj->name,
                            'min-version',
                            $otherObj->minVersion,
                            'max-version',
                            $otherObj->maxVersion,
                    );
                }

                $self->ivEmpty('mcpWaitHash');

                foreach my $otherObj ($self->ivValues('mcpPackageHash')) {

                    if (! defined $otherObj->useVersion) {

                        # Server doesn't support this package
                        $self->ivDelete('mcpPackageHash', $otherObj->name);
                    }
                }

                # We have also finished negotiating, now
                $self->mcpSendMsg('mcp-negotiate-end');

            } else {

                # Unrecognised message for this packet
                $self->mcpDebug($origToken, 'Unrecognised MCP message', 8220);

                return undef;
            }

        } elsif ($packageName eq 'mcp-cord') {

            # Strip the initial 'I' (or possibly an initial 'R') from the cord ID. If the ID doesn't
            #   begin with one of those characters (or if no ID was specified), the message is
            #   invalid
            $id = $argHash{'_id'};
            if (! defined $id || ! ($id =~ s/^[IR]//)) {

                $self->mcpDebug($origToken, 'Invalid or missing MCP cord ID', 8250);
                return undef;

            } else {

                delete $argHash{'_id'};
            }

            if ($msg eq 'mcp-cord-open') {

                # Check that the expected key is present in %argHash (ignoring any unexpected keys)
                if (! exists $argHash{'_type'}) {

                    $self->mcpDebug(
                        $origToken,
                        'Missing key-value pairs in \'mcp-cord-open\' message',
                        8260,
                    );

                    return undef;
                }

                # Check that some part of the code (probably in a plugin) understands the specified
                #   _type
                $funcRef = $self->ivShow('mcpCordOpenHash', $argHash{'_type'});
                if (! defined $funcRef) {

                    $self->mcpDebug($origToken, 'Unrecognised MCP cord type', 8261);
                    return undef;
                }

                # Check that the unique ID hasn't already been used (IDs must be unique, so even if
                #   a cord with the same ID has been closed, don't re-use it)
                if ($self->ivExists('mcpCordIDHash', $id)) {

                    # ID already in used
                    $self->mcpDebug($origToken, 'MCP cord ID already in use', 8262);
                    return undef;
                }

                # Call the corresponding function to notify some part of the code that a cord has
                #   opened
                # That code must call $self->accept_mcpCordID to add entries to $self->mcpCordIDHash
                #   and $self->mcpCordCloseHash
                # If the corresponding function doesn't want to use the cord, it must call
                #   $self->refuse_mcpCordID to mark the cord as closed (so future messages are
                #   ignored)
                &$funcRef($self, $argHash{'_type'}, $id);

            } elsif ($msg eq 'mcp-cord') {

                # Check that the expected key is present in %argHash
                if (! exists $argHash{'_message'}) {

                    $self->mcpDebug(
                        $origToken,
                        'Missing key-value pairs in \'mcp-cord\' message',
                        8270,
                    );

                    return undef;
                }

                # Extract those key-value pairs from the hash, leaving only the optional arguments
                $cordMsg = $argHash{'_message'};
                delete $argHash{'_message'};

                # Check that the ID is recognised, and that the cord is still open (in both those
                #   cases, $funcRef will be set to 'undef'
                $funcRef = $self->ivShow('mcpCordIDHash', $id);
                if (! $funcRef) {

                    $self->mcpDebug(
                        $origToken,
                        'MCP cord not open (or has been closed)',
                        8271,
                    );

                    return undef;
                }

                # Pass on the message and any optional arguments
                &$funcRef($self, $id, $cordMsg, %argHash);

            } elsif ($msg eq 'mcp-closed') {

                # Check that the ID is recognised, and that the cord is still open
                if (! $self->ivShow('mcpCordIDHash', $id)) {

                    $self->mcpDebug(
                        $origToken,
                        'MCP cord not open (or has already been closed)',
                        8280,
                    );

                    return undef;
                }

                # Call the corresponding function to notify some part of the code that the cord has
                #   closed
                # That code need not call any GA::Session function in response; this function will
                #   do the tidying up
                $funcRef = $self->ivShow('mcpCordCloseHash', $id);
                if (! $funcRef) {

                    $self->mcpDebug(
                        $origToken,
                        'Missing function reference in response to \'mcp-closed\' message',
                        8281,
                    );

                    # (Not a fatal error, so don't return 'undef')
                }

                # Call the corresponding function to notify some part of the code that a cord has
                #   closed
                &$funcRef($self, $id);

                # Update session IVs
                $self->ivAdd('mcpCordIDHash', $id, undef);
                $self->ivDelete('mcpCordCloseHash', $id);
            }

        } else {

            # Don't respond to a package's messages until we're sure that both server and client
            #   support the same version
            if (! defined $packageObj->useVersion) {

                $self->mcpDebug(
                    $origToken,
                    'Ignoring MCP message because server and client haven\'t negotiated the package'
                    . ' version to use',
                    8290,
                );

                return undef;

            # Pass the message on to the MCP package object
            } elsif (! $packageObj->msg($self, $msg, %argHash)) {

                $self->mcpDebug(
                    $origToken,
                    'MCP message was not handled by its package',
                    8291,
                );

                return undef;
            }
        }

        # MCP message handled. Fire any hooks that are using the 'mcp' hook event
        $self->checkHooks('mcp', $msg);

        return 1;
    }

    # (Support functions for the MSP/MXP/Pueblo/MCP functions above)

    sub setPseudoMSP {

        # Called by GA::Cmd::MSP->do
        # Some worlds are not able to negotiate telnet options to enable MSP, but nevertheless
        #   are able to send MSP sound/music triggers to the client
        # Therefore we need a setting of $self->mspMode which means something like 'the server did
        #   not negotiate MSP, but Axmud is responding to MSP sound/music triggers'
        # This function is called to turn on/off pseudo-MSP recognition. The server is informed
        #   using IAC DONT MSP or IAC DO MSP, even if it doesn't seem to recognise those
        #   telnet options
        # NB Pseudo-MSP recognition can be turned on, even if the general setting
        #   (GA::Client->useMspFlag) is FALSE
        #
        # Expected arguments
        #   $flag   - Set to TRUE to turn on pseudo-MSP recognition, or FALSE to turn it off
        #
        # Return values
        #   'undef' on improper arguments or if full MSP or pseudo-MSP recognition is already on/off
        #   1 otherwise

        my ($self, $flag, $check) = @_;

        # Local variables
        my %telConstHash;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setPseudoMSP', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        if ($flag) {

            if ($self->mspMode eq 'client_agree' || $self->mspMode eq 'client_simulate') {

                # Full MSP or pseudo-MSP recognition is already turned on
                return 1;

            } else {

                # Turn on pseudo-MSP recognition
                $self->optSendDo($telConstHash{'TELOPT_MSP'});
                $self->ivPoke('mspMode', 'client_simulate');
            }

        } else {

            if ($self->mspMode eq 'no_invite' || $self->mspMode eq 'client_refuse') {

                # Full MSP or pseudo-MSP recognition is already turned off
                return 1;

            } else {

                # Turn off pseudo-MSP recognition
                $self->optSendDont($telConstHash{'TELOPT_MSP'});
                $self->ivPoke('mspMode', 'client_refuse');
            }
        }

        return 1;
    }

    sub setMxpLineMode {

        # Called by $self->updateEndLine, ->processEscSequence and ->checkMxpSecureMode
        # Sets a new value for $self->mxpLineMode
        # Also closes any open tags when open mode changes to secure mode
        #
        # Expected arguments
        #   $newMode   - The new MXP line mode (a value in the range 0-2)
        #
        # Optional arguments
        #   $tempFlag    - Used when turning on/off temp secure mode. When TRUE, this function
        #                   doesn't call $self->partiallyEmptyMxpStack; FALSE (or 'undef') when
        #                   $self->mxpLineMode is being set for any other reason
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of equivalent Axmud colour/style tags, when required (may be
        #       an empty list)

        my ($self, $newMode, $tempFlag, $check) = @_;

        # Local variables
        my (@emptyList, @tagList);

        # Check for improper arguments
        if (! defined $newMode || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->setMxpLineMode', @_);
            return @emptyList;
        }

#        if (
#            ! $tempFlag
#            && (
#                ($self->mxpLineMode == 1 && $newMode == 0)
#                || ($self->mxpLineMode == 0 && $newMode == 1)
#            )
#        ) {
#            # Switching between open/secure mode closes any open tags
#            @tagList = $self->emptyMxpStack();
#        }

        # v2.1.061. Code in previous versions did not handle switching modes correctly. Clients are
        #   supposed to close 'open' tags such as <B> when switching from line mode 0 to line mode
        #   1/2; the previous code closed both 'open' and 'secure' tags when line mode changed in
        #   any direction
        # Unfortunately, selectively popping tags from the MXP stack would not be possible without
        #   a significant redesign
        # As a compromise, we'll pop any 'open' tags from the MXP stack (which clears all tags
        #   higher up the stack, both 'open' and 'secure')
        if (! $tempFlag && $self->mxpLineMode == 0 && $newMode != 0) {

            @tagList = $self->partiallyEmptyMxpStack();
        }

        # Update the IV
        $self->ivPoke('mxpLineMode', $newMode);

        return @tagList;
    }

    sub convertMxpColour {

        # Called by $self->processMxpElement to convert the colours in a <COLOR> or <FONT>
        #   construction, in the form
        #       <COLOR FORE=foreground [BACK=background]>
        #       <FONT FACE=name [SIZE=size] [COLOR=foreground] [BACK=background]>
        # Also called by $self->processMxpGaugeElement for <GAUGE> tags
        #
        # 'foreground' and 'background' can be standard HTML colours (keys in
        #   GA::Client->constHtmlColourHash), RGB colour tags (in the form '#000000')
        # http://www.zuggsoft.com/zmud/mxp.htm states that we can use 'color attribute names such as
        #   blink', but neglects to specify WHICH colour attributes we can use - so 'blink' is the
        #   only one Axmud implements. If used, Axmud expects that 'foreground'/'background' will be
        #   in the form 'colour,blink' or 'blink,colour'
        #
        # This function converts a valid HTML colour to an RGB colour tag, in the form '#FFFFFF'
        #
        # Expected arguments
        #   $colour         - From the constructions above, the colour to convert: 'foreground' or
        #                       'background'
        #   $underlayFlag   - Flag set to TRUE for 'BACK=background' arguments, FALSE for
        #                       'COLOUR=foreground' arguments
        #
        # Return values
        #   An empty list on improper arguments or if $colour is invalid
        #   Otherwise, returns a list in the form
        #       (rgb_colour, blink_flag)
        #   ...where 'blink_flag' is TRUE or FALSE

        my ($self, $colour, $underlayFlag, $check) = @_;

        # Local variables
        my (
            $rgb, $blinkFlag,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $colour || ! defined $underlayFlag || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->convertMxpColour', @_);
            return @emptyList;
        }

        # Axmud treats MXP/HTML colours as case-insensitive
        $colour = lc($colour);

        # Remove 'blink', if present
        if ($colour =~ s/^blink\,//) {
            $blinkFlag = TRUE;
        } elsif ($colour =~ s/\,blink$//) {
            $blinkFlag = TRUE;
        } else {
            $blinkFlag = FALSE;
        }

        # Convert standard HTML colours to RGB format
        if ($axmud::CLIENT->ivExists('constHtmlColourHash', $colour)) {

            $rgb = $axmud::CLIENT->ivShow('constHtmlColourHash', $colour);

        } elsif ($colour =~ m/^\#[A-Fa-f0-9]{6}$/) {

            $rgb = $colour;

        } else {

            # Invalid colour
            return @emptyList;
        }

        if ($underlayFlag) {

            $rgb = 'u' . $rgb;
        }

        return ($rgb, $blinkFlag);
    }

    sub popMxpStack {

        # Called by $self->processMxpModalElement, ->processMxpLinkElement, ->processMxpSendElement,
        #   ->processMxpCustomElement or ->emptyMxpStack
        # After a modal tag (like <B> or <FONT>) is processed, a GA::Mxp::StackObj object is
        #   created, recording the then-current state of the current textview object's
        #   ->mxpModalStackHash
        # When the matching closing tag (</B> or </FONT>) is processed, we go through the current
        #   textview object's ->mxpModalStackList, 'popping' objects until we find one with the
        #   right keyword
        # Then we create a list of Axmud colour/style tags that restore the text attributes that
        #   applied when the original opening tag was processed
        # In this way, we can nest text attributes (but only those created with MXP)
        #
        # This functions updates the current textview's IVs, removing the popped stack object(s), if
        #   the operation is successful
        #
        # Expected arguments
        #   $keyword        - The closing tag's keyword, e.g. 'B' or 'FONT'
        #
        # Optional arguments
        #   $noCloseFlag    - Set to TRUE when called by $self->processMxpLinkElement or
        #                       ->processMxpSendElement, in which case those functions don't need to
        #                       be called back to close a <A>..</A> construction. Set to FALSE (or
        #                       'undef') in all other situations
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of Axmud colour/style tags restoring the text attributes that
        #       applied when the original opening tag was processed

        my ($self, $keyword, $noCloseFlag, $check) = @_;

        # Local variables
        my (
            $textViewObj, $stackObj, $fontFlag, $fontTag, $elementObj, $text, $flagArg, $entityObj,
            @emptyList, @stackList, @tagList,
            %sessionStackHash, %objStackHash,
        );

        # Check for improper arguments
        if (! defined $keyword || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->popMxpStack', @_);
            return @emptyList;
        }

        # Import the current textview object (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;
        # Import the stack of MXP modal tags
        @stackList = $textViewObj->mxpModalStackList;
        if (! @stackList) {

            $self->mxpDebug(
                '</' . uc($keyword) . '>',
                'Encountered closing \'</' . uc($keyword) . '>\' tag without matching opening'
                . ' tag',
                5002,
            );

            return @emptyList;
        }

        do {

            my $thisObj = pop @stackList;

            # GA::Obj::TextView->mxpModalStackList doesn't account for the following elements:
            #   <A>, <SEND>
            # While popping the stack, if we encounter one of those elements, we must close them not
            #   by modifying the textview object's ->mxpModalStackList, but by calling another
            #   function directly
            # The TRUE argument means 'don't call $self->popMxpStack as you normally would'. If this
            #   function was called by $self->processMxpSendElement (etc), $noCloseFlag is TRUE, so
            #   we don't need to call that function again
            # Other modal elements are closed entirely by the code in this function
            if (! $noCloseFlag) {

                if ($thisObj->keyword eq 'A') {

                    if ($self->mxpMode eq 'client_agree') {

                        push (@tagList,
                            $self->processMxpLinkElement(
                                '</A>',
                                'close',
                                'A',
                                TRUE,
                            ),
                        );

                    } elsif ($self->puebloMode eq 'client_agree') {

                        push (@tagList,
                            $self->processPuebloLinkElement(
                                '</A>',
                                'close',
                                'A',
                                TRUE,
                            ),
                        );
                    }

                } elsif ($thisObj->keyword eq 'SEND') {

                    push (@tagList,
                        $self->processMxpSendElement(
                            '</SEND>',
                            'close',
                            'SEND',
                            TRUE,
                        ),
                    );
                }
            }

            if ($thisObj->keyword eq $keyword) {

                # Matching opening tag found
                $stackObj = $thisObj;
            }

        } until ($stackObj || ! @stackList);

        if (! $stackObj) {

            $self->mxpDebug(
                '</' . uc($keyword) . '>',
                'Encountered closing \'</' . uc($keyword) . '>\' tag without matching opening'
                . ' tag',
                5003,
            );

            return @emptyList;
        }

        # Matching opening tag found. Import the current textview object's current MXP text
        #   attributes (for quick comparison)
        %sessionStackHash = $textViewObj->mxpModalStackHash;
        # ...and the stored MXP text attributes (those in use, before the original opening tag)
        %objStackHash = $stackObj->stackHash;

        # Check each key-value pair in turn. If they are different, add an Axmud colour/style tag to
        #   the return list, which will re-created conditions that existed before the original
        #   opening tag
        # NB The keys in $stackHash don't have the same name as corresponding Axmud colour/style
        #   tags
        if ($sessionStackHash{'bold_flag'} ne $objStackHash{'bold_flag'}) {

            $fontFlag = TRUE;
        }

        if ($sessionStackHash{'italics_flag'} ne $objStackHash{'italics_flag'}) {

            if (! $objStackHash{'italics_flag'}) {
                push (@tagList, 'italics_off');
            } else {
                push (@tagList, 'italics');
            }
        }

        if ($sessionStackHash{'underline_flag'} ne $objStackHash{'underline_flag'}) {

            if (! $objStackHash{'underline_flag'}) {
                push (@tagList, 'underline_off');
            } else {
                push (@tagList, 'underline');
            }
        }

        if ($sessionStackHash{'strike_flag'} ne $objStackHash{'strike_flag'}) {

            if (! $objStackHash{'strike_flag'}) {
                push (@tagList, 'strike_off');
            } else {
                push (@tagList, 'strike');
            }
        }

        if ($sessionStackHash{'colour_foreground'} ne $objStackHash{'colour_foreground'}) {

            if (! $objStackHash{'colour_foreground'}) {
                push (@tagList, $self->currentTabObj->textViewObj->textColour);
            } else {
                push (@tagList, $objStackHash{'colour_foreground'});
            }
        }

        if ($sessionStackHash{'colour_background'} ne $objStackHash{'colour_background'}) {

            if (! $objStackHash{'colour_background'}) {
                push (@tagList, $self->currentTabObj->textViewObj->underlayColour);
            } else {
                push (@tagList, $objStackHash{'colour_background'});
            }
        }

        # (<BOLD> and <HIGH> are implemented the same way)
        if ($sessionStackHash{'high_flag'} ne $objStackHash{'high_flag'}) {

            $fontFlag = TRUE;
        }

        if ($sessionStackHash{'font_name'} ne $objStackHash{'font_name'}) {

            $fontFlag = TRUE;
        }

        if ($sessionStackHash{'font_size'} ne $objStackHash{'font_size'}) {

            $fontFlag = TRUE;
        }

        if ($sessionStackHash{'blink_flag'} ne $objStackHash{'blink_flag'}) {

            if (! $objStackHash{'blink_flag'}) {
                push (@tagList, 'blink_off');
            } else {
                push (@tagList, 'blink_slow');
            }
        }

        # Update the current textview object's IVs
        $textViewObj->set_mxpModalStackList(@stackList);
        $textViewObj->set_mxpModalStackHash($stackObj->stackHash);

        if ($fontFlag) {

            # Create a dummy style tag to describe the changed font/font size (including bold
            #   on/off)
            push (@tagList, $self->createMxpFontTag($stackObj->stackHash));
        }

        # For custom elements, get the matching element object
        $elementObj = $self->ivShow('mxpElementHash', lc($stackObj->keyword));
        if ($elementObj && $elementObj->flagArg) {

            # We have been storing the text between two matching custom elements, e.g. from the MXP
            #   spec, <RName>...</RName>, which define a tag property
            # Stop storing text for this tag property, and move it to ->mxpFlagTextStoreHash, ready
            #   to be stored in the display buffer at the next opportunity
            $text = $self->ivShow('mxpFlagTextHash', $elementObj->flagArg);
            if (defined $text) {

                # (Guard against possibility that two <RName>...</RName> constructions appear on the
                #   same line)
                if (! $self->ivShow('mxpFlagTextStoreHash', $elementObj->flagArg)) {

                    $self->ivAdd('mxpFlagTextStoreHash', $elementObj->flagArg, $text);

                } else {

                    $self->ivAdd(
                        'mxpFlagTextStoreHash',
                        $elementObj->flagArg,
                        $self->ivShow('mxpFlagTextStoreHash', $elementObj->flagArg) . $text,
                    );
                }

                # Of the six standard tag properties, only one ('Set xxx') must be implemented
                #   immediately
                # ('Prompt' is handled by $self->processLineSegment, and 'RoomName' etc are handled
                #   by the Locator task)
                $flagArg = $elementObj->flagArg;
                if ($flagArg =~ m/^Set\s+\w/) {

                    $flagArg =~ s/^Set\s+//;

                    # Find the matching entity object. If it doesn't exist, don't create it
                    $entityObj = $self->ivShow('mxpEntityHash', $elementObj->name);
                    if ($entityObj) {

                        $entityObj->ivPoke('value', $text);
                        # Mark any corresponding gauges to be updated
                        $self->ivAdd('mxpGaugeUpdateHash', $elementObj->name, undef);
                    }
                }
            }

            $self->ivDelete('mxpFlagTextHash', $elementObj->flagArg);
        }

        # Operation complete
        return @tagList;
    }

    sub partiallyEmptyMxpStack {

        # Called by $self->updateEndLine and ->setMxpLineMode
        # Modified version of $self->emptyMxpStack. This function pops any 'open' tags (which
        #   automatically pops any tags higher in the stack). Any 'secure' tags at the bottom of the
        #   stack remain unpopped
        # (For the reasons why this is done, see the comments in $self->setMxpLineMode)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of Axmud colour/style tags restoring the text attributes that
        #       applied before any of the existing open tags were processed

        my ($self, $check) = @_;

        # Local variables
        my (
            $textViewObj, $popFlag,
            @emptyList, @tagList,
        );

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->partiallyEmptyMxpStack', @_);
            return @emptyList;
        }

        # Import the current textview object (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        if ($textViewObj->mxpModalStackList) {

            do {

                $popFlag = FALSE;

                # Import the current stack of MXP modal tags (actually a list of
                #   GA::Mxp::StackObj objects), and look for the first open tag
                my @stackList = $textViewObj->mxpModalStackList;

                OUTER: foreach my $stackObj (@stackList) {

                    # (The only tags allowed in open line mode - which are the tags we need to close
                    #   now - are those specified in this hash)
                    if ($axmud::CLIENT->ivExists('constMxpModalHash', $stackObj->keyword)) {

                        # This is an open tag, so close it
                        @tagList = $self->popMxpStack($stackObj->keyword);
                        $popFlag = TRUE;
                        last OUTER;
                    }
                }

            } until (! $popFlag);
        }

        # Operation complete
        return @tagList;
    }

    sub emptyMxpStack {

        # Called by $self->reactDisconnect, ->processEscChar and ->processEscSequence
        # Empties the current textview's stack of GA::Mxp::StackObj objects by calling
        #   $self->popMxpStack for each one in turn
        # This has the effect of closing all open MXP tags
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of Axmud colour/style tags restoring the text attributes that
        #       applied before any of the existing open tags were processed

        my ($self, $check) = @_;

        # Local variables
        my (
            $textViewObj,
            @emptyList, @tagList,
        );

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->emptyMxpStack', @_);
            return @emptyList;
        }

        # Import the current textview (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;
        if (! $textViewObj->mxpModalStackList) {

            # There are no open MXP tags
            return @emptyList;
        }

        # Otherwise, pop stack objects one by one, and in reverse order
        do {

            my $stackObj = $textViewObj->ivIndex(
                'mxpModalStackList',
                $textViewObj->ivLast('mxpModalStackList'),
            );

            push (@tagList, $self->popMxpStack($stackObj->keyword));

        } until (! $textViewObj->mxpModalStackList);

        # Operation complete
        return @tagList;
    }

    sub findMxpArgsByPosn {

        # From http://www.mushclient.com/mushclient/mxp.htm :
        #
        # Arguments may be by keyword or positional. If by keyword the syntax is:
        #   argument_name=argument_value
        # If no argument name is provided then the argument is assumed to be the next argument by
        #   position from the previous argument, or if no previous argument the first argument. This
        #   means, that following a keyword argument, the next argument that does not have a keyword
        #   is now considered to be the argument in sequence after the keyword. Thus you could use a
        #   single keyword argument to "jump" to the middle of an argument list.
        #
        # This function takes a single 'name=value' construction (or a 'name' construction, or a
        #   'value' construction whose corresponding name can be inferred by position) and works out
        #   the compulsory 'name' part and the optional 'value' part
        # If an argument 'name' is found, everything up to and including that 'name' is removed from
        #   %$checkListRef so that, if the next call to this function is a 'value' construction, we
        #   can work out the 'name' by using the first remaining item in %$checkListRef (this is
        #   'arguments by position')
        # If the world specifies argument 'names' in the wrong order, resets $checkListRef so the
        #   current and subsequent calls to the function work as intended
        #
        # Expected arguments
        #   $origListRef    - Reference to a list of argument 'name's in the order they're
        #                       expected; e.g. for <SEND>, this list will be
        #                           ('href', 'hint', 'prompt', 'expire')
        #                   - This list is not modified during successive calls to this function
        #   $checkListRef   - Reference to a list of argument 'name's in the order they're
        #                       expected; e.g. for <SEND>, this list will be
        #                           ('href', 'hint', 'prompt', 'expire')
        #                   - Argument 'name's are removed from this list, as they are processed
        #                   - If this list is empty, it is reset using the contents of $origListRef
        #   $ivHashRef      - Reference to a hash of argument 'name's, stored as keys, and their
        #                       default values, e.g. for <SEND>, will contain
        #                           $ivHash{'href'} = undef,
        #                           $ivHash{'prompt'} = FALSE, (...and so on)
        #   $checkHashRef   - Reference to a hash of argument 'name's which never take a
        #                       corresponding value, those argument names stored as keys in the
        #                       hash; e.g. for <SEND>, will contain a single key-value pair:
        #                           $checkHash{'prompt'} = undef
        #   $argName        - An argument name or value to process...
        #
        # Optional argumements
        #   $argValue       - ...for 'name=value' constructions, both $argName and $argValue will be
        #                       set. For 'name' or 'value' constructions, $argName will be set but
        #                       $argValue will be 'undef'
        #
        # Return Values
        #   An empty list on improper arguments or if there's an error
        #   Otherwise returns a list in the form (name, value), where 'name' is set and 'value' may
        #       be either set or 'undef'

        my (
            $self, $origListRef, $checkListRef, $ivHashRef, $checkHashRef, $argName, $argValue,
            $check,
        ) = @_;

        # Local variables
        my (
            $matchFlag,
            @emptyList,
        );

        # Check for improper arguments
        if (
            ! defined $origListRef || ! defined $checkListRef || ! defined $ivHashRef
            || ! defined $checkHashRef || ! defined $argName || defined $check
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->findMxpArgByPosn', @_);
            return @emptyList;
        }

        # If the world specifies argument 'names' in the wrong order, reset $checkListRef so the
        #   current and subsequent calls to the function work as intended
        if (exists $$ivHashRef{lc($argName)}) {

            OUTER: foreach my $item (@$checkListRef) {

                if ($item eq lc($argName)) {

                    $matchFlag = TRUE;
                    last OUTER;
                }
            }

            if (! $matchFlag) {

                @$checkListRef = (@$origListRef);
            }
        }

        # If $argValue is 'undef', then it's not a 'name=value' construction but a 'value'
        #   construction. In this situation, the argument 'name' is the first remaining item in
        #   @$checkListRef
        # Exception: if 'name' exists in %$checkHashRef, then it's a 'name' construction (with no
        #   corresponding argument value)
        if (! defined $argValue && ! exists $$checkHashRef{lc($argName)}) {

            # 'value' construction
            $argValue = $argName;
            $argName = shift @$checkListRef;

            return ($argName, $argValue);
        }

        # Otherwise, it's a 'name' or a 'name=value' construction.
        # We must remove everything in @$checkListRef up to and including the matching argument
        #   name so that, if the next argument is not a name=value construction, we can set the
        #   'name' part to the following item in @$checkListRef
        $argName = lc($argName);
        do {

            my $item = shift @$checkListRef;

            if ($item eq $argName) {

                return ($argName, $argValue);
            }

        } until (! @$checkListRef);

        # Ran out of argument names, the calling function will report an error
        return @emptyList;
    }

    sub deleteMxpAttrib {

        # Called by $self->processMxpSupportElement
        # Updates a copy of GA::Client->constMxpAttribHash, which is in the form
        #   $tagHash{mxp_tag} = reference_to_list_of_tab_attributes
        # Removes an attribute from the list corresponding to a specified tag, and returns the
        #   updated hash
        #
        # Expected arguments
        #   $tag        - An MXP tag in lower-case (e.g. 'frame')
        #   $attrib     - One of the tag's attributes, also in lower-case (e.g. 'internal')
        #
        # Optional arguments
        #   %tagHash    - The hash to update. If the specified tag and/or attribute don't exist
        #                   in the hash, it is returned unmodified
        #
        # Return values
        #   An empty hash on improper arguments
        #   Otherwise, returns the modified hash

        my ($self, $tag, $attrib, %tagHash) = @_;

        # Local variables
        my (
            $listRef,
            @newList,
            %emptyHash,
        );

        # Check for improper arguments
        if (! defined $tag || ! defined $attrib) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->deleteMxpAttrib', @_);
            return %emptyHash;
        }

        # If the specified tag doesn't exist (has already been removed), make no changes
        if (! exists $tagHash{$tag}) {

            return %tagHash;
        }

        # Check the list corresponding to a specified tag and, if found, remove it
        $listRef = $tagHash{$tag};
        # MXP tags without attributes, such as <B>, appear as keys in %tagHash, but their
        #   corresponding value is 'undef' rather than a list reference
        if (defined $listRef) {

            foreach my $thisAttrib (@$listRef) {

                if ($thisAttrib ne $attrib) {

                    push (@newList, $thisAttrib);
                }
            }

            $tagHash{$tag} = \@newList;
        }

        return %tagHash;
    }

    sub createMxpFontTag {

        # Called by $self->processMxpModalElement, ->processMxpHtmlElement and ->popMxpStack
        # When a <FONT>...</FONT> construction is encountered, when both the opening and closing
        #   tags are processed, this function is called
        # Creates a dummy style tags in the form 'mxpf_monospace_bold_12' or 'mxpf_off'
        # GA::Obj::TextView->applyColourStyleTags extracts everything after the 'mxpf', and uses the
        #   extracted text to modify the 'mxp_font' Gtk3::TextTag
        #
        # Expected arguments
        #   %stackHash      - A hash of MXP text attributes and their settings, in the same format
        #                       as GA::Obj::TextView->mxpModalStackHash, but containing only
        #                       key-value pairs for changes that are about to be applied to
        #                       the current textview object's ->mxpModalStackHash
        #                   - e.g. { 'font_name' => 'Monospace', 'font_size' => '12' }
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns a dummy style tag in the forms 'mxpf_monospace_bold_12' or 'mxpf_off'

        my ($self, %stackHash) = @_;

        # Local variables
        my ($textViewObj, $tag);

        # Check for improper arguments
        if (! %stackHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createMxpFontTag', @_);
        }

        # Import IVs (for convenience)
        $textViewObj = $self->currentTabObj->textViewObj;

        # Create the dummy style tag
        if (
            ! $stackHash{'font_name'}
            && ! $stackHash{'font_size'}
            && ! $stackHash{'bold_flag'}
        ) {
            $tag = 'mxpf_off';

        } else {

            $tag = 'mxpf';

            if ($stackHash{'font_name'}) {
                $tag .= '_' . $stackHash{'font_name'};
            } else {
                $tag .= '_' . $textViewObj->font;
            }

            # (<BOLD> and <HIGH> are implemented the same way)
            if ($stackHash{'bold_flag'} || $stackHash{'high_flag'}) {

                $tag .= '_bold';
            }

            if ($stackHash{'font_size'}) {
                $tag .= '_' . int($stackHash{'font_size'});
            } else {
                $tag .= '_' . $textViewObj->fontSize;
            }

            if ($stackHash{'spacing'}) {

                $tag .= '_p' . int($stackHash{'spacing'});
            }
        }

        return $tag;
    }

    sub checkMxpSecureMode {

        # Called by $self->processIncomingData just after a token is processed and
        #   $self->mxpTempMode is set (to the value 0, 1 or 2)
        # If temp secure mode was on before that token was processed, and it's still on, then we
        #   can turn it off
        # If temp secure mode has been turned on by that token, check the next token that will be
        #   processed, which must be an 'mxp' token
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $tempMode   - The value of $self->mxpTempMode, before the most recent token was
        #                   processed (may be 'undef')
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list of equivalent Axmud colour/style tags, when required (may be
        #       an empty list)

        my ($self, $tempMode, $check) = @_;

        # Local variables
        my (
            $type,
            @emptyList, @tagList,
        );

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->checkMxpSecureMode', @_);
            return @emptyList;
        }

        if (defined $tempMode) {

            # MXP temporary secure mode has expired with the token just processed by the calling
            #   function
            push (@tagList, $self->setMxpLineMode($self->mxpTempMode, TRUE));
            $self->ivUndef('mxpTempMode');

        } else {

            # MXP temporary secure mode was applied by the token just processed by the calling
            #   function
            # Check that the next token to process is an MXP token
            $type = $self->ivFirst('currentTokenList');
            if (defined $type && $type ne 'mxp') {

                $self->mxpDebug('n/a', 'Temp secure mode not followed by an MXP tag', 5004);

                # Disable temp secure mode
                push (@tagList, $self->setMxpLineMode($self->mxpTempMode, TRUE));
                $self->ivUndef('mxpTempMode');
            }
        }

        return @tagList;
    }

    sub convertMxpWinSize {

        # Called by $self->processMxpFrameElement
        # When the world specifies a new frame using a <FRAME> tag, it can optionally specify the
        #   frame's size and position
        # Work out the equivalent size and position on the workspace, in pixels
        #
        # Expected arguments
        #   $frameObj   - The GA::Mxp::Frame object created in response to the <FRAME> tag
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list in the form (left, top, width, height)
        #   ...where 'left' and 'top' are the workspace coordinates of the top-left of the proposed
        #       window position, and 'width' / 'height' is the size of the window, all in pixels

        my ($self, $frameObj, $check) = @_;

        # Local variables
        my (
            $workspaceObj, $availableWidth, $availableHeight, $charWidth, $charHeight, $right,
            $bottom,
            @emptyList, @returnList,
        );

        # Check for improper arguments
        if (! defined $frameObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->convertMxpWinSize', @_);
        }

        # The frame is opened in the same workspace used by the session's 'main' window
        # The size of the available workspace is the current width and height, minus any space
        #   reserved for panels
        $workspaceObj = $self->mainWin->workspaceObj;
        $availableWidth = $workspaceObj->currentWidth - $workspaceObj->panelLeftSize
                            - $workspaceObj->panelRightSize;
        $availableHeight = $workspaceObj->currentHeight - $workspaceObj->panelTopSize
                            - $workspaceObj->panelBottomSize;

        # Get size of an 'X' character (because the MXP specification demands it)
        ($charWidth, $charHeight) = $self->currentTabObj->textViewObj->getCharSize('X');

        # $frameObj->left (etc) can be in the form 'n%' (a percentage), 'nc' (a multiple of
        #   character widths/heights) or 'n' (a value in pixels), relative to the left or top of the
        #   available desktop
        # A minus value, i.e. '-n%', '-nc' or '-n' signifies that the value is relative to the
        #   right or bottom of the available desktop
        # If an invalid value was specified, use a default value
        foreach my $iv ('left', 'top', 'width', 'height') {

            my ($value, $minus, $num, $type, $newValue);

            $value = $frameObj->$iv;

            if ($value =~ m/(\-)?(\d+)([\%c]?)/) {

                $minus = $1;
                $num = $2;
                $type = $3;

                if (! $type) {

                    if ($iv eq 'left' || $iv eq 'width') {

                        if ($minus) {
                            $newValue = $availableWidth - $num;
                        } else {
                            $newValue = $num;
                        }

                    } else {

                        if ($minus) {
                            $newValue = $availableHeight - $num;
                        } else {
                            $newValue = $num;
                        }
                    }

                } elsif ($type eq '%') {

                    if ($iv eq 'left' || $iv eq 'width') {

                        if ($minus) {
                            $newValue = $availableWidth - ($availableWidth * ($num / 100));
                        } else {
                            $newValue = $availableWidth * ($num / 100);
                        }

                    } else {

                        if ($minus) {
                            $newValue = $availableHeight - ($availableHeight * ($num / 100));
                        } else {
                            $newValue = $availableHeight * ($num / 100);
                        }
                    }

                } elsif ($type eq 'c') {

                    if ($iv eq 'left' || $iv eq 'width') {

                        # (Take into account spacing around the grid window's strip and table
                        #   objects by using $axmud::CLIENT->constGridSpacingPixels; it's not
                        #   exact, but it's good enough)
                        $newValue = ($charWidth * $num) + $workspaceObj->controlsLeftSize
                                        + $workspaceObj->controlsRightSize
                                        + ($axmud::CLIENT->constGridSpacingPixels * 2);

                        if ($minus) {

                            $newValue = $availableWidth - $newValue;
                        }

                    } else {

                        $newValue = ($charHeight * $num) + $workspaceObj->controlsTopSize
                                        + $workspaceObj->controlsBottomSize
                                        + ($axmud::CLIENT->constGridSpacingPixels * 2);

                        if ($minus) {

                            $newValue = $availableHeight - $newValue;
                        }
                    }
                }

            } else {

                # Invalid value, so use a default value
                if ($iv eq 'left' || $iv eq 'top') {
                    $newValue = 0;
                } elsif ($iv eq 'width') {
                    $newValue = int($availableWidth / 2);
                } else {
                    $newValue = int($availableHeight / 2);
                }
            }

            push (@returnList, $newValue);
        }

        # Sanity checking, for the benefit of an MXP frame tag which tries to draw a window outside
        #   the bounds of the desktop

        # Left
        if ($returnList[0] < 0) {

            $returnList[0] = 0;
        }

        # Top
        if ($returnList[1] < 0) {

            $returnList[1] = 0;
        }

        # Width
        $right = $returnList[0] + $returnList[2];                   # left + width
        if ($right > $availableWidth) {

            $returnList[2] = $availableWidth - $returnList[0];      # total width - left
        }

        # Height
        $bottom = $returnList[1] + $returnList[3];                  # top + height
        if ($bottom > $availableHeight) {

            $returnList[3] = $availableHeight - $returnList[1];     # total height - top
        }

        return @returnList;
    }

    sub getMxpFrame {

        # Can be called by anything
        # Looks up the name of an MXP frame (implemented as a Frame task window) and returns the
        #   corresponding frame object
        # The special name '_previous' refers to $self->mxpPrevFrame, a frame in $self->mxpFrameHash
        #   that could have any name, so all code should call this function rather than looking up a
        #   frame in $self->mxpFrameHash directly
        #
        # Expected arguments
        #   $name   - An MXP frame name - one of the keys in $self->mxpFrameHash, or '_previous'
        #
        # Return values
        #   'undef' on improper arguments or if the name doesn't match an MXP frame object
        #   Otherwise returns the matching MXP frame object

        my ($self, $name, $check) = @_;

        # Check for improper arguments
        if (! defined $name || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getMxpFrame', @_);
        }

        if ($name eq '_previous') {
            return $self->ivShow('mxpFrameHash', $self->mxpPrevFrame);
        } else {
            return $self->ivShow('mxpFrameHash', $name);
        }
    }

    sub removeMxpFrames {

        # Called by GA::Task::Frame->closeWin and ->del_winObj
        # If an MXP frame (implemented as a Frame task window) is closed, close all remanining
        #   frames (interior and exterior) and inform the world that frames are no longer supported
        # (In other words, if the user manually closes a Frame task window, there's no way of
        #   informing the world that one frame is not available but any others still are, so we
        #   have to stop using frames altogether)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->removeMxpFrames', @_);
        }

        # Close exterior frames (Frame task windows), but don't close interior frames (pane objects)
        foreach my $frameObj ($self->ivValues('mxpFrameHash')) {

            if ($frameObj->taskObj && $frameObj->taskObj->winObj) {

                $frameObj->taskObj->closeWin();
            }
        }

        # Reset IVs. Internal frames will continue to exist, but nothing will write to them
        $self->ivEmpty('mxpFrameHash');
        $self->ivUndef('mxpCurrentFrame');
        $self->ivUndef('mxpPrevFrame');
        $self->ivPoke('currentTabObj', $self->defaultTabObj);

        # Inform the world by processing a fake <SUPPORT> tag
        $self->ivPoke('mxpDisableFrameFlag', TRUE);
        $self->processMxpSupportElement(
            '<SUPPORT>',
            'open',
            'SUPPORT',
        );

        return 1;
    }

    sub convertMxpImageSize {

        # Called by $self->processMxpImageElement
        # When the world specifies an image using an <IMAGE> tag, it can optionally specify the
        #   image's width and height. These values can be expressed as in integers (the size in
        #   pixels), in the form 'nc' (size in characters) or 'n%' (size in percentage of the
        #   available space).
        # Convert a value (either width or height) to a size in pixels
        #
        # Expected arguments
        #   $value      - The value to convert (in the form 'n', 'nc' or 'n%')
        #   $mode       - 'width' to convert a width, 'height' to convert a height
        #
        # Return values
        #   'undef' on improper arguments or if $val isn't in the form 'n', 'nc' or 'n%'
        #   Otherwise, the converted size in pixels

        my ($self, $value, $mode, $check) = @_;

        # Local variables
        my ($num, $type, $charWidth, $charHeight, $rectObj);

        # Check for improper arguments
        if (! defined $value || ! defined $mode || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->convertMxpImageSize', @_);
        }

        if ($value =~ m/(\d+)([\%c]?)/) {

            $num = $1;
            $type = $2;

            if (! $type) {

                # $value was already in pixels
                return int($value);

            } elsif ($type eq 'c') {

                # Get size of an X character
                ($charWidth, $charHeight) = $self->currentTabObj->textViewObj->getCharSize('X');
                if ($mode eq 'width') {

                    return int($num * $charWidth);

                } elsif ($mode eq 'height') {

                    return int($num * $charHeight);

                } else {

                    # Emergency default
                    return undef;
                }

            } else {

                # Get the size of the default tab's textview, in pixels
                # Convert the percentage into a fraction (e.g. convert 50% into 0.5)
                $num /= 100;

                # Get a Gtk3::Gdk::Rectangle
                $rectObj = $self->defaultTabObj->textViewObj->textView->get_visible_rect();
                if ($mode eq 'width') {

                    return int($num * $rectObj->width);

                } elsif ($mode eq 'height') {

                    return int($num * $rectObj->height);

                } else {

                    # Emergency default
                    return undef;
                }
            }

        } else {

            # Invalid image size format (not 'n', 'nc' or 'n%'
            return undef;
        }
    }

    sub updateMxpGauges {

        # Called by $self->spinMaintainLoop
        # When an MXP entity is modified (including being created or deleted), an entry is added
        #   to $self->mxpGaugeUpdateHash
        # Once per maintenance loop, this function is called. The function checks whether any of the
        #   modified entities have corresponding 'main' window gauges and, if so, updates the
        #   GA::Obj::Gauge objects and redraws the gauges
        # This function also checks the world profile's ->mxpStatusVarHash. If a modified MXP
        #   entity has an equivalent Status task variable, it is passed on to the Status task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $updateFlag,
            @deleteList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateMxpGauges', @_);
        }

        foreach my $entName ($self->ivKeys('mxpGaugeUpdateHash')) {

            my ($entityObj, $gaugeObj, $taskVar);

            # If the entity has been deleted, its gauge must be removed
            if (! $self->ivExists('mxpEntityHash', $entName)) {

                push (@deleteList, $entName);

            } else {

                # Update the corresponding gauge object, if it has actually been created
                $gaugeObj = $self->ivShow('mxpGaugeHash', $entName);
                if ($gaugeObj) {

                    $updateFlag = TRUE;
                    $entityObj = $self->ivShow('mxpEntityHash', $entName);

                    if (defined $entityObj) {

                        if ($gaugeObj->mxpEntity eq $entName) {
                            $gaugeObj->ivPoke('value', $entityObj->value);
                        } else {
                            $gaugeObj->ivPoke('maxValue', $entityObj->value);
                        }
                    }
                }

                # Pass the value on to the Status task, if possible
                $taskVar = $self->currentWorld->ivShow('mxpStatusVarHash', $entName);
                if (defined $taskVar && $self->statusTask && defined $entityObj) {

                    $self->statusTask->setValue($taskVar, $entityObj->value);
                }
            }
        }

        if (@deleteList) {

            $self->removeMxpGauges(@deleteList);

        } elsif ($updateFlag) {

            $self->mxpGaugeStripObj->updateGauges();
        }

        # All corresponding gauges have been updated
        $self->ivEmpty('mxpGaugeUpdateHash');

        return 1;
    }

    sub removeMxpGauges {

        # Called by various functions
        # Removes MXP gauges created by $self->processMxpGaugeElement and updates IVs.
        # If there are no more gauges left on the gauge level allocated to MXP, the gauge level
        #   itself is also removed
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   @list       - A list of MXP entity names; any matching MXP gauges are removed. If it's
        #                   an empty list, ALL MXP gauges are removed
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, @list) = @_;

        # Local variables
        my @modList;

        # (No improper arguments to check)

        if ($self->mxpGaugeStripObj) {

            # If no entity names were specified, remove all MXP gauges
            if (! @list) {

                if ($self->mxpGaugeLevel) {

                    $self->mxpGaugeStripObj->removeGaugeLevel($self, $self->mxpGaugeLevel);
                }

                $self->ivUndef('mxpGaugeLevel');
                $self->ivEmpty('mxpGaugeHash');

            # Otherwise, compile a list of MXP gauges to remove. If entity names were specified,
            #   check corresponding MXP gauges actually exist.
            } else {

                foreach my $name (@list) {

                    if ($self->ivExists('mxpGaugeHash', $name)) {

                        push (@modList, $self->ivShow('mxpGaugeHash', $name));
                    }
                }

                if (@modList) {

                    # The FALSE argument means 'don't keep an empty gauge level'
                    $self->mxpGaugeStripObj->removeGauges($self, FALSE, @modList);

                    foreach my $obj (@modList) {

                        $self->ivDelete('mxpGaugeHash', $obj->number);
                    }

                    if (! $self->mxpGaugeHash) {

                        # All gauges have been removed
                        $self->mxpGaugeStripObj->removeGaugeLevel($self, $self->mxpGaugeLevel);
                        $self->ivUndef('mxpGaugeLevel');
                    }
                }
            }
        }

        return 1;
    }

    sub mxpDoRelocate {

        # Called by $self->incomingDataLoop
        # Initiaties an MXP crosslinking operation. Closes the current connection and opens a new
        #   one
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mxpDoRelocate', @_);
        }

        # If auto-saves are turned on, do an auto-save now
        if ($axmud::CLIENT->autoSaveFlag && $self->autoSaveCheckTime) {

            # Perform the auto-save
            $self->pseudoCmd('save');
            $self->ivPoke('autoSaveLastTime', $self->sessionTime);
            # Set the time at which the next auto-save will occur
            $self->resetAutoSave();
        }

        # Update the connection info strip object for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            $winObj->setHostLabel(
                $self->getHostLabelText(),
                'MXP crosslinking operation in progress...',
            );
        }

        # Terminate the current connection
        $self->doTempDisconnect();
        $self->ivPoke('mxpRelocateMode', 'started');

        # Make sure all changes are visible immediately
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->dispatchCmd');

        # Intitiate the new connection
        if (! $self->doConnect($self->mxpRelocateHost, $self->mxpRelocatePort, $self->protocol)) {

            # Reconnection failed
            $self->doDisconnect();

        } else {

            $self->ivPoke('mxpRelocateMode', 'wait_login');
        }

        return 1;
    }

    sub applyMxpFileFilter {

        # Called by $self->processMxpImageElement and ->processMspSoundTrigger
        # Given a full file path, applies the MXP file filter
        # If the world has provided a plugin to convert an image/sound file its own format into a
        #   format supported by Axmud, call the plugin, which performs the conversion and returns
        #   the path to the converted file
        #
        # Expected arguments
        #   $path       - Full file path to the image/sound file to convert, e.g.
        #                   '/home/myname/axmud-data/deathmud/mxp/myimage.gff'
        #
        # Return values
        #   'undef' on improper arguments or if the file can't be converted
        #   Otherwise returns the file path to the converted file (which the calling function will
        #       delete, after it's used), e.g. '/home/myname/axmud-data/deathmud/mxp/myimage.gif'

        my ($self, $path, $check) = @_;

        # Local variables
        my ($file, $dir, $ext, $filterObj, $pluginObj, $funcRef);

        # Check for improper arguments
        if (! defined $path || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->applyMxpFileFilter', @_);
        }

        ($file, $dir, $ext) = File::Basename::fileparse($path, qr/\.[^.]*/);
        $ext =~ s/^\.//;

        # Has the world specified a file filter for this file extension?
        $filterObj = $self->ivShow('mxpFilterHash', $ext);
        if (! defined $filterObj) {

            # No file filter found, so ignore the file
            return undef;
        }

        # Does the named plugin exist, and is it enabled?
        $pluginObj = $axmud::CLIENT->ivShow('pluginHash', $filterObj->name);
        if (! defined $pluginObj || ! $pluginObj->enabledFlag) {

            # Plugin not available, so ignore the file
            return undef;
        }

        # Has the plugin informed the GA::Client of which function to call, and is it a valid
        #   function?
        $funcRef = $axmud::CLIENT->ivShow('pluginMxpFilterHash', $filterObj->name);
        if (! defined $funcRef || ref($funcRef) ne 'CODE') {

            # Conversion function not specified, so ignore the file
            return undef;
        }

        # Apply the file filter to convert the file from the world's own image format into a
        #   .gif or .bmp format
        $path = &$funcRef($path, $filterObj->src, $filterObj->dest, $filterObj->proc);
        if (! $path) {

            # Conversion failed, so ignore the file
            return undef;

        } else {

            return $path;
        }
    }

    sub replaceMxpEntities {

        # Called by $self->processMxpSendElement()
        # A modified version of $self->processMxpEntity()
        # A <SEND>...</SEND> construction may contain one or more entities. The MXP documentation
        #   specifies a special purpose for %text; but some MUDs might want to use other entities
        # Given a string for which %text; has already been substituted for its values, look for
        #   more entities and, if found, substitute them for their values
        #
        # NB To avoid unfortunate accidents, this function ignores invalid entities, rather than
        #   issuing an error via a call to $self->mxpDebug
        #
        # Expected arguments
        #   $string     - The string containing zero, one or more entities
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the modified value of $string (or the original value, if no valid
        #       entities are found)

        my ($self, $string, $check) = @_;

        # Check for improper arguments
        if (! defined $string || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->replaceMxpEntities', @_);
        }

        # Handle entities in the form '&#nnn;'
        while ($string =~ m/(\&\#([0-9]{1,3})\;)/) {

            my ($enText, $enNum, $enReplace);

            $enText = $1;
            $enNum = $2;
            # We'll assume that 'nnn' can be '064' as well as '64'. Convert it to a numeric value
            $enNum = $enNum += 0;

            # Ignore numbers not in the range 32-255
            if ($enNum >= 32 && $enNum <= 255) {

                $enReplace = chr($enNum);
                $string =~ s/$enText/$enReplace/g;
            }
        }

        # Handle entities in the form '&NAME;'
        while ($string =~ m/(\&([A-Za-z][A-Za-z0-9_]*)\;)/) {

            my ($enText, $enName, $enReplace, $entityObj);

            $enText = $1;
            $enName = $2;

            # Does an entity called $enName exist?
            if (! $self->ivExists('mxpEntityHash', $enName)) {

                # Standard entity names don't have their own GA::Mxp::Entity object
                if ($axmud::CLIENT->ivExists('constMxpEntityHash', $enName)) {

                    $enReplace = $axmud::CLIENT->ivShow('constMxpEntityHash', $enName);
                    $string =~ s/$enText/$enReplace/g;

                } else {

                    # Git #18: Handle unrecognised entity names by removing (and ignoring) them
                    $string =~ s/$enText//g;
                }

            } else {

                # Replace the named entity with its value
                $entityObj = $self->ivShow('mxpEntityHash', $enName);
                $enReplace = $entityObj->value;
                $string =~ s/$enText/$enReplace/g;
            }
        }

        # Operation complete
        return $string;
    }

    sub generateMcpKey {

        # Called by $self->detectMCP (only) during the initial MCP negotiation
        # Generates a random key for the server to use as an authentification key, every time it
        #   sends us an MCP message
        # Sends the key to the server
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($length, $string);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->generateMcpKey', @_);
        }

        # Length of string is 10-20 characters
        $length = int(rand(11));
        $length += 10;

        $string = '';
        for (my $count = 0; $count < $length; $count++) {

            # ASCII 65-90 are the letters A-Z
            my $num = int(rand(36));

            if ($num < 10) {

                # Add a numeral character
                $string .= $num;

            } else {

                # Add a letter
                $num += 55;
                $string .= chr($num);
            }
        }

        # Store the key
        $self->ivPoke('mcpAuthKey', $string);

        # Send the key to the server
        $self->send(
            '#$#mcp authentication-key: ' . $self->mcpAuthKey . ' version: 2.1 to: 2.1',
        );

        return 1;
    }

    sub extractMcpArgs {

        # Called by $self->processMcpMsg
        # Extracts an optional series of key-value pairs from a partial MCP out-of-bounds line
        #   with everything except the key-value pairs already extracted), and returns them as a
        #   list
        #
        # Expected arguments
        #   $origToken  - The text of the token containg the MCP message
        #   $msg        - The MCP message name, e.g. 'mcp-negotiate-can'
        #   $string     - A string containing the key-value pairs in the usual MCP format, e.g.
        #                   'package: edit min-version: 1.0 max-version: 1.0'. Might be an empty
        #                   string (or a string containing only space characters), in which case an
        #                   empty list is returned
        #
        # Return values
        #   An empty list on improper arguments or if there's an error
        #   Otherwise returns a list in the form (mode, key, value, key, value...), where 'mode'
        #       is 'normal' if there are no multi-line values, and 'multi' if there is at least
        #       one multi-line value

        my ($self, $origToken, $msg, $string, $check) = @_;

        # Local variables
        my (
            $multiObj,
            @emptyList, @normalList, @multiList, @dataTagList,
            %checkHash,
        );

        # Check for improper arguments
        if (! defined $origToken || ! defined $msg || ! defined $string || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMcpArgs', @_);
            return @emptyList;
        }

        do {

            my ($key, $value, $portion, $multiFlag);

            if ($string =~ m/^\s*$/) {

                # All text extracted; exit the loop
                $string = '';

            } else {

                # Extract a key, in the form 'key: ' or 'key*: '
                if ($string =~ m/^(\s*([A-Za-z][A-Za-z0-9\-]*)\s*\:\s*)/) {

                    $key = $2;
                    $portion = $1;

                } elsif ($string =~ m/^(\s*([A-Za-z][A-Za-z0-9\-]*)\s*\*\s*\:\s*)/) {

                    $key = $2;
                    $portion = $1;
                    $multiFlag = TRUE;

                } else {

                    $self->mcpDebug($origToken, 'Invalid key in key-value pair list', 8300);
                    return @emptyList
                }

                $string = substr($string, length($portion));

                # Extract a simple value, as an unquoted or a quoted string
                if (
                    $string
                    =~ m/^([\-\~\`\!\@\#\$\%\^\&\(\)\=\+\{\}\[\]\|\'\;\?\/\>\<\.\,A-Za-z0-9\_]+)/
                ) {
                    # Unquoted string
                    $value = $1;
                    $portion = $1;

                } elsif (substr($string, 0, 2) eq '""') {

                    # Empty quoted string
                    $value = '';
                    $portion = '';

                } elsif ($string =~ m/^(\"(.*[^\\])\")/) {

                    # Quoted string, in the form "...", with any escaped quotes, \", counting as a
                    #   part of the string
                    $value = $2;
                    $portion = $1;

                } else {

                    $self->mcpDebug($origToken, 'Invalid value in key-value pair list', 8301);
                    return @emptyList;
                }

                $string = substr($string, length($portion));

                # Keys are case-insensitive. Convert them all to lower case
                $key = lc($key);

                # Keys must not be duplicated (and it's an error if they are)
                if (exists $checkHash{$key}) {

                    $self->mcpDebug($origToken, 'Duplicate key in key-value pair list', 8302);
                    return @emptyList;

                } else {

                    $checkHash{$key} = undef;
                }

                # If the key was followed by an asterisk, this corresponding value is ignored; the
                #   value is instead set over multiple MCP messages beginning #$#*
                if ($key eq '_data-tag') {

                    # For MCP messages with multiline values, the SPEC suggests the '_data-tag'
                    #   key occurs last, but that's not defined
                    # Put this key in its value in a separate list; if this MCP message doesn't use
                    #   multiline values, then it's an ordinary key we can put back in @normalList
                    #   at the end of this function
                    push (@dataTagList, $key, $value);

                } elsif (! $multiFlag) {

                    # Ordinary key/value pair
                    push (@normalList, $key, $value);

                } else {

                    # Key with multiline value. The value specified on the line is ignored (it's
                    #   probably a literal "" anyway)
                    push (@multiList, $key, '');
                }
            }

        } until ($string eq '');

        # Key/value pairs extracted
        if (@multiList) {

            # At least one multiline value was specified, so we need to wait for the remaining
            #   MCP messages

            # Check that @dataTagList contains exactly two items (the '_data-tag' key, and a valid
            #   value)
            if (! @dataTagList) {

                $self->mcpDebug(
                    $origToken,
                    'Missing data tag in MCP message specifying multiline value(s)',
                    8310,
                );

                return @emptyList;

            } elsif ((scalar @dataTagList) > 2 ) {

                $self->mcpDebug(
                    $origToken,
                    'Multiple data tags in MCP message specifying multiline value(s)',
                    8311,
                );

                return @emptyList;

            } else {

                # Multiline data tags should be unique, at least until the server has finished
                #   sending the multiline data
                # Check that neither the server nor we are sending a different set of multiline data
                #   using the same data tag
                if ($self->ivExists('mcpMultiObjHash', $dataTagList[1])) {

                    $self->mcpDebug(
                        $origToken,
                        'Data tag tag in MCP message specifying multiline value(s) is already in'
                        . ' use',
                        8312,
                    );

                    return @emptyList;
                }

                # Create a new MCP multiline object to store the multiline data until the server has
                #   finished sending it
                $multiObj = Games::Axmud::Mcp::Obj::MultiLine->new(
                    $dataTagList[1],
                    $msg,
                    \@normalList,
                    \@multiList,
                );

                if (! $multiObj) {

                    $self->mcpDebug($origToken, 'Internal error processing multiline data', 8312);
                    return @emptyList;

                } else {

                    $self->ivAdd('mcpMultiObjHash', $multiObj->name, $multiObj);

                    # Wait until the rest of the multiline data arrives, before passing the MCP
                    #   key/value pairs to a package
                    return ('multi');
                }
            }

        } else {

            # Operation complete with no multiline values
            return ('normal', @normalList, @dataTagList);
        }
    }

    sub processMcpContinuation {

        # Called by $self->processMcpMsg to process the main portion of an MSP multiline
        #   continuation
        #
        # Expected arguments
        #   $origToken  - The text of the token containg the MCP message
        #   $string     - The line portion to process, in the form
        #                   '<space><data tag><space><key>:<space><value>'
        #
        # Return values
        #   'undef' on improper arguments or if the continuation line is invalid
        #   1 otherwise

        my ($self, $origToken, $string, $check) = @_;

        # Local variables
        my ($dataTag, $key, $value, $multiObj, $oldValue);

        # Check for improper arguments
        if (! defined $origToken || ! defined $string || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMcpContinuation', @_);
        }

        # Extract the data tag
        if ($string =~ m/^(\s*([A-Za-z][A-Za-z0-9\-]*)\s*)/) {

            $dataTag = $2;
            $string = substr($string, length($1));

        } else {

            $self->mcpDebug($origToken, 'Invalid data tag in multiline continuation', 8400);
            return undef;
        }

        # Extract the key and value
        if ($string =~ m/^(([A-Za-z][A-Za-z0-9\-]*)\s*\:\s)/) {

            $key = $2;
            $value = substr($string, length($1));

        } else {

            $self->mcpDebug($origToken, 'Invalid key and/or value in multiline continuation', 8401);
            return undef;
        }

        # Check that the data tag was specified by a previous MCP message with multiline values
        $multiObj = $self->ivShow('mcpMultiObjHash', $dataTag);
        if (! $multiObj) {

            $self->mcpDebug($origToken, 'Unrecognised data tag in multiline continuation', 8402);
            return undef;
        }

        # Check that the key is one of those whose value can be updated
        if ($multiObj->ivExists('normalHash', $key)) {

            $self->mcpDebug(
                $origToken,
                'Multiline continuation tried to update a key whose value is not a mutliline value',
                8403,
            );

            return undef;

        } elsif (! $multiObj->ivExists('multiHash', $key)) {

            $self->mcpDebug(
                $origToken,
                'Multiline continuation tried to update an undefined key',
                8404,
            );

            return undef;
        }

        # Update the existing value, separating each line with a newline character
        $oldValue = $multiObj->ivShow('multiHash', $key);
        if ($oldValue eq '') {
            $multiObj->ivPoke('multiHash', $key, $value);
        } else {
            $multiObj->ivPoke('multiHash', $key, "$oldValue\n$value");
        }

        return 1;
    }

    sub mcpSendMsg {

        # Can be called by anything (usually by code in an MCP package object)
        # Sends an MCP message to the server. Adds the initial '#$#', adds the authentification key
        #   for this session, lays out key/value pairs in the correct format, and strips the message
        #   of any non-7-bit ASCII characters
        # NB All MCP messages to the server should be sent using this function, except for
        #   the first one, which is sent by $self->generateMcpKey()
        # NB MCP messages with multiline values can be sent manually by calling this function
        #   several times, or by calling $self->mcpSendMultiLine once
        #
        # Expected arguments
        #   $name   - The message name, e.g. 'mcp-negotiate-can'
        #
        # Optional arguments
        #   @list   - An optional list of arguments, in the form (key, value, key, value...). If
        #               specified, must contain an even number of items (i.e. the value must not be
        #               'undef', but it could be an empty string). Duplicates keys are ignored.
        #               Keys are case insensitive, and are converted to lower case before being sent
        #
        # Return values
        #   'undef' on improper arguments, if MCP isn't enabled in this session, if the
        #       authentification key hasn't been set, if @list contains an odd number of items or if
        #       sending the message fails
        #   1 otherwise

        my ($self, $name, @list) = @_;

        # Local variables
        my (
            $text, $modText,
            %checkHash,
        );

        # Check for improper arguments
        if (! defined $name) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpSendMsg', @_);
        }

        # Check MCP is enabled
        if (! $self->mcpMode eq 'client_agree' || ! defined $self->mcpAuthKey) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP message not sent - MCP not enabled in this session',
                8500,
            );

            return undef;
        }

        # Compose the message
        $text = '#$#' . $name . ' ' . $self->mcpAuthKey;

        if (@list) {

            do {

                my ($key, $value);

                $key = lc(shift @list);
                $value = shift @list;

                if (! defined $key || ! defined $value) {

                    $self->mcpDebug(
                        '<outgoing MCP message>',
                        'MCP message not sent - undefined key and/or value',
                        8501,
                    );

                    return undef;

                } elsif (exists $checkHash{$key}) {

                   $self->mcpDebug(
                        '<outgoing MCP message>',
                        'Duplicate key ignored in MCP message to be sent',
                        8502,
                    );

                } elsif (
                    $value
                    =~ m/^[\-\~\`\!\@\#\$\%\^\&\(\)\=\+\{\}\[\]\|\'\;\?\/\>\<\.\,A-Za-z0-9\_]+$/
                ) {
                    # Value is an unquoted string
                    $text .= ' ' . $key . ': ' . $value;
                    # Guard against duplicate keys
                    $checkHash{$key} = undef;

                } else {

                    # Value is a quoted string. Replace any " with \"
                    $value =~ s/\"/\\\"/;
                    $text .= ' ' . $key . ': "' . $value . '"';
                }

            } until (! @list);
        }

        # Strip non-7-bit ASCII characters
        $modText = '';
        foreach my $char (split (//, $text)) {

            if (ord($char) < 127) {

                $modText .= $char;
            }
        }

        # Send the message to the world
        return $self->send($modText);
    }

    sub mcpSendMultiLine {

        # Can be called by anything (usually by code in an MCP package object)
        # An alternative to $self->mcpSendMsg, when we want to send MCP messages with multiline
        #   values to the server
        #
        # Expected arguments
        #   $name   - The message name, e.g. 'mcp-negotiate-can'
        #
        # Optional arguments
        #   @list   - An optional list of arguments, in the form (key, value, key, value...). If
        #               specified, must contain an even number of items (i.e. the value must not be
        #               'undef', but it could be an empty string). Duplicates keys are ignored.
        #               Keys are case insensitive, and are converted to lower case before being sent
        #           - In the list, values can be either scalars or list references. If a scalar,
        #               it's a normal value. If a list reference, it's a multiline value; each item
        #               in the list is sent on a separate MCP out-of-bounds line
        #
        # Return values
        #   'undef' on improper arguments, if MCP isn't enabled in this session, if the
        #       authentification key hasn't been set, if @list contains an odd number of items or if
        #       sending the messages fails
        #   1 otherwise

        my ($self, $name, @list) = @_;

        # Local variables
        my (
            $dataTag, $text,
            @multiList,
            %checkHash,
        );

        # Check for improper arguments
        if (! defined $name) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpSendMultiLine', @_);
        }

        # Check MCP is enabled
        if (! $self->mcpMode eq 'client_agree' || ! defined $self->mcpAuthKey) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP multiline message not sent - MCP not enabled in this session',
                8510,
            );

            return undef;
        }

        # Generate a data tag that's not already in use. Use an 8-character tag
        do {

            $dataTag = '';
            for (my $count = 0; $count < 8; $count++) {

                # ASCII 65-90 are the letters A-Z
                my $num = int(rand(36));

                if ($num < 10) {

                    # Add a numeral character
                    $dataTag .= $num;

                } else {

                    # Add a letter
                    $num += 55;
                    $dataTag .= chr($num);
                }
            }

        } until (! $self->ivExists('mcpMultiObjHash', $dataTag));

        # Compose the (first) MCP message
        $text = '#$#' . $name . ' ' . $self->mcpAuthKey;

        if (@list) {

            do {

                my ($key, $value);

                $key = lc(shift @list);
                $value = shift @list;

                if (! defined $key || ! defined $value) {

                    $self->mcpDebug(
                        '<outgoing MCP message>',
                        'MCP multiline message not sent - undefined key and/or value',
                        8511,
                    );

                    return undef;

                } elsif (exists $checkHash{$key}) {

                    $self->mcpDebug(
                        '<outgoing MCP message>',
                        'Duplicate key ignored in MCP multiline message to be sent',
                        8512,
                    );

                } elsif (ref($value) eq 'HASH') {

                    # Multiline value. The value specified on this line is ignored by the server
                    $text .= ' ' . $key . ': ""';

                    push (@multiList,
                        '#$#* ' . $dataTag . ' ' . $key . ': ' . $value,
                    );

                } elsif (
                    $value
                    =~ m/^[\-\~\`\!\@\#\$\%\^\&\(\)\=\+\{\}\[\]\|\'\;\?\/\>\<\.\,A-Za-z0-9\_]+$/
                ) {
                    # Simple value is an unquoted string
                    $text .= ' ' . $key . ': ' . $value;
                    # Guard against duplicate keys
                    $checkHash{$key} = undef;

                } else {

                    # Simple value is a quoted string. Replace any " with \"
                    $value =~ s/\"/\\\"/;
                    $text .= ' ' . $key . ': "' . $value . '"';
                }

            } until (! @list);
        }

        # At least one multiline value
        foreach my $item ($text, @multiList) {

            # Strip non-7-bit ASCII characters
            my $modItem = '';

            foreach my $char (split (//, $item)) {

                if (ord($char) < 127) {

                    $modItem .= $char;
                }
            }

            # Send the message to the world
            $self->send($modItem);
        }

        # Operation complete
        return 1;
    }

    sub mcpCordOpen {

        # Can be called by anything
        # Opens an MCP cord. Generates unique ID and sends it to the server, then updates IVs
        #
        # MCP cords can be opened by the server (the world) or by the client (Axmud)
        #
        # Any part of the Axmud code (including plugins) can open an MCP cord
        #   - Call $self->add_mcpCordType to register the cord type, in case the server wants to
        #       open a cord using the same type
        #       - You must provide a function that's called when the server opens a cord of that
        #           type
        #       - That function must (at a minimum) call $self->accept_mcpCordID or
        #           $self->refuse_mcpCordID
        #   - Call $self->mcpCordOpen (this function!) to open the cord
        #   - Call $self->mcpCordMsg to send a message along the cord
        #   - Call $self->mcpCordClose to close the cord
        #       - Once a cord has been closed, it can't be re-opened (but you can open a new cord
        #           using the same type)
        #
        # Any part of the Axmud code (including plugins) can accept cords open by the server
        #   - Call $self->add_mcpCordType to register the cord type, in case the server wants to
        #       open a cord using the same type
        #       - You must provide a function that's called when the server opens a cord of that
        #           type
        #       - That function must (at a minimum) call $self->accept_mcpCordID or
        #           $self->refuse_mcpCordID
        #
        #   Example calls:
        #       $session->add_mcpCordType(
        #           'whiteboard',               # Cord type
        #           \&respond_to_cord_open,
        #       );
        #
        #       $session->accept_mcpCordID(
        #           12345678,                   # Cord ID. Initial I/R will be removed if you use it
        #           \&respond_to_cord_message,
        #           \&respond_to_cord_close,
        #       );
        #
        #       $session->refuse_mcpCordID(
        #           12345678,                   # Cord ID. Initial I/R will be removed if you use it
        #       );
        #
        #       $session->mcpCordOpen(
        #           'whiteboard',               # Cord type
        #           \&respond_to_cord_message,
        #           \&respond_to_cord_close,
        #       );
        #
        #       $session->mcpCordMsg(
        #           12345678,                   # Cord ID. Initial I/R will be removed if you use it
        #           'delete-stroke',            # Cord message (i.e. value of the '_message' key
        #           $key,                       # Optional key-value pairs as arguments
        #           $value,
        #           $key,
        #           $value,
        #       );
        #
        #       $session->mcpCordClose(
        #           12345678,                   # Cord ID. Initial I/R will be removed if you use it
        #       );
        #
        # Expected arguments
        #   $type       - The MCP cord type. Should already exist as a key in
        #                   $self->mcpCordOpenHash, in case the server initiates a cord of that
        #                   type. Call $self->add_mcpCordType to add an entry to that IV before
        #                   calling this function, if no entry already exists
        #   $msgFuncRef
        #               - Reference to a function which should be called every time the server sends
        #                   us an MCP message on this cord
        #   $closeFuncRef
        #               - Reference to a function which should be called when the server closes the
        #                   cord
        #
        # Return values
        #   'undef' on improper arguments, if MCP isn't enabled in this session or if the MCP cord
        #       type doesn't exist in $self->mcpCordOpenHash
        #   Otherwise returns the unique ID for this cord with the initial I/R character removed,
        #       e.g. '12345678' (matching the form of keysin $self->mcpCordIDHash and
        #       $self->mcpCordCloseHash)

        my ($self, $type, $msgFuncRef, $closeFuncRef, $check) = @_;

        # Local variables
        my $id;

        # Check for improper arguments
        if (
            ! defined $type || ! defined $msgFuncRef || ! defined $closeFuncRef
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpCordOpen', @_);
        }

        # Check MCP is enabled, and that the specified $type has been defined
        if (! $self->mcpMode eq 'client_agree' || ! defined $self->mcpAuthKey) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord not opened - MCP not enabled in this session', 8600,
            );

            return undef;

        } elsif (! $self->ivExists('mcpCordOpenHash', $type)) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord not opened - unrecognised cord type', 8601,
            );

            return undef;
        }

        # Generate a unique ID
        do {

            my $length;

            # The full ID is the letter 'I' (for 'initiator') or 'R' (for 'recipient'), followed by
            #   8-16 random integers
            $length = int(rand(9));
            $length += 8;

            $id = int(rand($length + 1));

        } until (! $self->ivExists('mcpCordIDHash', $id));

        # Send the ID to the server, which opens the cord
        $self->mcpSendMsg(
            'mcp-cord-open',
                '_id',
                # Axmud is always the 'recipient' of MCP negotiations, never the 'initiator'
                'R' . $id,
                '_type',
                $type,
        );

        # Update IVs
        $self->ivAdd('mcpCordIDHash', $id, $msgFuncRef);
        $self->ivAdd('mcpCordCloseHash', $id, $closeFuncRef);

        return $id;
    }

    sub mcpCordMsg {

        # Can be called by anything
        # Sends a message to the server along an MCP cord
        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions
        #
        # Expected arguments
        #   $id         - The MCP cord ID, one of the keys in $self->mcpCordIDHash. It doesn't need
        #                   to begin with an initial 'I' or 'R' character, because this function
        #                   will add an initial 'R'
        #   $cordMsg    - The cord message
        #
        # Optional arguments
        #   @list       - An optional list of arguments, in the form (key, value, key, value...). If
        #                   specified, must contain an even number of items (i.e. the value must not
        #                   be 'undef', but it could be an empty string)
        #
        # Return values
        #   'undef' on improper arguments, if MCP isn't enabled in this session, if the MCP cord ID
        #       doesn't exist in $self->mcpCordIDHash, if the cord has been closed or if the MCP
        #       message can't be sent to the world
        #   1 otherwise

        my ($self, $id, $cordMsg, @list) = @_;

        # Check for improper arguments
        if (! defined $id || ! defined $cordMsg) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpCordMsg', @_);
        }

        # Axmud stores MCP cord IDs with the initial I/R character removed. If the calling function
        #   has specified one of those characters, remove it
        $id =~ s/^[IR]//;

        # Check MCP is enabled, and that the specified cord has been opened and not yet closed
        if (! $self->mcpMode eq 'client_agree' || ! defined $self->mcpAuthKey) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord message not sent - MCP not enabled in this session',
                8610,
            );

            return undef;

        } elsif (! $self->ivShow('mcpCordIDHash', $id)) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord message not sent - unrecognised or closed cord',
                8611,
            );

            return undef;
        }

        # Send the cord message
        return $self->mcpSendMsg(
            'mcp-cord',
                '_id',
                # Axmud is always the 'recipient' of MCP negotiations, never the 'initiator'
                'R' . $id,
                '_message',
                $cordMsg,
                @list,
        );
    }

    sub mcpCordClose {

        # Can be called by anything
        # Closes an open MCP cord
        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions
        #
        # Expected arguments
        #   $id         - The MCP cord ID, one of the keys in $self->mcpCordIDHash. It doesn't need
        #                   to begin with an initial 'I' or 'R' character, because this function
        #                   will add an initial 'R'
        #
        # Return values
        #   'undef' on improper arguments, if MCP isn't enabled in this session, if the MCP cord ID
        #       doesn't exist in $self->mcpCordIDHash, if the cord has already been closed or if the
        #       'mcp-cord-close' message can't be sent to the world
        #   1 otherwise

        my ($self, $id, $check) = @_;

        # Check for improper arguments
        if (! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpCordClose', @_);
        }

        # Axmud stores MCP cord IDs with the initial I/R character removed. If the calling function
        #   has specified one of those characters, remove it
        $id =~ s/^[IR]//;

        # Check MCP is enabled, and that the specified cord has been opened and not yet closed
        if (! $self->mcpMode eq 'client_agree' || ! defined $self->mcpAuthKey) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord message not sent - MCP not enabled in this session',
                8620,
            );

            return undef;

        } elsif (! $self->ivShow('mcpCordIDHash', $id)) {

            $self->mcpDebug(
                '<outgoing MCP message>',
                'MCP cord message not sent - unrecognised or closed cord',
                8611,
            );

            return undef;
        }

        # Update IVs
        $self->ivAdd('mcpCordIDHash', $id, undef);
        $self->ivDelete('mcpCordCloseHash', $id);

        # Send the cord message
        return $self->mcpSendMsg(
            'mcp-cord-close',
                '_id',
                # Axmud is always the 'recipient' of MCP negotiations, never the 'initiator'
                'R' . $id,
        );
    }

    # (Debug message handling for MXP/Pueblo/MCP)

    sub mxpDebug {

        # Called by various functions
        # Stores an MXP debug message until $self->processIncomingData is ready to display it (by
        #   not displaying it immediately, we can avoid some very ugly Gtk3 errors)
        #
        # Expected arguments
        #   $token      - The MXP token that caused the error
        #   $msg        - The debug message
        #
        # Optional arguments
        #   $num       - An optional 4-digit error number, specified literally in the Axmud code
        #                   (could be set to 'undef' if we just need a quick, temporary message).
        #                   Currently, MXP errors use the range 1000-4999 and mixed MXP/Pueblo
        #                   errors use the range 5000-5999
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $token, $msg, $num, $check) = @_;

        # Check for improper arguments
        if (! defined $token || ! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mxpDebug', @_);
        }

        # (Do nothing if the client flag is not set)
        if ($axmud::CLIENT->debugMxpFlag) {

            if (! defined $num) {

                $num = 9998;
            }

            $self->ivPush('protocolDebugList', 'mxp', $token, $num, $msg);
        }

        return 1;
    }

    sub puebloDebug {

        # Called by various functions
        # Stores a Pueblo debug message until $self->processIncomingData is ready to display it (by
        #   not displaying it immediately, we can avoid some very ugly Gtk3 errors)
        #
        # Expected arguments
        #   $token      - The MXP token that caused the error
        #   $msg        - The debug message
        #
        # Optional arguments
        #   $num       - An optional 4-digit error number, specified literally in the Axmud code
        #                   (could be set to 'undef' if we just need a quick, temporary message).
        #                   Currently, Pueblo errors use the range 6000-8999 and mixed MXP/Pueblo
        #                   errors use the range 5000-5999
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $token, $msg, $num, $check) = @_;

        # Check for improper arguments
        if (! defined $token || ! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->puebloDebug', @_);
        }

        # (Do nothing if the client flag is not set)
        if ($axmud::CLIENT->debugPuebloFlag) {

            if (! defined $num) {

                $num = 9999;
            }

            $self->ivPush('protocolDebugList', 'pueblo', $token, $num, $msg);
        }

        return 1;
    }

    sub mcpDebug {

        # Called by various functions
        # Stores an MCP debug message until $self->processIncomingData is ready to display it (by
        #   not displaying it immediately, we can avoid some very ugly Gtk3 errors)
        #
        # Expected arguments
        #   $token      - The MCP token that caused the error
        #   $msg        - The debug message
        #
        # Optional arguments
        #   $num       - An optional 4-digit error number, specified literally in the Axmud code
        #                   (could be set to 'undef' if we just need a quick, temporary message).
        #                   Currently, Pueblo errors use the range 6000-8999 and mixed MXP/Pueblo
        #                   errors use the range 5000-5999
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $token, $msg, $num, $check) = @_;

        # Check for improper arguments
        if (! defined $token || ! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcpDebug', @_);
        }

        # (Do nothing if the client flag is not set)
        if ($axmud::CLIENT->debugMcpFlag) {

            if (! defined $num) {

                $num = 9999;
            }

            $self->ivPush('protocolDebugList', 'mcp', $token, $num, $msg);
        }

        return 1;
    }

    sub displayProtocolDebug {

        # Called by $self->processIncomingData
        # If any MXP/Pueblo/MCP debug messages have been generated, display them, then display a
        #   summary of the token that caused the problem
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayProtocolDebug', @_);
        }

        if ($self->protocolDebugList) {

            do {

                my ($protocol, $token, $num, $msg, $showText);

                $protocol = $self->ivShift('protocolDebugList');
                $token = $self->ivShift('protocolDebugList');
                $num = $self->ivShift('protocolDebugList');
                $msg = $self->ivShift('protocolDebugList');

                $self->writeDebug(uc($protocol) . ': ' . $msg . ' (#' . $num . ')');
                $self->writeDebug('   Token: ' . $token);

                # (In this debug message, replace newline characters with visible '\n' and '\r'
                #   strings, so that the user can see the first line, plus any newline character
                #   which terminates it)
                if ($self->processOrigLine =~ m/(.*[\n\r])/) {
                    $showText = $1;
                } else {
                    $showText = $self->processOrigLine;
                }

                $showText =~ s/[\n]/\\n/;
                $showText =~ s/[\r]/\\r/;

                if ($showText) {
                    $self->writeDebug('   Line : ' . $showText);
                } else {
                    $self->writeDebug('   Line : (empty line)');
                }

            } until (! $self->protocolDebugList);
        }

        return 1;
    }

    # Telnet option negotiation / MUD protocols

    sub prepareTelnetOptions {

        # Called by $self->doConnect just before opening the telnet connection
        # Sets up telnet negotiation options
        #
        # NB There is not much for this function to do. The format of this and related functions is
        #   the same, missing out nothing, so maintainers can clearly see which function(s) are
        #   handling which telnet option/MUD protocol
        #
        # Expected arguments
        #   $connectObj - The GA::Obj::Telnet object to be used in the connection
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $connectObj, $check) = @_;

        # Local variables
        my (
            $echoFlag, $sgaFlag, $ttypeFlag, $eorFlag, $nawsFlag, $newEnvironFlag, $charSetFlag,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $connectObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareTelnetOptions', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # ECHO (http://www.ietf.org/rfc/rfc857.txt)
        #
        #   Server: IAC WILL TELOPT_ECHO
        #   Client: IAC DO TELOPT_ECHO
        #   Server: IAC WONT TELOPT_ECHO
        #   Client: IAC DONT TELOPT_ECHO
        #
        #   Server: IAC WILL TELOPT_ECHO
        #   Client: IAC DONT TELOPT_ECHO
        #
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'echo')) {
            $echoFlag = FALSE;
        } else {
            $echoFlag = $axmud::CLIENT->useEchoFlag;
        }

        if (! $echoFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL ECHO
            #   SENT DONT ECHO

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL ECHO
            #   SENT DO ECHO
            #   RCVD WONT ECHO
            #   SENT DONT ECHO
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_ECHO'});
        }

        # SGA (http://www.ietf.org/rfc/rfc858.txt)
        #
        #   Server: IAC WILL TELOPT_SGA
        #   Client: IAC DO TELOPT_SGA
        #
        #   Server: IAC WILL TELOPT_SGA
        #   Client: IAC DONT TELOPT_SGA
        #
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'sga')) {
            $sgaFlag = FALSE;
        } else {
            $sgaFlag = $axmud::CLIENT->useSgaFlag;
        }

        if (! $sgaFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL SUPPRESS GO AHEAD
            #   SENT DONT SUPPRESS GO AHEAD

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL SUPPRESS GO AHEAD
            #   SENT DO SUPPRESS GO AHEAD
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_SGA'});
        }

        # TTYPE (Terminal type - http://www.ietf.org/rfc/rfc1091.txt)
        #
        #   Server: IAC DO TELOPT_TTYPE
        #   Client: IAC WILL TELOPT_TTYPE
        #   Server: IAC SB TELOPT_TTYPE SEND IAC SE
        #   Client: IAC SB TELOPT_TTYPE IS <terminal_type> IAC SE
        #   Server: IAC SB TELOPT_TTYPE SEND IAC SE
        #   Client: IAC SB TELOPT_TTYPE IS <other_terminal_type> IAC SE
        #   ...
        #
        #   Server: IAC DO TELOPT_TTYPE
        #   Client: IAC WONT TELOPT_TTYPE
        #
        # MTTS (Mud Terminal Type Standard - http://tintin.sourceforge.io/mtts/)
        #
        #   Server: IAC DO TELOPT_TTYPE
        #   Client: IAC WILL TELOPT_TTYPE
        #   Server: IAC SB TELOPT_TTYPE SEND IAC SE
        #   Client: IAC SB TELOPT_TTYPE IS <client name> IAC SE
        #   Server: IAC SB TELOPT_TTYPE SEND IAC SE
        #   Client: IAC SB TELOPT_TTYPE IS <terminal_type> IAC SE
        #   Server: IAC SB TELOPT_TTYPE SEND IAC SE
        #   Client: IAC SB TELOPT_TTYPE IS <mtts_string> IAC SE
        #   ...
        #
        #   Server: IAC DO TELOPT_TTYPE
        #   Client: IAC WONT TELOPT_TTYPE
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'ttype')) {

            $ttypeFlag = FALSE;

        } elsif ($self->currentWorld->ivExists('termOverrideHash', 'termTypeMode')) {

            if (! $self->currentWorld->ivShow('termOverrideHash', 'termTypeMode')) {
                $ttypeFlag = FALSE;
            } else {
                $ttypeFlag = TRUE;
            }

        } else {

            if (! $axmud::CLIENT->useTTypeFlag || $axmud::CLIENT->termTypeMode eq 'send_nothing') {
                $ttypeFlag = FALSE;
            } else {
                $ttypeFlag = TRUE;
            }
        }

        if (! $ttypeFlag) {

            # GA::Obj::Telnet option log
            #   RCVD DO TERMINAL TYPE
            #   SENT WONT TERMINAL TYPE

        } else {

            # GA::Obj::Telnet option log
            #   RCVD DO TERMINAL TYPE
            #   SENT WILL TERMINAL TYPE
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_TTYPE'});
        }

        # EOR (End of record - RFC 885, http://www.ietf.org/rfc/rfc885.txt)
        #
        #   Server: IAC WILL TELEOPT_EOR
        #   Client: IAC DO TELEOPT_EOR
        #   Server: IAC EOR
        #   ...
        #
        #   Server: IAC WILL TELEOPT_EOR
        #   Client: IAC DONT TELEOPT_EOR
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'eor')) {
            $eorFlag = FALSE;
        } else {
            $eorFlag = $axmud::CLIENT->useEorFlag;
        }

        if (! $eorFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL EOR
            #   SENT DONT EOR
            $self->ivPoke('eorMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL TELEOPT_EOR
            #   SENT DO TELEOPT_EOR
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_EOR'});
        }

        # NAWS (Negotiate About Window Size - RFC 1073, http://www.ietf.org/rfc/rfc1073.txt)
        #
        # (NB If the world profile specifies that the terminal size should be set manually, by
        #   sending commands like 'cols 100' and 'rows 30', then NAWS is not used in this
        #   session)
        #
        #   Server: IAC DO TELOPT_NAWS
        #   Client: IAC WILL TELOPT_NAWS
        #   Client: IAC SB TELOPT_NAWS <w1> <w0> <h1> <h0> IAC SE
        #   Client: IAC SB TELOPT_NAWS <new_w1> <new_w0> <new_h1> <new_h0> IAC SE
        #
        #   Server: IAC DO TELOPT_NAWS
        #   Client: IAC WONT TELOPT_NAWS
        #
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'naws')) {

            $nawsFlag = FALSE;

        } else {

            if (! $axmud::CLIENT->useNawsFlag || $self->currentWorld->sendSizeInfoFlag) {
                $nawsFlag = FALSE;
            } else {
                $nawsFlag = TRUE;
            }
        }

        if (! $nawsFlag) {

            # GA::Obj::Telnet option log
            #   RCVD DO NAWS
            #   SENT WONT NAWS
            $self->ivPoke('nawsMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD DO NAWS
            #   SENT WILL NAWS
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_NAWS'});
        }

        # NEW-ENVIRON (New Environment option - RFC 1572, http://www.ietf.org/rfc/rfc1572.txt)
        #
        #   Server: IAC DO TELOPT_NEW_ENVIRON
        #   Client: IAC WILL TELOPT_NEW_ENVIRON
        #   Server: IAC SB NEW-ENVIRON SEND [ type ... [ type ... [ ... ] ] ] IAC SE
        #   Client: IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ]
        #               IAC SE
        #   ...
        #
        #   Server: IAC DO TELOPT_NEW_ENVIRON
        #   Client: IAC WONT TELOPT_NEW_ENVIRON
        #
        # MNES (MUD NEW-ENVIRON Standard - https://tintin.mudhalla.net/protocols/mnes/)
        #
        #   Server: IAC DO TELOPT_NEW_ENVIRON
        #   Client: IAC WILL TELOPT_NEW_ENVIRON
        #   Server: IAC SB NEW-ENVIRON SEND [ type ... [ type ... [ ... ] ] ] IAC SE
        #   Client: IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ]
        #               IAC SE
        #   ...
        #
        #   Server: IAC DO TELOPT_NEW_ENVIRON
        #   Client: IAC WONT TELOPT_NEW_ENVIRON
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'new_environ')) {
            $newEnvironFlag = FALSE;
        } else {
            $newEnvironFlag = $axmud::CLIENT->useNewEnvironFlag;
        }

        if (! $newEnvironFlag) {

            # GA::Obj::Telnet option log
            #   RCVD DO NEW-ENVIRON
            #   SENT WILL NEW-ENVIRON
            $self->ivPoke('newEnvironMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD DO NEW-ENVIRON
            #   SENT WONT NEW-ENVIRON
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_NEW_ENVIRON'});
        }

        # CHARSET (Character encoding - RFC 1073, http://www.ietf.org/rfc/rfc2066.txt)
        #
        #   Server: IAC DO TELOPT_CHARSET
        #   Client: IAC WILL TELOPT_CHARSET
        #   Server: IAC SB CHARSET REQUEST { "[TTABLE ]" <Version> } <char set list> IAC SE
        #   ...
        #
        #   Server: IAC DO TELOPT_CHARSET
        #   Client: IAC WONT TELOPT_CHARSET
        #
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'charset')) {
            $charSetFlag = FALSE;
        } else {
            $charSetFlag = $axmud::CLIENT->useCharSetFlag;
        }

        if (! $charSetFlag) {

            # GA::Obj::Telnet option log
            #   RCVD DO CHARSET
            #   SENT WILL CHARSET
            $self->ivPoke('charSetMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD DO CHARSET
            #   SENT WONT CHARSET
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_CHARSET'});
        }

        return 1;
    }

    sub disableTelnetOption {

        # Called by GA::Client->toggle_telnetOption after it disables a telnet negotation option
        # If the option has been negotiated in this session, inform the server that we no longer
        #   want it
        #
        # Expected arguments
        #   $option     - The option to disable; one of 'echo', 'sga', 'ttype', 'eor', 'naws',
        #                   'new_environ', 'charset'
        #               - Other values are ignored, since the calling function should have checked
        #                   $option already. 'new_environ' and 'charset' have not been implemented,
        #                   and are also ignored
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $option, $check) = @_;

        # Local variables
        my %telConstHash;

        # Check for improper arguments
        if (! defined $option || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disableTelnetOption', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # ECHO (http://www.ietf.org/rfc/rfc857.txt)
        #   Client: IAC WONT TELOPT_ECHO
        if ($option eq 'echo' && $self->echoMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_ECHO'});
            $self->ivPoke('echoMode', 'client_refuse');
            $self->updateEcho();

        # SGA (http://www.ietf.org/rfc/rfc858.txt)
        #   Client: IAC WONT TELOPT_SGA
        } elsif ($option eq 'sga' && $self->sgaMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_SGA'});
            $self->ivPoke('sgaMode', 'client_refuse');

        # TTYPE (Terminal type - http://www.ietf.org/rfc/rfc1091.txt)
        #   Client: IAC WONT TELOPT_TTYPE
        } elsif ($option eq 'ttype' && $self->sendTTypeList) {

            $self->ivEmpty('sendTTypeList');
            $self->optSendWont($telConstHash{'TELOPT_TTYPE'});

            # For MTTS, there's a danger of an infinite set of option negotations. To avoid any
            #   possibility of that, Axmud (in common with other MUD clients) won't accept new
            #   TTYPE/MTTS negotiations after the server sends IAC DONT TTYPE
            $self->connectObj->option_accept(Dont => $telConstHash{'TELOPT_TTYPE'});

        # EOR (End of record - RFC 885, http://www.ietf.org/rfc/rfc885.txt)
        #   Client: IAC DONT TELEOPT_EOR
        } elsif ($option eq 'eor' && $self->eorMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_EOR'});
            $self->ivPoke('eorMode', 'client_refuse');

        # NAWS (Negotiate About Window Size - RFC 1073, http://www.ietf.org/rfc/rfc1073.txt)
        #   Client: IAC WONT TELOPT_NAWS
        } elsif ($option eq 'naws' && $self->nawsMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_NAWS'});
            $self->ivPoke('nawsMode', 'client_refuse');

        # NEW-ENVIRON (New Environment option - RFC 1572, http://www.ietf.org/rfc/rfc1572.txt)
        } elsif ($option eq 'new_environ' && $self->newEnvironMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_NEW_ENVIRON'});
            $self->ivPoke('newEnvironMode', 'client_refuse');

        # CHARSET (Character encoding - RFC 1073, http://www.ietf.org/rfc/rfc2066.txt)
        } elsif ($option eq 'charset' && $self->charSetMode eq 'client_agree') {

            $self->optSendWont($telConstHash{'TELOPT_CHARSET'});
            $self->ivPoke('charSetMode', 'client_refuse');
        }

        return 1;
    }

    sub prepareMudProtocols {

        # Called by $self->doConnect just before opening the connection
        # Sets up MUD protocols
        #
        # Expected arguments
        #   $connectObj - The GA::Obj::Telnet object to be used in the connection
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $connectObj, $check) = @_;

        # Local variables
        my (
            $msdpFlag, $msspFlag, $mccpFlag, $mspFlag, $mxpFlag, $zmpFlag, $aard102Flag, $atcpFlag,
            $gmcpFlag,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $connectObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareMudProtocols', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # MSDP (Mud Server Data Protocol - http://tintin.sourceforge.io/msdp/)
        #
        #   Server: IAC WILL TELOPT_MSDP
        #   Client: IAC DO TELOPT_MSDP
        #   Client: IAC SB TELOPT_MSDP MSDP_VAR <list> MSDP_VAL <commands> IAC SE
        #
        #   Server: IAC WILL TELOPT_MSDP
        #   Client: IAC DONT TELOPT_MSDP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'msdp')) {
            $msdpFlag = FALSE;
        } else {
            $msdpFlag = $axmud::CLIENT->useMsdpFlag;
        }

        if (! $msdpFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL MSDP
            #   SENT DONT MSDP
            $self->ivPoke('msdpMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL MSDP
            #   SENT DO MSDP
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MSDP'});
        }

        # MSSP (Mud Server Status Protocol - http://tintin.sourceforge.io/mssp/)
        #
        #   Server: IAC WILL TELOPT_MSSP
        #   Client: IAC DO TELOPT_MSSP
        #   Server: IAC SB TELOPT_MSSP MSSP_VAR <variable> MSSP_VAL <value> MSSP_VAR <variable>
        #               MSSP_VAL <value> IAC SE
        #   ...
        #
        #   Server: IAC WILL TELOPT_MSSP
        #   Client: IAC DONT TELOPT_MSSP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'mssp')) {
            $msspFlag = FALSE;
        } else {
            $msspFlag = $axmud::CLIENT->useMsspFlag;
        }

        if (! $msspFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL MSSP
            #   SENT DONT MSSP
            $self->ivPoke('msspMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL MSSP
            #   SENT DO MSSP
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MSSP'});
        }

        # MCCP1 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        # MCCP2 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        #
        #   Server: IAC WILL TELOPT_MCCP1 / IAC WILL TELOPT_MCCP2
        #   Client: IAC DO TELOPT_MCCP1 / IAC DO TELOPT_MCCP2
        #
        #   Server: IAC WILL TELOPT_MCCP1 / IAC WILL TELOPT_MCCP2
        #   Client: IAC DONT TELOPT_MCCP1 /  IAC DONT TELOPT_MCCP2
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'mccp')) {
            $mccpFlag = FALSE;
        } else {
            $mccpFlag = $axmud::CLIENT->useMccpFlag;
        }

        if (! $mccpFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL MCCP1 / RCVD WILL MCCP2
            #   SENT DONT MCCP1 / SENT DONT MCCP2
            $self->ivPoke('mccpMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL MCCP1 / RCVD WILL MCCP2
            #   SENT DO MCCP1 / SENT DO MCCP2
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MCCP1'});
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MCCP2'});
        }

        # MSP (Mud Sound Protocol - http://www.zuggsoft.com/zmud/msp.htm)
        #
        #   Server: IAC WILL TELOPT_MSP
        #   Client: IAC DO TELOPT_MSP
        #
        #   Server: IAC WILL TELOPT_MSP
        #   Client: IAC DONT TELOPT_MSP
        #   Server: IAC WONT TELOPT_MSP

        #   Client: IAC DO TELOPT_MSP
        #
        #   Client: IAC DONT TELOPT_MSP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'msp')) {
            $mspFlag = FALSE;
        } else {
            $mspFlag = $axmud::CLIENT->useMspFlag;
        }

        if (! $mspFlag) {

            # GA::Obj::Telnet option log
            #   (Negotiation sequence never seen)
            $self->ivPoke('mspMode', 'client_refuse');

        } else {

            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MSP'});
        }

        # MXP (Mud Extension Protocol - http://www.zuggsoft.com/zmud/mxp.htm)
        #
        # NB Some worlds initiate a negotation with DO, some with WILL; Axmud responds to both
        #
        #   Server: IAC DO TELOPT_MXP
        #   Client: IAC WILL TELOPT_MXP
        #   Server: IAC SB MXP IAC SE
        #
        #   Server: IAC DO TELOPT_MXP
        #   Client: IAC WONT TELOPT_MXP
        #
        #   Server: IAC WILL TELOPT_MXP
        #   Client: IAC DO TELOPT_MXP
        #   Server: IAC SB MXP IAC SE
        #
        #   Server: IAC WILL TELOPT_MXP
        #   Client: IAC DONT TELOPT_MXP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'mxp')) {

            $mxpFlag = FALSE;

        } else {

            if (! $axmud::CLIENT->useMxpFlag || $self->puebloMode eq 'client_agree') {
                $mxpFlag = FALSE;
            } else {
                $mxpFlag = TRUE;
            }
        }

        if (! $mxpFlag) {

            # GA::Obj::Telnet option log
            #   (Negotiation sequence never seen)
            $self->ivPoke('mxpMode', 'client_refuse');

        } else {

            $connectObj->option_accept(Do => $telConstHash{'TELOPT_MXP'});
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_MXP'});
        }

        # PUEBLO (http://pueblo.sourceforge.io/doc/manual/html_standard_elements.html)
        # Implemented, but not handled out-of-bounds

        # ZMP (Zenith Mud Protocol - http://discworld.starturtle.net/external/protocols/zmp.html)
        #
        #   Server: IAC WILL TELOPT_ZMP
        #   Client: IAC DO TELOPT_ZMP
        #
        #   Server: IAC WILL TELOPT_ZMP
        #   Client: IAC DONT TELOPT_ZMP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'zmp')) {
            $zmpFlag = FALSE;
        } else {
            $zmpFlag = $axmud::CLIENT->useZmpFlag;
        }

        if (! $zmpFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL ZMP
            #   SENT DONT ZMP
            $self->ivPoke('atcpMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL ZMP
            #   SENT DO ZMP
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_ZMP'});
        }

        # AARD102 (Aardwolf 102 channel
        #   - http://www.aardwolf.com/blog/2008/07/10/
        #       telnet-negotiation-control-mud-client-interaction/
        #
        #   Server: IAC DO TELOPT_AARD102
        #   Client: IAC WILL TELOPT_AARD102
        #
        #   Server: IAC DO TELOPT_AARD102
        #   Client: IAC WONT TELOPT_AARD102
        #
        #   Server: IAC WILL TELOPT_AARD102
        #   Client: IAC DO TELOPT_AARD102
        #
        #   Server: IAC WILL TELOPT_AARD102
        #   Client: IAC DONT TELOPT_AARD102
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'aard192')) {
            $aard102Flag = FALSE;
        } else {
            $aard102Flag = $axmud::CLIENT->useAard102Flag;
        }

        if (! $aard102Flag) {

            # GA::Obj::Telnet option log
            #   RCVD DO AARD102
            #   SENT wONT AARD102
            #
            #   RCVD WILL AARD102
            #   SENT DONT AARD102
            $self->ivPoke('aard102Mode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD DO AARD102
            #   SENT WILL AARD102
            #
            #   RCVD WILL AARD102
            #   SENT DO AARD102
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_AARD102'});
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_AARD102'});
        }

        # ATCP (Achaea Telnet Client Protocol)
        #   - https://www.ironrealms.com/rapture/manual/files/FeatATCP-txt.html
        #
        #   Server: IAC WILL TELOPT_ATCP
        #   Client: IAC DO TELOPT_ATCP
        #
        #   Server: IAC WILL TELOPT_ATCP
        #   Client: IAC DONT TELOPT_ATCP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'atcp')) {
            $atcpFlag = FALSE;
        } else {
            $atcpFlag = $axmud::CLIENT->useAtcpFlag;
        }

        if (! $atcpFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL ATCP
            #   SENT DONT ATCP
            $self->ivPoke('atcpMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL ATCP
            #   SENT DO ATCP
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_ATCP'});
            # (GMCP 'Do' has been seen in the wild, so probably ATCP uses it too)
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_ATCP'});
        }

        # GMCP (Generic MUD Communication Protocol) - https://www.gammon.com.au/gmcp
        #
        #   Server: IAC WILL TELOPT_GMCP
        #   Client: IAC DO TELOPT_GMCP
        #
        #   Server: IAC WILL TELOPT_GMCP
        #   Client: IAC DONT TELOPT_GMCP
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'gmcp')) {
            $gmcpFlag = FALSE;
        } else {
            $gmcpFlag = $axmud::CLIENT->useGmcpFlag;
        }

        if (! $gmcpFlag) {

            # GA::Obj::Telnet option log
            #   RCVD WILL GMCP
            #   SENT DONT GMCP
            $self->ivPoke('gmcpMode', 'client_refuse');

        } else {

            # GA::Obj::Telnet option log
            #   RCVD WILL GMCP
            #   SENT DO GMCP
            $connectObj->option_accept(Will => $telConstHash{'TELOPT_GMCP'});
            # (I've also seen 'Do' out in the wild)
            $connectObj->option_accept(Do => $telConstHash{'TELOPT_GMCP'});
        }

        # MTTS (Mud Terminal Type Standard - http://tintin.sourceforge.io/mtts/)
        # Strictly speaking a standard, not a protocol; Axmud implements it alongside TTYPE
        #   negotiatons

        # MNES (MUD NEW-ENVIRON Standard)
        # Strictly speaking a standard, not a protocol; Axmud implements it alongside NEW-ENVIRON
        #   negotiatons

        # MCP (Mud Client Protocol - http://www.moo.mud.org/mcp/)
        # Implemented, but not handled out-of-bounds

        return 1;
    }

    sub disableMudProtocol {

        # Called by GA::Client->toggle_mudProtocol after it disables a MUD protocol
        # If the protocol has been negotiated in this session, inform the server that we no longer
        #   want it (but not when in offline mode, for example)
        #
        # Expected arguments
        #   $protocol   - The protocol to disable; one of 'msdp', 'mssp', 'mccp', 'msp', 'mxp',
        #                   'pueblo', 'zmp', 'aard102', 'atcp', 'gmcp', 'mtts', 'mnes', 'mcp'.
        #               - Other values are ignored, since the calling function should have checked
        #                   $protocol already. 'mcp' haS not been
        #                   implemented, and are also ignored. MTTS is handled alongside TTYPE
        #                   negotiations, so cannot be turned off mid-session; 'mtts' is also
        #                   ignored by this function. Pueblo negotiations are not handled
        #                   out-of-bounds, so cannot be turned off mid-session; 'pueblo' is also
        #                   ignored by this function
        #
        # Return values
        #   'undef' on improper arguments or if not actually connected to a server
        #   1 otherwise

        my ($self, $protocol, $check) = @_;

        # Local variables
        my %telConstHash;

        # Check for improper arguments
        if (! defined $protocol || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disableMudProtocol', @_);
        }

        # Don't do anything if not actually connected
        if ($self->status ne 'connecting' && $self->status ne 'connected') {

            return undef;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # MSDP (Mud Server Data Protocol - http://tintin.sourceforge.io/msdp/)
        #   Client: IAC DONT TELOPT_MSDP
        if ($protocol eq 'msdp' && $self->msdpMode eq 'client_agree') {

            $self->resetMsdpData();
            $self->optSendWont($telConstHash{'TELOPT_MSDP'});
            $self->ivPoke('msdpMode', 'client_refuse');

        # MSSP (Mud Server Status Protocol - http://tintin.sourceforge.io/mssp/)
        } elsif ($protocol eq 'mssp' && $self->msspMode eq 'client_agree') {

            $self->ivPoke('msspMode', 'client_refuse');
            # (Don't remove any collected data from the world profile)

        # MCCP (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        } elsif ($protocol eq 'mccp') {

            # (Nothing to do; GA::Obj::Telnet handles it)

        # MSP (Mud Sound Protocol - http://www.zuggsoft.com/zmud/msp.htm)
        } elsif ($protocol eq 'msp') {

            $self->optSendDont($telConstHash{'TELOPT_MSP'});
            $self->ivPoke('mspMode', 'client_refuse');

        # MXP (Mud Extension Protocol - http://www.zuggsoft.com/zmud/mxp.htm)
        } elsif ($protocol eq 'mxp') {

            $self->optSendDont($telConstHash{'TELOPT_MXP'});
            $self->ivPoke('mxpMode', 'client_refuse');

            $self->removeMxpGauges();

        # PUEBLO (http://pueblo.sourceforge.io/doc/manual/html_standard_elements.html)
        # (negotiations not handled out-of-bounds)

        # ZMP (Zenith Mud Protocol - http://discworld.starturtle.net/external/protocols/zmp.html)
        # "Once enabled, ZMP may not be disabled."

        # AARD102 (Aardwolf 102 channel
        #   - http://www.aardwolf.com/blog/2008/07/10/
        #       telnet-negotiation-control-mud-client-interaction/
        } elsif ($protocol eq 'aard102') {

            $self->optSendDont($telConstHash{'TELOPT_AARD102'});
            $self->optSendWont($telConstHash{'TELOPT_AARD102'});
            $self->ivPoke('aard102Mode', 'client_refuse');

        # ATCP (Achaea Telnet Client Protocol)
        #   - https://www.ironrealms.com/rapture/manual/files/FeatATCP-txt.html
        } elsif ($protocol eq 'atcp') {

            $self->optSendDont($telConstHash{'TELOPT_ATCP'});
            $self->ivPoke('atcpMode', 'client_refuse');

        # Use GMCP (Generic MUD Communication Protocol) - https://www.gammon.com.au/gmcp
        } elsif ($protocol eq 'gmcp') {

            $self->optSendDont($telConstHash{'TELOPT_GMCP'});
            $self->ivPoke('gmcpMode', 'client_refuse');
        }

        # MTTS (Mud Terminal Type Standard - http://tintin.sourceforge.io/mtts/)
        # (Nothing to do - $self->prepareTTypeData checks GA::Client->useMttsFlag)
        # MNES (MUD NEW-ENVIRON Standard - https://tintin.mudhalla.net/protocols/mnes/)
        # (Nothing to do - $self->processNewEnvironData checks GA::Client->useMnesFlag)
        # MCP (Mud Client Protocol - http://www.moo.mud.org/mcp/)
        # Implemented, but not handled out-of-bounds

        return 1;
    }

    sub optCallback {

        # ->signal_connect callback from $self->doConnect when the server sends a telnet negotiation
        #   option
        #
        # Expected arguments
        #   $connectObj     - The GA::Obj::Telnet object used in the connection
        #   $option         - The TELNET option (e.g. 31 for NAWS)
        #
        # Optional arguments
        #   $remoteFlag     - Flag set to TRUE if the option applies to the (remote) server, FALSE
        #                       (or 'undef') if it applies to this client
        #   $enabledFlag    - Flag set to TRUE if the option is enabled, FALSE (or 'undef') if it is
        #                       disabled
        #   $wasEnabledFlag - Flag set to TRUE if the option was previously enabled, FALSE (or
        #                       'undef') if it was previously disabled
        #   $bufferPosn     - An integer indicating the position in $connectObj's input buffer
        #                       where the option takes effect (if 'undef', the value 0 is used)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my (
            $self, $connectObj, $option, $remoteFlag, $enabledFlag, $wasEnabledFlag, $bufferPosn,
            $check
        ) = @_;

        # Local variables
        my (
            $descrip, $echoFlag, $sgaFlag,
            %telConstHash,
        );

        # Check for improper arguments
        if (
            ! defined $connectObj || ! defined $option || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->optCallback', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # If the debug flag is set, show information about this option
        if ($axmud::CLIENT->debugTelnetFlag || $axmud::CLIENT->debugTelnetMiniFlag) {

            if ($axmud::CLIENT->ivExists('constTeloptHash', $option)) {
                $descrip = ' (' . $axmud::CLIENT->ivShow('constTeloptHash', $option) . ')';
            } else {
                $descrip = ' <unknown>';
            }

            $self->writeDebug('Telnet negotiation option: ' . $option . $descrip);

            if ($axmud::CLIENT->debugTelnetFlag) {

                $self->writeDebug('   remote          : ' . $remoteFlag);
                $self->writeDebug('   enabled         : ' . $enabledFlag);
                if (defined $wasEnabledFlag) {
                    $self->writeDebug('   was enabled     : ' . $wasEnabledFlag);
                } else {
                    $self->writeDebug('   was enabled     : ');
                }
                $self->writeDebug('   buffer position : ' . $bufferPosn);
            }
        }

        # Use a default $bufferPosn, in case the arguments passed to this function was 'undef'
        #   (don't think this can ever happen, but let's be certain about it)
        if (! defined $bufferPosn) {

            $bufferPosn = 0;
        }

        # ECHO (http://www.ietf.org/rfc/rfc857.txt)
        if ($option == $telConstHash{'TELOPT_ECHO'}) {

            # Server: IAC WILL TELOPT_ECHO
            if ($remoteFlag && $enabledFlag) {

                if ($self->currentWorld->ivExists('telnetOverrideHash', 'echo')) {
                    $echoFlag = FALSE;
                } else {
                    $echoFlag = $axmud::CLIENT->useEchoFlag;
                }

                if (! $echoFlag) {

                    # Client: IAC DONT TELOPT_ECHO
                    $self->optSendDont($telConstHash{'TELOPT_ECHO'});
                    $self->ivPoke('echoMode', 'client_refuse');
                    $self->updateEcho();

                } else {

                    # Client: IAC DO TELOPT_ECHO
                    $self->optSendDo($telConstHash{'TELOPT_ECHO'});
                    $self->ivPoke('echoMode', 'client_agree');
                    $self->updateEcho();
                 }

            # Server: IAC WONT TELOPT_ECHO
            } elsif ($remoteFlag && ! $enabledFlag) {

                $self->optSendDont($telConstHash{'TELOPT_ECHO'});
                $self->ivPoke('echoMode', 'server_stop');
                $self->updateEcho();
            }

        # SGA (http://www.ietf.org/rfc/rfc858.txt)
        } elsif ($option == $telConstHash{'TELOPT_SGA'}) {

            # Server: IAC WILL TELOPT_SGA
            if ($remoteFlag && $enabledFlag) {

                if ($self->currentWorld->ivExists('telnetOverrideHash', 'sga')) {
                    $sgaFlag = FALSE;
                } else {
                    $sgaFlag = $axmud::CLIENT->useSgaFlag;
                }

                if (! $sgaFlag) {

                    # Client: IAC DONT TELOPT_SGA
                    $self->optSendDont($telConstHash{'TELOPT_SGA'});
                    $self->ivPoke('sgaMode', 'client_refuse');

                } else {

                    # Client: IAC DO TELOPT_SGA
                    $self->optSendDo($telConstHash{'TELOPT_SGA'});
                    $self->ivPoke('sgaMode', 'client_agree');
                 }

            # Server: IAC WONT TELOPT_SGA
            } elsif ($remoteFlag && ! $enabledFlag) {

                $self->optSendDont($telConstHash{'TELOPT_SGA'});
                $self->ivPoke('sgaMode', 'server_stop');
            }

        # TTYPE (Terminal type - http://www.ietf.org/rfc/rfc1091.txt)
        # MTTS (Mud Terminal Type Standard - http://tintin.sourceforge.io/mtts/)
        } elsif ($option == $telConstHash{'TELOPT_TTYPE'}) {

            if (
                ! $remoteFlag
                && (
                    # Initial negotiation
                    ($enabledFlag && ! $wasEnabledFlag)
                    # v1.0.903 removed - Axmud no longer supports TTYPE/MTTS after a server copyover
#                    # Server is performing a copyover
#                    || (! $enabledFlag && $wasEnabledFlag)
                )
            ) {
                $self->prepareTTypeData();
            }

        # EOR (End of record - RFC 885, http://www.ietf.org/rfc/rfc885.txt)
        } elsif ($option == $telConstHash{'TELOPT_EOR'}) {

            # Server: IAC WILL TELOPT_EOR
            if (! $remoteFlag && $enabledFlag) {

                $self->ivPoke('eorMode', 'client_agree');

            } elsif ($remoteFlag) {

                # Don't allow $self->processLinePortion to check for prompts matching the world
                #   profile's ->cmdPromptPatternList (for the rest of the session)
                $self->ivPoke('eorgaFlag', TRUE);

                # The TRUE value tells ->processPrompt to set ->promptInsertFlag, which in turn
                #   instructs ->processIncomingData to insert an artificial newline character, if
                #   necessary
                $self->processPrompt(TRUE);
            }

        # NAWS (Negotiate About Window Size - RFC 1073, http://www.ietf.org/rfc/rfc1073.txt)
        } elsif ($option == $telConstHash{'TELOPT_NAWS'}) {

            # Client: IAC WILL TELOPT_NAWS
            if (! $remoteFlag && $enabledFlag) {

                $self->ivPoke('nawsMode', 'client_agree');

                # Call $self->optSendNaws() to inform the server of the default textview's current
                #   size
                $self->optSendNaws();
            }

        # NEW-ENVIRON (New Environment option - RFC 1572, http://www.ietf.org/rfc/rfc1572.txt)
        # MNES (MUD NEW-ENVIRON Standard - https://tintin.mudhalla.net/protocols/mnes/)
        } elsif ($option == $telConstHash{'TELOPT_NEW_ENVIRON'}) {

            # Client: IAC WILL TELOPT_NEW_ENVIRON
            if ($enabledFlag) {

                $self->ivPoke('newEnvironMode', 'client_agree');
            }

        # CHARSET (Character encoding - RFC 1073, http://www.ietf.org/rfc/rfc2066.txt)
        } elsif ($option == $telConstHash{'TELOPT_CHARSET'}) {

            # Client: IAC WILL TELOPT_CHARSET
            if ($enabledFlag) {

                $self->ivPoke('charSetMode', 'client_agree');
            }

        # MSDP (Mud Server Data Protocol - http://tintin.sourceforge.io/msdp/)
        } elsif ($option == $telConstHash{'TELOPT_MSDP'}) {

            # Server: IAC WILL TELOPT_MSDP
            # Client:  IAC SB TELOPT_MSDP MSDP_VAR <var> MDSP_VAL <val> IAC SE
            #           (here, IAC SB TELOPT_MSDP MSDP_VAR "LIST" MDSP_VAL "COMMANDS" IAC SE)
            if ($remoteFlag && $enabledFlag) {

                $self->ivPoke('msdpMode', 'client_agree');
                # We send LIST COMMANDS first, and then LIST LISTS, and then any more lists which
                #   the server has indicated it supports, each time waiting for a response before
                #   sending the next one
                $self->optSendMsdpList('COMMANDS');
                $self->ivPoke('msdpInitMode', 'sent_commands');

            } elsif (! $remoteFlag && ! $enabledFlag) {

                # (Server is performing a copyover)
                $self->resetMsdpData();
            }

        # MSSP (Mud Server Status Protocol - http://tintin.sourceforge.io/mssp/)
        } elsif ($option == $telConstHash{'TELOPT_MSSP'}) {

            # Server: IAC WILL TELOPT_MSSP
            if ($remoteFlag && $enabledFlag) {

                $self->ivPoke('msspMode', 'client_agree');
            }

        # MCCP1 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        # MCCP2 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        } elsif (
            $option == $telConstHash{'TELOPT_MCCP1'}
            || $option == $telConstHash{'TELOPT_MCCP2'}
        ) {
            # Server: IAC WILL TELOPT_MCCP1 / IAC WILL TELOPT_MCCP2
            if ($remoteFlag && $enabledFlag) {

                $self->ivPoke('mccpMode', 'client_agree');
                # If already accepted MCCP2, then don't accept MCCP1
                if ($option == $telConstHash{'TELOPT_MCCP2'}) {

                    $self->connectObj->option_accept(Wont => $telConstHash{'TELOPT_MCCP1'});
                }
            }

        # MSP (Mud Sound Protocol - http://www.zuggsoft.com/zmud/msp.htm)
        } elsif ($option == $telConstHash{'TELOPT_MSP'}) {

            #   Server: IAC WILL TELOPT_MSP
            #   Client: IAC DO TELOPT_MSP
            #
            #   Server: IAC DO TELOPT_MSP
            #   Client: IAC WILL TELOPT_MSP
            if ($remoteFlag && $enabledFlag) {

                $self->ivPoke('mspMode', 'client_agree');
            }

        # MXP (Mud Extension Protocol - http://www.zuggsoft.com/zmud/mxp.htm)
        } elsif ($option == $telConstHash{'TELOPT_MXP'}) {

            # Server: IAC DO TELOPT_MXP
            # Server: IAC WILL TELOPT_MXP
            #
            # NB At some worlds, $remoteFlag will be set, at others, it won't be set; therefore we
            #   only required that $enabledFlag is set
            if ($enabledFlag) {

                $self->ivPoke('mxpMode', 'client_agree');
            }

        # PUEBLO (http://pueblo.sourceforge.io/doc/manual/html_standard_elements.html)
        # Implemented, but not handled out-of-bounds

        # ZMP (Zenith Mud Protocol - http://discworld.starturtle.net/external/protocols/zmp.html)
        } elsif ($option == $telConstHash{'TELOPT_ZMP'}) {

            #   Server: IAC WILL TELOPT_ZMP
            #   Client: IAC DO TELOPT_ZMP
            #
            #   Server: IAC WILL TELOPT_ZMP
            #   Client: IAC DONT TELOPT_ZMP
            if ($enabledFlag) {

                $self->ivPoke('zmpMode', 'client_agree');

                # Sending 'zmp.ident' is optional, but Axmud always does it
                $self->zmpSendIdent();
            }

        # AARD102 (Aardwolf 102 channel
        #   - http://www.aardwolf.com/blog/2008/07/10/
        #       telnet-negotiation-control-mud-client-interaction/
        } elsif ($option == $telConstHash{'TELOPT_AARD102'}) {

            #   Server: IAC DO TELOPT_AARD102
            #   Client: IAC WILL TELOPT_AARD102
            #
            #   Server: IAC DO TELOPT_AARD102
            #   Client: IAC WONT TELOPT_AARD102
            #
            #   Server: IAC WILL TELOPT_AARD102
            #   Client: IAC DO TELOPT_AARD102
            #
            #   Server: IAC WILL TELOPT_AARD102
            #   Client: IAC DONT TELOPT_AARD102
            if ($enabledFlag) {

                $self->ivPoke('aard102Mode', 'client_agree');
            }

        # ATCP (Achaea Telnet Client Protocol)
        #   - https://www.ironrealms.com/rapture/manual/files/FeatATCP-txt.html
        } elsif ($option == $telConstHash{'TELOPT_ATCP'}) {

            #   Server: IAC WILL TELOPT_ATCP
            #   Client: IAC DO TELOPT_ATCP
            #
            #   Server: IAC WILL TELOPT_ATCP
            #   Client: IAC DONT TELOPT_ATCP
            # (Haven't seen $remoteFlag enabled yet)
            if ($enabledFlag) {

                $self->ivPoke('atcpMode', 'client_agree');
            }

        # Use GMCP (Generic MUD Communication Protocol) - https://www.gammon.com.au/gmcp
        } elsif ($option == $telConstHash{'TELOPT_GMCP'}) {

            #   Server: IAC WILL TELOPT_GMCP
            #   Client: IAC DO TELOPT_GMCP
            #
            #   Server: IAC WILL TELOPT_GMCP
            #   Client: IAC DONT TELOPT_GMCP
            # (Haven't seen $remoteFlag enabled yet)
            if ($enabledFlag) {

                $self->ivPoke('gmcpMode', 'client_agree');
            }
        }

        # MTTS (Mud Terminal Type Standard - http://tintin.sourceforge.io/mtts/)
        # Handled above alongside TTYPE
        # MNES (MUD NEW-ENVIRON Standard - https://tintin.mudhalla.net/protocols/mnes/)
        # Handled above alongise NEW-ENVIRON
        # MCP (Mud Client Protocol - http://www.moo.mud.org/mcp/)
        # Implemented, but not handled out-of-bounds

        # Record the time at which the last out-of-bounds communication was received
        $self->ivPoke('lastOutBoundsTime', $self->sessionTime);
        # Turn on the window blinker, and update IVs
        $self->turnOnBlinker(1);

        return 1;
    }

    sub subOptCallback {

        # ->signal_connect callback from $self->doConnect when the server sends a telnet negotiation
        #   sub-option
        #
        # Expected arguments
        #   $connectObj     - The GA::Obj::Telnet used in the connection
        #   $option         - The TELNET option (e.g. 31 for NAWS)
        #   $parameters     - A string of 0, 1 or more characters appearing between IAC SB and
        #                       ICA SE in the sub-negotiation
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $connectObj, $option, $parameters, $check) = @_;

        # Local variables
        my (
            $descrip, $name, $data, $byte, $byte2,
            @tokenList, @packageList, @dataList,
            %telConstHash, %tokenHash, %msdpHash, %msspHash,
        );

        # Check for improper arguments
        if (
            ! defined $connectObj || ! defined $option || ! defined $parameters
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->subOptCallback', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # If the debug flag is set, show information about this option
        if ($axmud::CLIENT->debugTelnetFlag || $axmud::CLIENT->debugTelnetMiniFlag) {

            if ($axmud::CLIENT->ivExists('constTeloptHash', $option)) {
                $descrip = ' (' . $axmud::CLIENT->ivShow('constTeloptHash', $option) . ')';
            } else {
                $descrip = ' <unknown>';
            }

            $self->writeDebug('Telnet sub-negotiation option: ' . $option . $descrip);
            if ($axmud::CLIENT->debugTelnetFlag) {

                $self->writeDebug('   parameters      : ' . $parameters);
            }
        }

        # Split $parameters into a list of IAC tokens
        foreach my $token (split(//, $parameters)) {

            push (@tokenList, ord($token));
        }

        # TTYPE (Terminal type - http://www.ietf.org/rfc/rfc1091.txt)
        if ($option == $telConstHash{'TELOPT_TTYPE'}) {

            # Server: IAC SB TELOPT_TTYPE SEND IAC SE
            if (@tokenList == 1 && $tokenList[0] == $telConstHash{'TELNET_SEND'}) {

                $self->optSendTType();
            }

        # NEW-ENVIRON (New Environment option - RFC 1572, http://www.ietf.org/rfc/rfc1572.txt)
        } elsif ($option == $telConstHash{'TELOPT_NEW_ENVIRON'}) {

            #   Server: IAC SB NEW-ENVIRON SEND [ type ... [ type ... [ ... ] ] ] IAC SE
            if ($self->newEnvironMode eq 'client_agree') {

                $self->processNewEnvironData($parameters);
            }

        # CHARSET (Character encoding - RFC 1073, http://www.ietf.org/rfc/rfc2066.txt)
        } elsif ($option == $telConstHash{'TELOPT_CHARSET'}) {

            #   Server: IAC SB CHARSET REQUEST { "[TTABLE ]" <Version> } <char set list> IAC SE
            if ($self->charSetMode eq 'client_agree') {

                $self->processCharSetData($parameters);
            }

        # MSDP (Mud Server Data Protocol - http://tintin.sourceforge.io/msdp/)
        } elsif ($option == $telConstHash{'TELOPT_MSDP'}) {

            # There's a lot of stuff to process, so use a separate function
            # If processed MSDP data was garbage, $self->msdpMode has been set to 'client_ignore',
            #   so we check for that here
            if ($self->msdpMode ne 'client_ignore') {

                %msdpHash = $self->extractMsdpData($parameters);
                if (%msdpHash) {

                    $self->processMsdpData(%msdpHash);
                }
            }

        # MSSP (Mud Server Status Protocol - http://tintin.sourceforge.io/mssp/)
        } elsif ($option == $telConstHash{'TELOPT_MSSP'}) {

            # Server: IAC SB TELOPT_MSSP MSSP_VAR <variable> MSSP_VAL <value> MSSP_VAR <variable>
            #               MSSP_VAL <value> IAC SE

            # Compile a hash in the form
            #   $msspHash{variable} = {last_matching_value}
            %msspHash = $self->extractMsspData($parameters);

            # Update the corresponding hash in the world profile
            if (%msspHash) {

                $self->processMsspData(%msspHash);
            }

        # MCCP1 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        } elsif ($option == $telConstHash{'TELOPT_MCCP1'}) {

            # Server: IAC SB TELOPT_MCCP1 WILL SE
            if ($self->mccpMode eq 'client_agree') {

                # Server has started compression
                $self->ivPoke('mccpMode', 'compress_start');
            }

        # MCCP2 (Mud Client Compression Protocol - http://tintin.sourceforge.io/mccp/)
        } elsif ($option == $telConstHash{'TELOPT_MCCP2'}) {

            # Server: IAC SB TELOPT_MCCP2 IAC SE
            if ($self->mccpMode eq 'client_agree') {

                # Server has started compression
                $self->ivPoke('mccpMode', 'compress_start');
            }

        # MXP (Mud Extension Protocol - http://www.zuggsoft.com/zmud/mxp.htm)
        } elsif ($option == $telConstHash{'TELOPT_MXP'}) {

            # (Axmud ignores the confirming IAC SB MXP IAC SE, because not all worlds seem to send
            #   it and, in any case, we've already set $self->mxpMode to 'client_agree')

            # ... (do nothing)

        # ZMP (Zenith Mud Protocol - http://discworld.starturtle.net/external/protocols/zmp.html)
        } elsif ($option == $telConstHash{'TELOPT_ZMP'}) {

            # Server: IAC SB TELOPT_ATCP Package[.SubPackages][.Command] NUL <parameters> IAC SE

            # Separate the command and its optional parameters
            if ($parameters =~ m/^([\w\-][\w\-\.]*)(\x00.*)?/) {

                $name = lc($1);
                $data = $2;

                if (defined $data) {

                    # Remove the initial nul byte
                    $data = substr($data, 1);
                    # And split into a list of data parameters
                    @dataList = split(/x00/, $data);
                }

                # Process the command
                $self->processZmpData($name, @dataList);
            }

        # AARD102 (Aardwolf 102 channel
        #   - http://www.aardwolf.com/blog/2008/07/10/
        #       telnet-negotiation-control-mud-client-interaction/
        } elsif ($option == $telConstHash{'TELOPT_AARD102'}) {

            # Server: IAC SB TELOPT_AARD102 byte byte IAC SE

            # Separate out the two bytes, and pass them (as numbers) for processing
            $byte = substr($parameters, 0, 1);
            if (defined $byte) {

                $byte = ord($byte);
            }

            $byte2 = substr($parameters, 1, 1);
            if (defined $byte2) {

                $byte2 = ord($byte2);
            }

            $self->processAard102Data($byte, $byte2);

        # ATCP (Achaea Telnet Client Protocol)
        #   - https://www.ironrealms.com/rapture/manual/files/FeatATCP-txt.html
        } elsif ($option == $telConstHash{'TELOPT_ATCP'}) {

            # Server: IAC SB TELOPT_ATCP Package[.SubPackages][.Message] <data> IAC SE

            # Separate the package name and data components
            if ($parameters =~ m/^([[:alpha:]\_][[:word:]\-\.]*)\s(.*)/) {

                $name = lc($1);
                $data = $2;

                # Process the package
                $self->processAtcpData($name, $data);
            }

        # Use GMCP (Generic MUD Communication Protocol) - https://www.gammon.com.au/gmcp
        } elsif ($option == $telConstHash{'TELOPT_GMCP'}) {

            # Server: IAC SB TELOPT_GMCP Package[.SubPackages][.Message] <data> IAC SE

            # Separate the package name and data components
            if ($parameters =~ m/^([[:alpha:]\_][[:word:]\-\.]*)\s(.*)/) {

                $name = lc($1);
                $data = $2;

                # Process the package
                $self->processGmcpData($name, $data);
            }
        }

        # Record the time at which the last out-of-bounds communication was received
        $self->ivPoke('lastOutBoundsTime', $self->sessionTime);
        # Turn on the window blinker, and update IVs
        $self->turnOnBlinker(1);

        return 1;
    }

    sub optSendDo {

        # Can be called by anything
        # Sends a standard IAC DO <option> message to the server
        #
        # Expected arguments
        #   $option     - The (numerical) telnet negotiation option (e.g. 31 for NAWS)
        #
        # Return values
        #   'undef' on improper arguments and in 'offline' mode
        #   1 otherwise

        my ($self, $option, $check) = @_;

        # Local variables
        my (
            $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendDo', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # CLIENT: IAC DO <option>
        $telCmd = pack(
            "C*",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_DO'},
            $option,
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendDont {

        # Can be called by anything
        # Sends a standard IAC DONT <option> message to the server
        #
        # Expected arguments
        #   $option     - The (numerical) telnet negotiation option (e.g. 31 for NAWS)
        #
        # Return values
        #   'undef' on improper arguments and in 'offline' mode
        #   1 otherwise

        my ($self, $option, $check) = @_;

        # Local variables
        my (
            $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendDont', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # CLIENT: IAC DONT <option>
        $telCmd = pack(
            "C*",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_DONT'},
            $option,
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendWill {

        # Can be called by anything
        # Sends a standard IAC WILL <option> message to the server
        #
        # Expected arguments
        #   $option     - The (numerical) telnet negotiation option (e.g. 31 for NAWS)
        #
        # Return values
        #   'undef' on improper arguments and in 'offline' mode
        #   1 otherwise

        my ($self, $option, $check) = @_;

        # Local variables
        my (
            $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendWill', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # CLIENT: IAC WILL <option>
        $telCmd = pack(
            "C*",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_WILL'},
            $option,
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendWont {

        # Can be called by anything
        # Sends a standard IAC WONT <option> message to the server
        #
        # Expected arguments
        #   $option     - The (numerical) telnet negotiation option (e.g. 31 for NAWS)
        #
        # Return values
        #   'undef' on improper arguments and in 'offline' mode
        #   1 otherwise

        my ($self, $option, $check) = @_;

        # Local variables
        my (
            $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendWont', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # CLIENT: IAC WONT <option>
        $telCmd = pack(
            "C*",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_WONT'},
            $option,
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendNaws {

        # Called by $self->optCallback and $self->textViewSizeUpdate
        # Sends a NAWS telnet option to the server (the width and height, in characters, of the
        #   Gtk3::Textview in this session's default tab)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or in 'offline' mode
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $width, $height, $telCmd,
            @optList,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendNaws', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode, or if the default tab's textview size isn't yet set
        if (
            ! $self->connectObj
            || ! $self->defaultTabObj
            || ! defined $self->defaultTabObj->textViewObj->textWidthChars
        ) {
            return undef;
        }

        # Import the width/height (for convenience)
        $width = $self->defaultTabObj->textViewObj->textWidthChars;
        $height = $self->defaultTabObj->textViewObj->textHeightChars;

        # RFC 1073 requires the width/height in the format WIDTH[1] WIDTH[0] HEIGHT[1] HEIGHT[0]
        # In addition, any 255 values must be double escaped (call ->checkOptList to do that)
        @optList = (
            $self->checkOptList(
                (int($width / 256)),     # WIDTH[1]
                ($width % 256),          # WIDTH[0]
                (int($height / 256)),    # HEIGHT[1]
                ($height % 256),         # HEIGHT[0]
            ),
        );

        # Client: IAC SB TELOPT_NAWS <w1> <w0> <h1> <h0> IAC SE
        $telCmd = pack(
            "C*",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_NAWS'},
            @optList,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendTType {

        # Called by $self->subOptCallback
        # Sends terminal type/MTTS information to the server
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if TTYPE has been disabled recently or in 'offline' mode
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $item, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendTType', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # $self->sendTTypeList contains a list of terminal types that can be sent in response to
        #   each server request. If the list contains two or more items, we remove the first item
        #   and send it. If there is only one item left, we don't remove and send it repeatedly from
        #   now on (the server should stop sending requests after sending it for a second time)
        # NB If GA::Client->useTTypeFlag has recently been set to FALSE, ->sendTTypeList will be
        #   empty and we don't respond
        if (
            ! $self->sendTTypeList
            || $self->currentWorld->ivExists('telnetOverrideHash', 'ttype')
        ) {
            return undef;

        } elsif ($self->ivNumber('sendTTypeList') > 1) {

            $item = $self->ivShift('sendTTypeList');

        } else {

            $item = $self->ivFirst('sendTTypeList');
        }

        # Client:  IAC SB TELOPT_TTYPE IS <terminal_type> IAC SE
        $telCmd = pack(
            "C4 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_TTYPE'},
            $telConstHash{'TELNET_IS'},
            $item,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendNewEnviron {

        # Called by $self->processNewEnvironData
        # Sends environment variables/MNES to the server
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   @argList    - a list in groups of 3, in the form
        #                   (type, name, value)
        #                 ...where 'type' is TELOPT_VAR or TELOPT_USERVAR, 'name' is an
        #                   environment variable like 'SYSTEMTYPE', and 'value' is the variable's
        #                   value (or 'undef', if Axmud doesn't use the variable)
        #
        # Return values
        #   'undef' on improper arguments or in 'offline' mode
        #   1 otherwise

        my ($self, @argList) = @_;

        # Local variables
        my (
            $telCmd, $string,
            @list,
            %telConstHash, %teloptHash,
        );

        # (No improper arguments to check)

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # A hash of NEW-ENVIRON telopt codes
        %teloptHash = (
            'TELOPT_IS'         => 0,
            'TELOPT_SEND'       => 1,
            'TELOPT_INFO'       => 2,
            'TELOPT_VAR'        => 0,
            'TELOPT_VALUE'      => 1,
            'TELOPT_ESC'        => 2,
            'TELOPT_USERVAR'    => 3,
        );

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # IAC SB NEW-ENVIRON IS type ... [ VALUE ... ] [ type ... [ VALUE ... ] [ ... ] ] IAC SE
        $string = "C4";
        push (@list,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_NEW_ENVIRON'},
            $teloptHash{'TELOPT_IS'},
        );

        if (@argList) {

            do {

                my ($type, $name, $value);

                $type = shift @argList;
                $name = shift @argList;
                $value = shift @argList;

                # (Basic sanity checking)
                if (defined $name) {

                    # Append type ... [ VALUE ... ]
                    $string .= " C1 A" . length($name);
                    push (@list,
                        $type,              # TELOPT_VAR or TELOPT_USERVAR
                        $name,
                    );

                    if (defined $value) {

                        $string .= " C1 A" . length($value);
                        push (@list,
                            $teloptHash{'TELOPT_VALUE'},
                            $value,
                        );
                    }
                }

            } until (! @argList);
        }

        $string .= " C2";
        push (@list,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        $telCmd = pack($string, @list);

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendCharSet {

        # Called by $self->processCharSetData
        # Sends character set acceptance/rejection to the server
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $charSet    - If specified, sends an acceptance to the server. If 'undef' or an empty
        #                   string, sends a rejection to the server
        #
        # Return values
        #   'undef' on improper arguments or in 'offline' mode
        #   1 otherwise

        my ($self, $charSet, $check) = @_;

        # Local variables
        my (
            $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendCharSet', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        if (! defined $charSet || $charSet eq '') {

            # Client:   IAC SB TELOPT_CHARSET REJECTED IAC SE
            $telCmd = pack(
                "C6",
                $telConstHash{'TELNET_IAC'},
                $telConstHash{'TELNET_SB'},
                $telConstHash{'TELOPT_CHARSET'},
                3,
                $telConstHash{'TELNET_IAC'},
                $telConstHash{'TELNET_SE'},
            );

        } else {

            # Client:   IAC SB CHARSET ACCEPTED <Charset> IAC SE
            $telCmd = pack(
                "C4 A* C2",
                $telConstHash{'TELNET_IAC'},
                $telConstHash{'TELNET_SB'},
                $telConstHash{'TELOPT_CHARSET'},
                2,
                $charSet,
                $telConstHash{'TELNET_IAC'},
                $telConstHash{'TELNET_SE'},
            );
        }

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendMsdpList {

        # Called by $self->optCallback or any other function
        # Sends an MSDP LIST command
        #
        # Expected arguments
        #   $arg    - The LIST command argument. Should be one of the keys in
        #               $self->msdpGenericListHash or $self->msdpCustomListHash (e.g. 'COMMANDS',
        #               CONFIGURABLE_VARIABLES'), in both cases the matching value should be TRUE
        #           - NB $arg is case insensitive
        #
        # Return values
        #   'undef' on improper arguments, if $arg is invalid or in 'offline' mode
        #   1 otherwise

        my ($self, $arg, $check) = @_;

        # Local variables
        my (
            $ucArg, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $arg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpList', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that $arg is valid
        $ucArg = uc($arg);
        if (
            ! $self->ivShow('msdpGenericListHash', $ucArg)
            && ! $self->ivShow('msdpCustomListHash', $ucArg)
        ) {
            return $self->writeWarning(
                'Invalid or unsupported MSDP list \'' . $arg . '\'',
                $self->_objClass . '->optSendMsdpList',
            );
        }

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR "LIST" MDSP_VAL <arg> IAC SE
        $telCmd = pack(
            "C4 A* C1 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            $telConstHash{'MSDP_VAR'},
            'LIST',
            $telConstHash{'MSDP_VAL'},
            $ucArg,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendMsdpReport {

        # Called by $self->processMsdpData, $self->doLogin or any other function
        # Sends a REPORT command to the server with one or more variables. Each variable is one of
        #   the reportable variables that the server supports, and which is stored as a key in
        #   $self->msdpGenericReportableFlagHash or $self->msdpCustomReportableFlagHash.
        # In addition, $self->msdpGenericReportedFlagHash and ->msdpCustomReportedFlagHash are also
        #   updated to show that the variables are now (presumably) being reported
        #
        # Expected arguments
        #   @list   - A list in the form (<var>, <var>, <var>, ...) (all values are
        #               case-insensitive)
        #
        # Return values
        #   'undef' on improper arguments or if a variable is specified which doesn't exist in
        #       either $self->msdpGenericReportableFlagHash or $self->msdpCustomReportableFlagHash,
        #       or which is marked as unreportable in ->msdpGenericReportableFlagHash, or in
        #       'offline' mode
        #   1 otherwise

        my ($self, @list) = @_;

        # Local variables
        my (
            $packString, $telCmd,
            @argList,
            %telConstHash,
        );

        # Check for improper arguments
        if (! @list) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpReport', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # Set the first part of the string used in the Perl 'pack' statement, and add to it as we
        #   go
        $packString = "C4 A*";

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that each <var> is valid, and compile a list of arguments to send to the server
        foreach my $var (@list) {

            my $ucVar = uc($var);

            # Check that each <var> is valid
            if (
                ! $self->ivShow('msdpGenericReportableFlagHash', $ucVar)
                && ! $self->ivShow('msdpCustomReportableFlagHash', $ucVar)
            ) {
                return $self->writeWarning(
                    'Invalid or unsupported MSDP reportable variable \'' . $var . '\'',
                    $self->_objClass . '->optSendMsdpReport',
                );

            } else {

                push (@argList,
                    $telConstHash{'MSDP_VAL'},
                    $var,
                );

                $packString .= " C1 A*";
            }
        }

        $packString .= " C2";

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR "REPORT" MDSP_VAL <var> ... IAC SE
        $telCmd = pack(
            $packString,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            $telConstHash{'MSDP_VAR'},
            'REPORT',
            @argList,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {

            # Update the reported variables hashes
            foreach my $var (@list) {

                my $ucVar = uc($var);

                if ($self->ivExists('msdpGenericReportedFlagHash', $ucVar)) {
                    $self->ivAdd('msdpGenericReportedFlagHash', $ucVar, TRUE);
                } else {
                    $self->ivAdd('msdpCustomReportedFlagHash', $ucVar, TRUE);
                }
            }

            return 1;
        }
    }

    sub optSendMsdpReset {

        # Can be called by any other function
        # Sends an MSDP RESET command
        #
        # Expected arguments
        #   $arg    - The RESET command argument. Should be one of the keys in
        #               $self->msdpGenericListHash or $self->msdpCustomListHash (e.g. 'COMMANDS',
        #               CONFIGURABLE_VARIABLES'), in both cases the matching value should be TRUE
        #           - NB $arg is case insensitive
        #
        # Return values
        #   'undef' on improper arguments, if $arg is invalid or in 'offline' mode
        #   1 otherwise

        my ($self, $arg, $check) = @_;

        # Local variables
        my (
            $ucArg, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $arg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpReset', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that $arg is valid
        $ucArg = uc($arg);
        if (
            ! $self->ivShow('msdpGenericListHash', $ucArg)
            && ! $self->ivShow('msdpCustomListHash', $ucArg)
        ) {
            return $self->writeWarning(
                'Invalid or unsupported MSDP list \'' . $arg . '\'',
                $self->_objClass . '->optSendMsdpReset',
            );
        }

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR "RESET" MDSP_VAL <arg> IAC SE
        $telCmd = pack(
            "C4 A* C1 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            $telConstHash{'MSDP_VAR'},
            'RESET',
            $telConstHash{'MSDP_VAL'},
            $ucArg,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendMsdpSend {

        # Called by $self->processMsdpData, $self->doLogin or any other function
        # Sends a SEND command to the server with one or more variables. Each variable is one of
        #   the sendable variables that the server supports, and which is stored as a key in
        #   $self->msdpGenericSendableFlagHash or $self->msdpCustomSendableFlagHash.
        #
        # Expected arguments
        #   @list   - A list in the form (<var>, <var>, <var>, ...) (all values are
        #               case-insensitive)
        #
        # Return values
        #   'undef' on improper arguments or if a variable is specified which doesn't exist in
        #       either $self->msdpGenericSendableFlagHash or $self->msdpCustomSendableFlagHash,
        #       or which is marked as unsendable in ->msdpGenericSendableFlagHash, or in 'offline'
        #       mode
        #   1 otherwise

        my ($self, @list) = @_;

        # Local variables
        my (
            $packString, $telCmd,
            @argList,
            %telConstHash,
        );

        # Check for improper arguments
        if (! @list) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpSend', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # Set the first part of the string used in the Perl 'pack' statement, and add to it as we go
        $packString = "C4 A*";

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that each <var> is valid, and compile a list of arguments to send to the server
        foreach my $var (@list) {

            my $ucVar = uc($var);

            # Check that each <var> is valid
            if (
                ! $self->ivShow('msdpGenericSendableFlagHash', $ucVar)
                && ! $self->ivShow('msdpCustomSendableFlagHash', $ucVar)
            ) {
                return $self->writeWarning(
                    'Invalid or unsupported MSDP sendable variable \'' . $var . '\'',
                    $self->_objClass . '->optSendMsdpSend',
                );

            } else {

                push (@argList,
                    $telConstHash{'MSDP_VAL'},
                    $var,
                );

                $packString .= " C1 A*";
            }
        }

        $packString .= " C2";

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR "SEND" MDSP_VAL <var> ... IAC SE
        $telCmd = pack(
            $packString,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            $telConstHash{'MSDP_VAR'},
            'SEND',
            @argList,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendMsdpUnreport {

        # Can be called by any other function
        # Sends a UNREPORT command to the server with one or more variables. Each variable is one of
        #   the reported variables that the server supports, and which is stored as a key in
        #   $self->msdpGenericReportedFlagHash or $self->msdpCustomReportedFlagHash.
        # In addition, $self->msdpGenericReportedFlagHash and ->msdpCustomReportedFlagHash are also
        #   updated to show that the variables are (presumably) no longer being reported
        #
        # Expected arguments
        #   @list   - A list in the form (<var>, <var>, <var>, ...) (all values are
        #               case-insensitive)
        #
        # Return values
        #   'undef' on improper arguments or if a variable is specified which doesn't exist in
        #       either $self->msdpGenericReportedFlagHash or $self->msdpCustomReportedFlagHash,
        #       or which is marked as unreported in ->msdpGenericReportedFlagHash, or in 'offline'
        #       mode
        #   1 otherwise

        my ($self, @list) = @_;

        # Local variables
        my (
            $packString, $telCmd,
            @argList,
            %telConstHash,
        );

        # Check for improper arguments
        if (! @list) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpUnreport', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # Set the first part of the string used in the Perl 'pack' statement, and add to it as we
        #   go
        $packString = "C4 A*";

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that each <var> is valid, and compile a list of arguments to send to the server
        foreach my $var (@list) {

            my $ucVar = uc($var);

            # Check that each <var> is valid
            if (
                ! $self->ivShow('msdpGenericReportedFlagHash', $ucVar)
                && ! $self->ivShow('msdpCustomReportedFlagHash', $ucVar)
            ) {
                return $self->writeWarning(
                    'Invalid or unsupported MSDP reported variable \'' . $var . '\'',
                    $self->_objClass . '->optSendMsdpUnreport',
                );

            } else {

                push (@argList,
                    $telConstHash{'MSDP_VAL'},
                    $var,
                );

                $packString .= " C1 A*";
            }
        }

        $packString .= " C2";

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR "REPORT" MDSP_VAL <var> ... IAC SE
        $telCmd = pack(
            $packString,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            $telConstHash{'MSDP_VAR'},
            'UNREPORT',
            @argList,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {

            # Update the reported variables hashes
            foreach my $var (@list) {

                my $ucVar = uc($var);

                if ($self->ivExists('msdpGenericReportedFlagHash', $ucVar)) {
                    $self->ivAdd('msdpGenericReportedFlagHash', $ucVar, FALSE);
                } else {
                    $self->ivAdd('msdpCustomReportedFlagHash', $ucVar, FALSE);
                }
            }

            return 1;
        }
    }

    sub optSendMsdpVars {

        # Called by $self->processMsdpData or any other function
        # Sends one or more variable-value pairs to the server. Each variable is one of the
        #   configurable variables that the server supports, and which is stored as a key in
        #   $self->msdpGenericConfigFlagHash or $self->msdpCustomConfigFlagHash. The corresponding
        #   value is sent to the server as well as being stored in $self->msdpGenericConfigValHash
        #   or $self->msdpCustomConfigValHash
        #
        # Expected arguments
        #   @list   - A list in the form (<var>, <value>, <var>, <value>...) (all values are
        #               case-insensitive)
        #
        # Return values
        #   'undef' on improper arguments or if a variable is specified which doesn't exist in
        #       either $self->msdpGenericConfigFlagHash or $self->msdpCustomConfigFlagHash, or
        #       which is marked as unconfigurable in ->msdpGenericConfigFlagHash, or in 'offline'
        #       mode
        #   1 otherwise

        my ($self, @list) = @_;

        # Local variables
        my (
            $packString, $telCmd,
            @argList,
            %telConstHash, %updateHash,
        );

        # Check for improper arguments
        if (! @list) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpVars', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # Set the first part of the string used in the Perl 'pack' statement, and add to it as we
        #   go
        $packString = "C3 ";

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that each <var> is valid, and compile a list of arguments to send to the server
        do {

            my ($var, $val, $ucVar, $ucVal);

            $var = shift @list;
            $val = shift @list;
            if (! defined $var || ! defined $val) {

                return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMsdpVars', @_);

            } else {

                $ucVar = uc($var);
                $ucVal = uc($val);
            }

            # Check that each <var> is valid
            if (
                ! $self->ivShow('msdpGenericConfigFlagHash', $ucVar)
                && ! $self->ivShow('msdpCustomConfigFlagHash', $ucVar)
            ) {
                return $self->writeWarning(
                    'Invalid or unsupported MSDP configurable variable \'' . $var . '\'',
                    $self->_objClass . '->optSendMsdpVars',
                );

            } else {

                push (@argList,
                    $telConstHash{'MSDP_VAR'},
                    $var,
                    $telConstHash{'MSDP_VAL'},
                    $val,
                );

                $packString .= " C1 A* C1 A*";

                # In a moment we'll update $self->msdpGenericConfigFlagHash and
                #   ->msdpCustomConfigFlagHash
                $updateHash{$ucVar} = $ucVal;
            }

        } until (! @list);

        $packString .= " C2";

        # Client:  IAC SB TELOPT_MSDP MSDP_VAR <var> MDSP_VAL <var> ... IAC SE
        $telCmd = pack(
            $packString,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_MSDP'},
            @argList,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;

        } else {

            # Send successful. Update Session hashes
            foreach my $var (keys %updateHash) {

                my $val = $updateHash{$var};

                if ($self->ivExists('msdpGenericConfigFlagHash', $var)) {
                    $self->ivAdd('msdpGenericConfigValHash', $var, $val);
                } else {
                    $self->ivAdd('msdpCustomConfigValHash', $var, $val);
                }
            }

            return 1;
        }
    }

    sub optSendMxpSecure {

        # Called by $self->processMxpOfficialElement or any other code
        # Sends a SECURE-tagged line to the world
        #
        # Expected arguments
        #   $string     - A string to send. This function adds '<ESC>[1z' to the beginning of
        #                   $string, and a newline character to the end
        #
        # Return values
        #   'undef' on improper arguments or if the line can't be sent
        #   1 otherwise

        my ($self, $string, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendMxpSecure', @_);
        }

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        $string = "\e\[1z$string\n";

        if (
            ! $self->connectObj->put(
                String => $string,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendZmp {

        # Can be called by anything
        # Sends a ZMP command to the world (in the same format as commands sent from the world to
        #   the client)
        #
        # Expected arguments
        #   $cmd        - The ZMP command to send, usually a string in the form
        #                   'Package[.SubPackages][.Command]'. Only ZMP-compliant command names are
        #                   sent (the command must contain only ASCII alph-numeric characters, dots
        #                   and dashes; must not begin or end with a dot)
        #
        # Optional arguments
        #   @paramList  - An optional list of parameters. Must not contain NUL bytes; any IAC bytes
        #                   are escaped by this function
        #
        # Return values
        #   'undef' on improper arguments, if the session is in 'offline' mode or if $cmd is
        #       invalid/forbidden
        #   1 otherwise

        my ($self, $cmd, @paramList) = @_;

        # Local variables
        my (
            $escape, $payload, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $cmd) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendZmp', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that a $cmd was specified
        if ($cmd eq '') {

            return $self->writeWarning(
                'Invalid empty ZMP command name',
                $self->_objClass . '->optSendZmp',
            );

        # Check that $cmd is in the correct format. If not, we don't the command
        } elsif (! ($cmd =~ m/^[\w\-]([\w\-\.]*[\w\-])?$/)) {

            return $self->writeWarning(
                'Invalid ZMP command name',
                $self->_objClass . '->optSendZmp',
            );
        }

        # Check that the parameters, if specified, don't contain a NUL byte
        foreach my $param (@paramList) {

            if ($param =~ m/x00/) {

                return $self->writeWarning(
                    'Invalid ZMP parameter',
                    $self->_objClass . '->optSendZmp',
                );
            }
        }

        # Check that the parameters, if specified, don't contain any un-escaped IAC bytes. If so,
        #   replaced them with an IAC escape sequence
        $escape = chr(255) . chr(255);
        foreach my $param (@paramList) {

            $param =~ s/([^x255])x255([^x255])/$1$escape$2/g;
            $param =~ s/^x255([^x255])/$escape$1/;
            $param =~ s/([^x255])x255$/$1$escape/;
        }

        # If the command is 'zmp.ident', check that this command hasn't been sent before
        if (lc($cmd) eq 'zmp.ident') {

            if ($self->zmpSendIdentFlag) {

                # ZMP spec says we're not allowed to send it twice
                return undef;

            } else {

                $self->ivPoke('zmpSendIdentFlag', TRUE);
            }
        }

        # Set the payload
        unshift(@paramList, $cmd);
        $payload = join(chr(0), @paramList);

        $telCmd = pack(
            "C3 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_ZMP'},
            $payload,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendAard102 {

        # Can be called by anything
        # Sends an AARD102 sequence to the world
        #
        # Expected arguments
        #   $option     - The option to send; should be a number in the range 1-9, 11-12, 14-17,
        #                   50-53. This function will send any number in the range 1-254
        #   $flag       - TRUE to turn the option on (e.g. option 9 to turn on say tags), FALSE to
        #                   turn it off (e.g. option 17 to turn off inventory tags)
        #
        # Return values
        #   'undef' on improper arguments, if the session is in 'offline' mode or if $option is not
        #       in the range 1-254
        #   1 otherwise

        my ($self, $option, $flag, $check) = @_;

        # Local variables
        my (
            $payload, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $option || ! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendAard102', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode, or if $option is an invalid value
        if (! $self->connectObj || ! $axmud::CLIENT->intCheck($option, 1, 254))  {

            return undef;
        }

        # Set the payload
        $payload = chr($option);
        if (! $flag) {
            $payload .= chr(2);     # Off
        } else {
            $payload .= chr(1);     # On
        }

        $telCmd = pack(
            "C3 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_AARD102'},
            $payload,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendAtcp {

        # Can be called by anything
        # Sends an ATCP package to the world (in the same format as packages sent from the world to
        #   the client)
        #
        # Expected arguments
        #   $name       - The name of the ATCP module, usually a string in the form
        #                   'Package[.SubPackages][.Message]', but any non-empty string will be sent
        #                   (e.g. Aardwolf allows pseudo-package names like 'request room')
        #
        # Optional arguments
        #   $data       - A scalar of encoded JSON data, e.g. '{ "zone": "town" }'. If 'undef', or
        #                   if $name isn't in the form 'Package[.SubPackages][.Message]', the $data
        #                   scalar is sent (only $name is sent)
        #   $encodeFlag - If TRUE, $data is assumed to be a scalar representing decoded JSON data;
        #                   either a true scalar, or a list/hash reference (possibly including
        #                   embedded list/hash) references. If FALSE or 'undef', or if TRUE and
        #                   $data can't be encoded, $data is not modified
        #
        # Return values
        #   'undef' on improper arguments, if $arg is invalid or in 'offline' mode
        #   1 otherwise

        my ($self, $name, $data, $encodeFlag, $check) = @_;

        # Local variables
        my (
            $payload, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $name || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendAtcp', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that a $name was specified
        if ($name eq '') {

            return $self->writeWarning(
                'Invalid empty ATCP package name',
                $self->_objClass . '->optSendAtcp',
            );

        # Check that $name is in the correct format. If not, we don't send $data, if specified
        } elsif (! ($name =~ m/^[[:alpha:]\_][[:word:]\-\.]*$/)) {

            $data = undef;
        }

        # Encode $data, if required
        if (defined $data && $data ne '' && $encodeFlag) {

            $data = $axmud::CLIENT->encodeJson($data);
        }

        # Set the payload
        if (! defined $data || $data eq '') {
            $payload = $name;
        } else {
            $payload = $name . ' ' . $data;
        }

        # Client:  IAC SB ATCP Package[.SubPackages][.Message] <data> IAC SE
        $telCmd = pack(
            "C3 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_ATCP'},
            $payload,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    sub optSendGmcp {

        # Can be called by anything
        # Sends an GMCP package to the world (in the same format as packages sent from the world to
        #   the client)
        #
        # Expected arguments
        #   $name       - The name of the GMCP module, usually a string in the form
        #                   'Package[.SubPackages][.Message]', but any non-empty string will be sent
        #                   (e.g. Aardwolf allows pseudo-package names like 'request room')
        #
        # Optional arguments
        #   $data       - A scalar of encoded JSON data, e.g. 'comm.repop { "zone": "town" }'. If
        #                   'undef', or if $name isn't in the form
        #                   'Package[.SubPackages][.Message]', the $data scalar is sent (only $name
        #                   is sent)
        #   $encodeFlag - If TRUE, $data is assumed to be a scalar representing decoded JSON data;
        #                   either a true scalar, or a list/hash reference (possibly including
        #                   embedded list/hash) references. If FALSE or 'undef', or if TRUE and
        #                   $data can't be encoded, $data is not modified
        #
        # Return values
        #   'undef' on improper arguments, if $arg is invalid or in 'offline' mode
        #   1 otherwise

        my ($self, $name, $data, $encodeFlag, $check) = @_;

        # Local variables
        my (
            $payload, $telCmd,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $name || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->optSendGmcp', @_);
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Do nothing in offline mode
        if (! $self->connectObj) {

            return undef;
        }

        # Check that a $name was specified
        if ($name eq '') {

            return $self->writeWarning(
                'Invalid empty GMCP package name',
                $self->_objClass . '->optSendGmcp',
            );

        # Check that $name is in the correct format. If not, we don't send $data, if specified
        } elsif (! ($name =~ m/^[[:alpha:]\_][[:word:]\-\.]*$/)) {

            $data = undef;
        }

        # Encode $data, if required
        if (defined $data && $data ne '' && $encodeFlag) {

            $data = $axmud::CLIENT->encodeJson($data);
        }

        # Set the payload
        if (! defined $data || $data eq '') {
            $payload = $name;
        } else {
            $payload = $name . ' ' . $data;
        }

        # Client:  IAC SB GMCP Package[.SubPackages][.Message] <data> IAC SE
        $telCmd = pack(
            "C3 A* C2",
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SB'},
            $telConstHash{'TELOPT_GMCP'},
            $payload,
            $telConstHash{'TELNET_IAC'},
            $telConstHash{'TELNET_SE'},
        );

        if (
            ! $self->connectObj->put(
                String => $telCmd,
                Telnetmode => 0,
            )
        ) {
            return undef;
        } else {
            return 1;
        }
    }

    # Telnet option negotiation / MUD protocols - support functions

    sub checkOptList {

        # Called by various functions
        # Telnet option negotation requires that the value 255 should represent TELNET_IAC; if the
        #   actual value 255 is needed, it must be double-escaped
        # Check a list of one or more values in the range 0-255 and deal with any 255 values
        #   (e.g. convert the list (100, 200, 255, 0) into (100, 200, 255, 255, 0)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   @list   - A list of values in the range 0-255 (can be an empty list)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns the modified list

        my ($self, @list) = @_;

        # Local variables
        my @modList;

        # (No improper arguments to check)

        foreach my $value (@list) {

            push (@modList, $value);

            if ($value == 255) {

                # Double escape this value
                push (@modList, $value);
            }
        }

        return @modList;
    }

    sub updateEcho {

        # Called by $self->optCallback when the server requests that the client stop/resume ECHOing
        #   (also called by $self->disableTelnetOption)
        # If this is the current session and the client is not ECHOing world commands, obscures text
        #   in the command entry box for any 'internal' windows used by this session
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $unobscureFlag  - Set to TRUE when called by $self->doLogin, in which case we unobscure
        #                       the command entry box, regardless of the setting of $self->echoMode
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $unobscureFlag, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateEcho', @_);
        }

        # Generate a list of this session's entry strip objects (the TRUE argument means 'only
        #   return 'internal' windows')
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if (
                $stripObj
                && $stripObj->winObj->visibleSession
                && $stripObj->winObj->visibleSession eq $self
            ) {
                push (@list, $stripObj);
            }
        }

        foreach my $stripObj (@list) {

            # (The server has suggested that the client stop ECHOing, and the client has agreed)
            if ($self->echoMode eq 'client_agree' && ! $unobscureFlag) {

                $stripObj->obscureEntry(TRUE);

            # (The server has suggested that the client stop ECHOing, and the client has refused)
            # (The server has suggested that the client resume ECHOing, and the client has agreed)
            } else {

                $stripObj->obscureEntry(FALSE);
            }
        }

        # If the ECHO telnet option has been turned off and this session's special echo mode is
        #   enabled, disable it (extremely unlikely, but we'll check anyway)
        if ($self->echoMode ne 'client_agree' && $self->specialEchoMode eq 'enabled') {

            $self->ivPoke('specialEchoMode', 'disabled');

            # Need to inform the strip objects. Can't incorporate this code into the foreach loop
            #   just above, because $unobscureFlag is a complicating factor
            # (If the ECHO telnet option is turned back on at some later point, don't re-enable
            #   special echo mode. It's only enabled shortly after a login is completed)
            foreach my $stripObj (@list) {

                $stripObj->set_specialEchoFlag(FALSE);
            }
        }

        return 1;
    }

    sub updateSpecialEcho {

        # Called by $self->processIncomingData whenever there's a change in this session's special
        #   echo mode, in which case all strip entry objects (GA::Strip::Entry) must be informed
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateSpecialEcho', @_);
        }

        # (The TRUE argument means 'only return 'internal' windows)
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if ($stripObj) {

                # Make sure the entry box is unobscured (assuming this session is the visible one,
                #   and that the world has actually turned on echo mode)
                #   visible one)
                if (
                    $stripObj->winObj->visibleSession
                    && $stripObj->winObj->visibleSession eq $self
                    && $self->echoMode eq 'client_agree'
                ) {
                    $stripObj->obscureEntry(FALSE);
                }

                if ($self->specialEchoMode eq 'enabled') {

                    # Tell the strip entry object to start sending characters to the world, one at a
                    #   time, as soon as they're typed (but only for world commands)
                    $stripObj->set_specialEchoFlag(TRUE);

                } else {

                    # Tell the strip entry object to stop doing that
                    $stripObj->set_specialEchoFlag(FALSE);
                }
            }
        }

        return 1;
    }

    sub prepareTTypeData {

        # Called by $self->optCallback when the server first requests TTYPE data
        # Prepares (or resets) the items to be during TTYPE option sub-negotiations
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $termTypeMode, $customClientName, $customClientVersion, $useCtrlSeqFlag, $termType,
            @termList, @itemList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareTTypeData', @_);
        }

        # Override GA::Client IVs, if necessary
        if ($self->currentWorld->ivExists('termOverrideHash', 'termTypeMode')) {
            $termTypeMode = $self->currentWorld->ivShow('termOverrideHash', 'termTypeMode');
        } else {
            $termTypeMode = $axmud::CLIENT->termTypeMode;
        }

        if ($self->currentWorld->ivExists('termOverrideHash', 'customClientName')) {
            $customClientName = $self->currentWorld->ivShow('termOverrideHash', 'customClientName');
        } else {
            $customClientName = $axmud::CLIENT->customClientName;
        }

        if ($self->currentWorld->ivExists('termOverrideHash', 'customClientVersion')) {

            $customClientVersion
                = $self->currentWorld->ivShow('termOverrideHash', 'customClientVersion');

        } else {

            $customClientVersion = $axmud::CLIENT->customClientVersion;
        }

        # Check that VT100 control sequences are enabled, before proceeding
        if ($self->currentWorld->ivExists('termOverrideHash', 'useCtrlSeqFlag')) {
            $useCtrlSeqFlag = $self->currentWorld->ivShow('termOverrideHash', 'useCtrlSeqFlag');
        } else {
            $useCtrlSeqFlag = $axmud::CLIENT->useCtrlSeqFlag;
        }

        if (
            ! $axmud::CLIENT->useMttsFlag
            || $self->currentWorld->ivExists('telnetOverrideHash', 'mtts')
        ) {
            # Compile a list of terminal types to send to the server (depending on the values of
            #   GA::Client->termTypeMode, GA::Client->customClientName and
            #   $self->currentWorld->termType)
            # When $self->optSendTType sends a terminal type, it removes the first item from this
            #   list until there is only one left; then it continues sending that last item whenever
            #   the server requests a terminal type. (The server is supposed to look out for
            #   duplicates, and to stop sending requests at that point)

            # Import the list of supported termtypes. If the world profile's ->termType is set,
            #   remove it from this list, as it's going to be sent first (in modes 1-4)
            @termList = $axmud::CLIENT->constTermTypeList;
            if ($self->currentWorld->termType) {

                foreach my $item (@termList) {

                    if ($item ne $self->currentWorld->termType) {

                        push (@itemList, $item);
                    }
                }

            } else {

                @itemList = @termList;
            }

            if ($termTypeMode eq 'send_client') {

                unshift (@itemList, $axmud::NAME_SHORT);

            } elsif ($termTypeMode eq 'send_client_version') {

                unshift (@itemList, $axmud::NAME_SHORT . ' v' . $axmud::VERSION);

            } elsif ($termTypeMode eq 'send_custom_client') {

                if ($customClientName) {

                    if ($customClientVersion) {

                        unshift (
                            @itemList,
                            $customClientName . ' v' . $customClientVersion,
                        );

                    } else {

                        unshift (@itemList, $customClientName);
                    }
                }

            } elsif ($termTypeMode eq 'send_unknown') {

                @itemList = ('unknown');
            }

            if (
                $self->currentWorld->termType
                && (
                    $termTypeMode eq 'send_client' || $termTypeMode eq 'send_client_version'
                    || $termTypeMode eq 'send_custom_client' || $termTypeMode eq 'send_default'
                )
            ) {
                unshift (@itemList, $self->currentWorld->termType);
            }

            $self->ivPoke('sendTTypeList', @itemList);
            $self->ivPoke('specifiedTType', $itemList[0]);

        } else {

            # Compile a list of three items to send to the server (depending on the values of
            #   GA::Client->termTypeMode, ->customClientName and ->customClientVersion); Axmud does
            #   not cycle through term types when using MTTS
            if ($customClientName) {
                push (@itemList, uc($customClientName));
            } else {
                push (@itemList, uc($axmud::NAME_SHORT));
            }

            if ($self->currentWorld->termType) {

                $termType = lc($self->currentWorld->termType);      # Converted to upper case below
            }

            # MTTS only supports 'dumb', 'ansi', 'vt100' and 'xterm'
            if (
                $termType
                && (
                    $termType eq 'dumb'
                    || $termType eq 'ansi'
                    || $termType eq 'vt100'
                    || $termType eq 'xterm'
                )
            ) {
                push (@itemList, uc($termType));

            } else {

                # Default value best matching Axmud capabilities; also used by MNES
                if ($useCtrlSeqFlag) {
                    push (@itemList, 'VT100-TRUECOLOR');
                } else {
                    push (@itemList, 'ANSI-TRUECOLOR');
                }
            }

            # MTTS bitvector
            push (@itemList, 'MTTS ' . $self->compileMTTSBitVector($useCtrlSeqFlag));

            # All done
            $self->ivPoke('sendTTypeList', @itemList);
            $self->ivPoke('specifiedTType', $itemList[1]);
        }

        return 1;
    }

    sub compileMTTSBitVector {

        # Called by $self->prepareTTypeData (and also by ->processCharSetData)
        # Compiles the bitvector used by the MTTS standard (and re-used by the MNES standard)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $useCtrlSeqFlag     - Flag matching GA::Client->useCtrlSeqFlag. If not defined, then
        #                           this function finds it
        #
        # Return values
        #   'undef' on improper arguments
        #   A bitvector in the range 0-1023, otherwise (as described in
        #       http://tintin.sourceforge.io/mtts/)

        my ($self, $useCtrlSeqFlag, $check) = @_;

        # Local variables
        my $bitVector;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->compileMTTSBitVector', @_);
        }

        # If not supplied by the calling function, then check that VT100 control sequences are
        #   enabled
        if ($self->currentWorld->ivExists('termOverrideHash', 'useCtrlSeqFlag')) {
            $useCtrlSeqFlag = $self->currentWorld->ivShow('termOverrideHash', 'useCtrlSeqFlag');
        } else {
            $useCtrlSeqFlag = $axmud::CLIENT->useCtrlSeqFlag;
        }

        # MTTS bitvector:
        $bitVector = 0;

        #     1 "ANSI"              Client supports all ANSI color codes. . Supporting blink and
        #                               underline is optional
        $bitVector += 1;
        #     2 "VT100"             Client supports most VT100 codes
        if ($useCtrlSeqFlag) {

            $bitVector += 2;
        }
        #     4 "UTF-8"             Client is using UTF-8 character encoding
        if ($self->sessionCharSet =~ m/utf/i) {

            $bitVector += 4;
        }
        #     8 "256 COLORS"        Client supports all xterm 256 color codes
        $bitVector += 8;
        #    16 "MOUSE TRACKING"    Client supports xterm mouse tracking
        #    32 "OSC COLOR PALETTE" Client supports the OSC color palette
        if ($axmud::CLIENT->oscPaletteFlag) {

            $bitVector += 32;
        }
        #    64 "SCREEN READER"     Client is using a screen reader
        if ($axmud::BLIND_MODE_FLAG) {

            $bitVector += 64;
        }
        #   128 "PROXY"             Client is a proxy allowing different users to connect from the
        #                               same IP address
        #   256 "TRUECOLOR"         Client supports truecolor codes using semicolon notation
        $bitVector += 256;
        #   512 "MNES"              Client supports the Mud New Environment Standard for information
        #                               exchange.

        return $bitVector;
    }

    sub processNewEnvironData {

        # Called by $self->subOptCallback
        # Handles NEW-ENVIRON and MNES communications (explained in full below)
        #
        # Expected arguments
        #   $parameters     - A NEW-ENVIRON or MNES data string to process
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $mnesFlag, $cmd, $data, $modSessionType, $termTypeMode, $customClientName,
            $customClientVersion, $localIP, $useCtrlSeqFlag,
            @knownList, @customList, @argList,
            %teloptHash, %envHash,
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processNewEnvironData', @_);
        }

        # A hash of NEW-ENVIRON telopt codes
        %teloptHash = (
            'TELOPT_IS'         => 0,
            'TELOPT_SEND'       => 1,
            'TELOPT_INFO'       => 2,
            'TELOPT_VAR'        => 0,
            'TELOPT_VALUE'      => 1,
            'TELOPT_ESC'        => 2,
            'TELOPT_USERVAR'    => 3,
        );

        # A hash of 'well-known' environment variables. If Axmud can send a value, it's added to
        #   the hash; otherwise a key's corresponding value is 'undef'
        # Axmud doesn't have NEW-ENVIRON "user-defined" environment variables
        if (! $self->currentChar) {

            $envHash{'USER'} = undef;
            $envHash{'ACCT'} = undef;

        } else {

            $envHash{'USER'} = $self->currentChar->name;
            # (If no account name exists for this character, the value set is 'undef')
            $envHash{'ACCT'} = $self->currentWorld->ivShow('accountHash', $self->currentChar->name);
        }

        # System names are those defined by RFT1340
        if ($^O eq 'MSWin32') {
            $envHash{'SYSTEMTYPE'} = 'WIN32';
        } elsif ($^O eq 'linux') {
            $envHash{'SYSTEMTYPE'} = 'UNIX';
        } elsif ($^O eq 'MacOS') {
            $envHash{'SYSTEMTYPE'} = 'MACOS';
        } elsif ($^O =~ m/bsd/i) {
            $envHash{'SYSTEMTYPE'} = 'UNIX_BSD';
        } else {
            $envHash{'SYSTEMTYPE'} = undef;
        }

        # Axmud doesn't send values for these 'well-known' environment variables
        $envHash{'JOB'} = undef;
        $envHash{'PRINTER'} = undef;
        $envHash{'DISPLAY'} = undef;

        # MNES (MUD NEW-ENVIRON Standard - https://tintin.mudhalla.net/protocols/mnes/)
        # Implemented here alongside NEW-ENVIRON
        if ($self->currentWorld->ivExists('telnetOverrideHash', 'mnes')) {
            $mnesFlag = FALSE;
        } else {
            $mnesFlag = $axmud::CLIENT->useMnesFlag;
        }

        if ($mnesFlag) {

            # CHARSET - Character set
            # -----------------------

            # The default MNES values are specified by GA::Client->constMnesCharsetList
            # The value used in IVs like $self->charSet, GA::Session->worldCharset and
            #   GA::Profile::World->worldCharSet is generally provided by the system, and might be
            #   longer than the default MNES values (for example, 'koi8-r' instead of the MNES
            #   value, 'KOI-8')
            # Therefore we try to find a close match, rather than an exact match, by removing the
            #   hyphens, and looking for an MNES value which starts with $self->sessionCharSet
            # (For example, the MNES value 'KOI-8' becomes 'KOI8', $self->sessionCharset becomes
            #   'koi8r'; the latter contains the former, so that's a match)
            # Exception: the ISO codes must be specified exactly (so 'ISO-8859-11' does not match
            #   'ISO-8859-1')
            # If we don't find a match, use the MNES value 'ASCII' as a default
            $envHash{'CHARSET'} = 'ASCII';

            $modSessionType = uc($self->sessionCharSet);
            $modSessionType =~ s/\-//g;

            OUTER: foreach my $item ($axmud::CLIENT->constMnesCharsetList) {

                my $modClientType = $item;
                $modClientType =~ s/\-//g;

                if (uc(substr($modSessionType, 0, 3) eq 'ISO')) {

                    if ($modClientType eq $modSessionType) {

                        $envHash{'CHARSET'} = $item;
                        last OUTER;
                    }

                } elsif (index($modClientType, $modSessionType) == 0) {

                    $envHash{'CHARSET'} = $item;
                    last OUTER;
                }
            }

            # CLIENT_NAME - Name of the client
            # CLIENT_VERSION - Version of the client
            # --------------------------------------

            # (Use the same rules about disclosing this client's identity, as are used in
            #   TTYPE negotiations)
            if ($self->currentWorld->ivExists('termOverrideHash', 'termTypeMode')) {
                $termTypeMode = $self->currentWorld->ivShow('termOverrideHash', 'termTypeMode');
            } else {
                $termTypeMode = $axmud::CLIENT->termTypeMode;
            }

            if ($self->currentWorld->ivExists('termOverrideHash', 'customClientName')) {

                $customClientName
                    = $self->currentWorld->ivShow('termOverrideHash', 'customClientName');

            } else {

                $customClientName = $axmud::CLIENT->customClientName;
            }

            if ($self->currentWorld->ivExists('termOverrideHash', 'customClientVersion')) {

                $customClientVersion
                    = $self->currentWorld->ivShow('termOverrideHash', 'customClientVersion');

            } else {

                $customClientVersion = $axmud::CLIENT->customClientVersion;
            }

            if ($termTypeMode eq 'send_client') {

                $envHash{'CLIENT_NAME'} = $axmud::NAME_SHORT;
                # (User has opted out of displaying the actual client version, so just use a generic
                #   version)
                $envHash{'CLIENT_VERSION'} = '1.0.0';

            } elsif ($termTypeMode eq 'send_client_version') {

                $envHash{'CLIENT_NAME'} = $axmud::NAME_SHORT;
                $envHash{'CLIENT_VERSION'} = $axmud::VERSION;

            } elsif ($termTypeMode eq 'send_custom_client') {

                if ($customClientName) {

                    $envHash{'CLIENT_NAME'} = $customClientName;

                    if ($customClientVersion) {
                        $envHash{'CLIENT_VERSION'} = $customClientVersion;
                    } else {
                        $envHash{'CLIENT_VERSION'} = '1.0.0';
                    }
                }
            }

            # "IPADDRESS" - The client's IP address
            # -------------------------------------

            # Providing the user's real IP address, any proxy notwithstanding, is in the authors'
            #   view a breach of the user's privacy. Hence, the user must opt-in. If not, the same
            #   generic IP address sent by the Chat Task is used
            $envHash{'IPADDRESS'} = '127.0.0.1';
            if ($axmud::CLIENT->allowMnesSendIPFlag) {

                $localIP = $axmud::CLIENT->ipv4Get();
                if ($localIP) {

                    $envHash{'IPADDRESS'} = $localIP;
                }
            }

            # MTTS - bitvector
            # ----------------

            $envHash{'MTTS'} = $self->compileMTTSBitVector();

            # TERMINAL_TYPE - name
            # --------------------

            # Check that VT100 control sequences are enabled
            if ($self->currentWorld->ivExists('termOverrideHash', 'useCtrlSeqFlag')) {
                $useCtrlSeqFlag = $self->currentWorld->ivShow('termOverrideHash', 'useCtrlSeqFlag');
            } else {
                $useCtrlSeqFlag = $axmud::CLIENT->useCtrlSeqFlag;
            }

            # Default value best matching Axmud capabilities; also used by MTTS
            if ($useCtrlSeqFlag) {
                $envHash{'TERMINAL_TYPE'} = 'VT100-TRUECOLOR';
            } else {
                $envHash{'TERMINAL_TYPE'} = 'ANSI-TRUECOLOR';
            }
        }

        # $parameters is in the form <cmd_char><data>
        $cmd = substr($parameters, 0, 1);
        $data = substr($parameters, 1);

        # All NEW-ENVIRON commands except TELOPT_SEND are ignored
        if (ord($cmd) == $teloptHash{'TELOPT_SEND'}) {

            # IAC SB NEW-ENVIRON SEND [ type ... [ type ... [ ... ] ] ] IAC SE

            # $data can be an empty string, 'TELOPT_VAR', 'TELOPT_USERVAR',
            #   'TELOPT_VAR TELOPT_USERVAR', a single variable <name> (usually 'SYSTEMTYPE') or a
            #   sequence of 'VAR <name>' and 'USERVAR <name>' components
            if (
                $data eq ''
                || $data eq chr($teloptHash{'TELOPT_VAR'})
                || $data eq ( chr($teloptHash{'TELOPT_VAR'}) . chr($teloptHash{'TELOPT_USERVAR'}) )
            ) {
                push (@knownList, 'USER', 'JOB', 'ACCT', 'PRINTER', 'SYSTEMTYPE', 'DISPLAY');

            } elsif ($data eq chr($teloptHash{'TELOPT_USERVAR'})) {

                # (No user-defined environment variables to send)
                @knownList = ();

            } elsif (! ($data =~ m/^[\x00\x03]/)) {

                if (exists $envHash{$data}) {
                    push (@knownList, $data);
                } else {
                    push (@customList, $data);
                }

            } else {

                # Extract 'VAR <name>' and 'USERVAR <name>' components, one by one
                do {

                    my ($char, $index, $name);

                    # Get the inintial VAR or USERVAR
                    $char = substr($data, 0, 1);
                    $data = substr($data, 1);

                    if (
                        ord($char) ne $teloptHash{'TELOPT_VAR'}
                        && ord($char) ne $teloptHash{'TELOPT_USERVAR'}
                    ) {
                        # The remaining portion of $data doesn't start with VAR or USERVAR, so
                        #   discard it
                        $data = '';

                    } else {

                        # Extract the <name>
                        $index = index($data, chr($teloptHash{'TELOPT_VAR'}));
                        if ($index == -1) {

                            $index = index($data, chr($teloptHash{'TELOPT_USERVAR'}));
                        }

                        if ($index == -1) {

                            $name = $data;
                            $data = '';

                        } else {

                            $name = substr($data, 0, $index);
                            $data = substr($data, $index);
                        }

                        # Mark it to be sent
                        if (exists $envHash{$name}) {
                            push (@knownList, $name);
                        } else {
                            push (@customList, $name);
                        }
                    }

                } until ($data eq '');
            }

            # Any requested well-known environment variables are in @knownList, and any user-defined
            #   environment variables are in @customList
            # Combine them into a list in groups of 3, in the form
            #   (type, name, value)
            # ...where 'type' is TELOPT_VAR or TELOPT_USERVAR, 'name' is an environment variable
            #   like 'SYSTEMTYPE', and 'value' is the variable's value (or 'undef', if Axmud doesn't
            #   use the variable)
            foreach my $item (@knownList) {

                push (@argList, $teloptHash{'TELOPT_VAR'}, $item, $envHash{$item});
            }

            foreach my $item (@customList) {

                push (@argList, $teloptHash{'TELOPT_USERVAR'}, $item, undef);
            }

            # Send the list to the world
            $self->optSendNewEnviron(@argList);
        }

        return 1;
    }

    sub processCharSetData {

        # Called by $self->subOptCallback
        # CHARSET is partially implemented; translation tables are ignored (as they are probably
        #   obsolete, and no known world uses them)
        # This function receives CHARSET REQUEST commands, and sends an ACCEPTED or REJECTED
        #   command in response
        # Other CHARSET commands, such as TTABLE-IS and TTABLE-REJECTED, are ignored
        #
        # Expected arguments
        #   $parameters     - A CHARSET data string to process
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $cmd, $data, $sep,
            @setList,
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processCharSetData', @_);
        }

        # $parameters is in the form <cmd_char><data>
        $cmd = substr($parameters, 0, 1);
        $data = substr($parameters, 1);

        # All CHARSET commands except REQUEST are ignored
        if (ord($cmd) == 1) {

            # Translation tables are not implemented (by Axmud or by any known MU*)
            # Remove the optional { "[TTABLE]" <Version> } component, if present (the <Version> is
            #   a single character whose value we can ignore)
            $data =~ s/^\[TTABLE\].//;

            # The first character identifies the separator in the list of character sets, i.e.,
            #   $data is in the form <sep> <char_set> { ... <sep> <char_set>
            $sep = substr($data, 0, 1);
            @setList = split($sep, $data);

            # Check character sets against those known to be available on the system
            # RFC2066 specifies that <char_set> is case-insensitive, so use lc() throughout
            foreach my $set (@setList) {

                # Deal with 'UTF-8', which appears in GA::CLIENT->charSetList as 'utf8'
                if (lc($set) eq 'utf-8' && $axmud::CLIENT->ivFind('charSetList', 'utf8')) {

                    # Send IAC SB TELOPT_CHARSET ACCEPTED <Charset> IAC SE
                    if ($self->optSendCharSet($set)) {

                        # Use this character set from now on
                        $self->ivPoke('sessionCharSet', 'utf8');

                        return 1;
                    }
                }

                # Otherwise, check literal values of $set
                foreach my $otherSet ($axmud::CLIENT->charSetList) {

                    if (lc($set) eq lc($otherSet)) {

                        # Send IAC SB TELOPT_CHARSET ACCEPTED <Charset> IAC SE
                        if ($self->optSendCharSet($set)) {

                            # Use this character set from now on
                            $self->ivPoke('sessionCharSet', $otherSet);

                            return 1;
                        }
                    }
                }
            }

            # None of the character sets suggested by the world are available
            # Send IAC SB TELOPT_CHARSET REJECTED IAC SE
            $self->optSendCharSet();
        }

        return 1;
    }

    sub extractMsdpData {

        # Called by $self->subOptCallback to extract an MSDP data string
        #
        # Expected arguments
        #   $parameters     - A MDSP data string to process
        #
        # Return values
        #   An empty hash on improper arguments or if a sanity check fails
        #   Otherwise returns a hash in the form
        #       $extractHash{var} = value
        #   ...where 'value' can be a simple scalar, or a list/hash reference (possibly containing
        #       nested lists/hashes)

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (%emptyHash, %telConstHash, %extractHash);

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMsdpData', @_);
            return %emptyHash;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # $parameters can contain one or more items. Each item can be in one of three general forms:
        #   Variables and values
        #       MSDP_VAR <var> MSDP_VAL <value>
        #   Arrays
        #       MSDP_VAR <var> MSDP_VAL MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #   Tables
        #       MSDP_VAR <var> MSDP_VAL MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        #
        # Extract each item in turn until $parameters is empty
        do {

            my (
                $var, $val, $posn,
                @list,
            );

            # Call $self->extractMsdpVal, ->extractMsdpArray and ->extractMsdpTable recursively, so
            #   that $var is <var>, and $val is a scalar representing <value>, a list reference
            #   representing MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE or a hash reference representing
            #   MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
            ($var, $val, $parameters) = $self->extractMsdpVal($parameters);
            if (! defined $var) {

                # Sanity check has failed
                $self->writeWarning(
                    'There appears to be a problem with the MSDP data the current world has sent. '
                    . ' Please report this issue (code ' . $self->msdpSanityNum
                    . ', ' . $axmud::SCRIPT . ' v' . $axmud::VERSION . ') to the authors.',
                );

#                # Disable MSDP for this session
#                $self->optSendDont($telConstHash{'TELOPT_MSDP'});
#                $self->ivPoke('msdpMode', 'client_refuse');
#                $self->ivUndef('msdpSanityNum');
                # v1.2.103 The code above caused a disconnection issue at Evennia. That is now
                #   fixed, but the MSDP spec contains no mention of the client sending IAC DONT MSDP
                # Instead, we'll ignore any more MSDP data for this session
                $self->ivPoke('msdpMode', 'client_ignore');
                $self->ivUndef('msdpSanityNum');

                return %emptyHash;

            } else {

                # Some worlds (e.g. 4Dimensions) are sending a single item in the form
                #       MSDP_VAR <var> MSDP_VAL <value> MSDP_VAL <value> ...
                # The MSDP specs only hint at this behaviour. We will treat is as being the
                #   equivalent of
                #       MSDP_VAR <var> MSDP_VAL MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
                $posn = index($val, chr($telConstHash{'MSDP_VAL'}));
                if ($posn != -1) {

                    @list = split(chr($telConstHash{'MSDP_VAL'}), $val);
                    $val = \@list;
                }

                # Compile a results hash in the form
                #   $hash{var} = value
                $extractHash{$var} = $val;
            }

        } until (! $parameters);

        return %extractHash;

        return 1;
    }

    sub extractMsdpVal {

        # Called initially by $self->processMsdpData, and then recursively by this function,
        #   $self->extractMsdpArray and $self->extractMsdpTable
        #
        # Extracts a variable and value in the form
        #       MSDP_VAR <var> MSDP_VAL <value>
        # Each <value> can be a simple scalar, or a nested array/table in the form
        #       MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #       MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        #
        # Expected arguments
        #   $parameters     - The remaining portion of the original MDSP data string to process
        #
        # Return values
        #   An empty list on improper arguments or if a sanity check fails
        #   Otherwise, returns a list in the form
        #       (<var>, <value>, <remaining>)
        #   ...where <value> can be a scalar, or a list or hash reference; and where <remaining>
        #       is the remaining portion of $parameters (if any; should be empty when this function
        #       is called by $self->processMsdpData)

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $type, $posn, $var, $val,
            @emptyList,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMsdpVal', @_);
            return @emptyList;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Extract MSDP_VAR
        $type = ord(substr($parameters, 0, 1));
        # (Sanity check)
        if ($type != $telConstHash{'MSDP_VAR'}) {

            # Sanity check failed
            $self->ivPoke('msdpSanityNum', 101);      # Store the approximate line number
            return @emptyList;

        } else {

            $parameters = substr($parameters, 1);
            if (! $parameters) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 102);
                return @emptyList;
            }
        }

        # Extract
        #   <var> MSDP_VAL <value>
        $posn = index($parameters, chr($telConstHash{'MSDP_VAL'}));
        if ($posn == -1) {

            # Sanity check failed
            $self->ivPoke('msdpSanityNum', 111);
            return @emptyList;

        } else {

            $var = substr($parameters, 0, $posn);
            $parameters = substr($parameters, ($posn + 1));
            if (! $var) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 112);
                return @emptyList;
            }
        }

        # Is $val actually a <value>, or is it MSDP_ARRAY_OPEN marking the start of a nested array,
        #   or MSDP_TABLE_OPEN marking the start of a nested table?
        $type = ord(substr($parameters, 0, 1));
        if ($type == $telConstHash{'MSDP_ARRAY_OPEN'}) {

            # $val is set to a list reference
            ($val, $parameters) = $self->extractMsdpArray($parameters);
            if (! defined $val) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 121);
                return @emptyList;
            }

        } elsif ($type == $telConstHash{'MSDP_TABLE_OPEN'}) {

            # $val is set to a hash reference
            ($val, $parameters) = $self->extractMsdpTable($parameters);
            if (! defined $val) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 122);
                return @emptyList;
            }

        } else {

            # Find the position of the next MSDP_VAR. Everything to the left of that character is
            #   <value>
            $posn = index($parameters, chr($telConstHash{'MSDP_VAR'}));
            if ($posn == -1) {

                $val = $parameters;
                $parameters = '';

            } else {

                $val = substr($parameters, 0, $posn);
                $parameters = substr($parameters, $posn);   # Retain the next MSDP_VAL, etc
            }
        }

        return ($var, $val, $parameters);
    }

    sub extractMsdpArray {

        # Called by $self->extractMsdpVal, by this function or by $self->extractMsdpTable
        #   (recursively)
        #
        # Extracts a nested array in the form
        #       MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        # Calls other functions recursively to extract the contents of the array, which is in the
        #   general form
        #       MSDP_VAL <value> MSDP_VAL <value> ...
        # Each <value> can be a simple scalar, or a nested array/table in the form
        #       MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #       MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        #
        # Expected arguments
        #   $parameters     - The remaining portion of the original MDSP data string to process
        #
        # Return values
        #   An empty list on improper arguments or if a sanity check fails
        #   Otherwise, returns a list in the form
        #       (<list_ref>, <remaining>)
        #   ...where <list_ref> contains the contents of the array in the form
        #       (<value> <value> <value>...)
        #   ...and where <remaining> is the remaining portion of $parameters (if any)

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $type, $nextType, $exitFlag,
            @emptyList, @varList,
            %telConstHash,
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMsdpArray', @_);
            return @emptyList;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Extract the initial MSDP_ARRAY_OPEN
        $type = ord(substr($parameters, 0, 1));
        # (Sanity check)
        if ($type != $telConstHash{'MSDP_ARRAY_OPEN'}) {

            # Sanity check failed
            $self->ivPoke('msdpSanityNum', 211);
            return @emptyList;

        } else {

            $parameters = substr($parameters, 1);
            if (! $parameters) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 212);
                return @emptyList;
            }
        }

        # Extract
        #   MSDP_VAL <value> MSDP_VAL <value> ... MSDP_ARRAY_CLOSE
        # Each <value> can also be in the form
        #   MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #   MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        do {

            my ($val, $posn, $setPosn);

            $type = ord(substr($parameters, 0, 1));

            if ($type == $telConstHash{'MSDP_ARRAY_CLOSE'}) {

                $exitFlag = TRUE;

            } else {

                # Extract MSDP_VAL
                if ($type != $telConstHash{'MSDP_VAL'}) {

                    # Sanity check failed
                    $self->ivPoke('msdpSanityNum', 221);
                    return @emptyList;

                } else {

                    $parameters = substr($parameters, 1);
                    if (! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 222);
                        return @emptyList;
                    }
                }

                $type = ord(substr($parameters, 0, 1));
                if ($type == $telConstHash{'MSDP_ARRAY_OPEN'}) {

                    # $val is set to a list reference
                    ($val, $parameters) = $self->extractMsdpArray($parameters);
                    # Sanity check failed if $val is undefined or if $parameters is now empty,
                    #   because it should still contain at least MSDP_ARRAY_CLOSE
                    if (! defined $val || ! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 231);
                        return @emptyList;
                    }

                } elsif ($type == $telConstHash{'MSDP_TABLE_OPEN'}) {

                    # $val is set to a hash reference
                    ($val, $parameters) = $self->extractMsdpTable($parameters);
                    # Sanity check failed if $val is undefined or if $parameters is now empty,
                    #   because it should still contain at least MSDP_ARRAY_CLOSE
                    if (! defined $val || ! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 232);
                        return @emptyList;
                    }

                } else {

                    # Find the position of the first MSDP_VAL, MSDP_ARRAY_OPEN, MSDP_TABLE_OPEN or
                    #   MSDP_TABLE_CLOSE. Everything to the left of that character is <value>
                    $posn = index($parameters, chr($telConstHash{'MSDP_VAL'}));
                    if ($posn != -1) {

                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_ARRAY_OPEN'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_ARRAY_CLOSE'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_TABLE_OPEN'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_TABLE_CLOSE'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    if (
                        ! defined $setPosn
                        # MSDP_ARRAY_OPEN can't be followed by MSDP_TABLE_CLOSE
                        || ord(substr($parameters, $setPosn, 1))
                                == $telConstHash{'MSDP_TABLE_CLOSE'}
                    ) {
                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 233);
                        return @emptyList;
                    }

                    $nextType = substr($parameters, $setPosn, 1);
                    $val = substr($parameters, 0, $setPosn);
                    $parameters = substr($parameters, $setPosn);   # Retain the next MSDP_VAL, etc
                }

                push (@varList, $val);
            }

        } until (
            $exitFlag
            || (defined $nextType && $nextType eq chr($telConstHash{'MSDP_TABLE_CLOSE'}))
        );

        # Remove the MSDP_ARRAY_CLOSE now at the beginning of $parameters (the calling function
        #   doesn't need it)
        $parameters = substr($parameters, 1);

        return (\@varList, $parameters);
    }

    sub extractMsdpTable {

        # Called by $self->extractMsdpVal, by $self->extractMsdpArray or by this function
        #   (recursively)
        #
        # Extracts a nested table in the form
        #       MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        # Calls other functions recursively to extract the contents of the table, which is in the
        #   general form
        #       MSDP_VAR <var> MSDP_VAL <value> MSDP_VAR <var> MSDP_VAL <value> ...
        # Each <value> can be a simple scalar, or a nested array/table in the form
        #       MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #       MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        #
        # Expected arguments
        #   $parameters     - The remaining portion of the original MDSP data string to process
        #
        # Return values
        #   An empty list on improper arguments or if a sanity check fails
        #   Otherwise, returns a list in the form
        #       (<hash_ref>, <remaining>)
        #   ...where <hash_ref> contains the contents of the table in the form
        #       $hash{var} = $value
        #   ...and where <remaining> is the remaining portion of $parameters (if any)

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $type, $nextType, $exitFlag,
            @emptyList,
            %telConstHash, %varHash
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMsdpTable', @_);
            return @emptyList;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;

        # Extract the initial MSDP_TABLE_OPEN
        $type = ord(substr($parameters, 0, 1));
        # (Sanity check)
        if ($type != $telConstHash{'MSDP_TABLE_OPEN'}) {

            # Sanity check failed
            $self->ivPoke('msdpSanityNum', 301);
            return @emptyList;

        } else {

            $parameters = substr($parameters, 1);
            if (! $parameters) {

                # Sanity check failed
                $self->ivPoke('msdpSanityNum', 302);
                return @emptyList;
            }
        }

        # Extract
        #   MSDP_VAR <var> MSDP_VAL <value> MSDP_VAR <var> MSDP_VAL <value> ... MSDP_ARRAY_CLOSE
        # Each <value> can also be in the form
        #   MSDP_ARRAY_OPEN ... MSDP_ARRAY_CLOSE
        #   MSDP_TABLE_OPEN ... MSDP_TABLE_CLOSE
        do {

            my ($var, $val, $posn, $setPosn);

            $type = ord(substr($parameters, 0, 1));
            if ($type == $telConstHash{'MSDP_TABLE_CLOSE'}) {

                $exitFlag = TRUE;

            } else {

                # Extract MSDP_VAR
                if ($type != $telConstHash{'MSDP_VAR'}) {

                    # Sanity check failed
                    $self->ivPoke('msdpSanityNum', 311);
                    return @emptyList;

                } else {

                    $parameters = substr($parameters, 1);
                    if (! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 312);
                        return @emptyList;
                    }
                }

                # Extract <var> MSDP_VAL
                $posn = index($parameters, chr($telConstHash{'MSDP_VAL'}));
                if ($posn == -1) {

                    # Sanity check failed
                    $self->ivPoke('msdpSanityNum', 321);
                    return @emptyList;

                } else {

                    $var = substr($parameters, 0, $posn);
                    $parameters = substr($parameters, ($posn + 1));
                    if (! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 322);
                        return @emptyList;
                    }
                }

                # Extract <value>
                $type = ord(substr($parameters, 0, 1));
                if ($type == $telConstHash{'MSDP_ARRAY_OPEN'}) {

                    # $val is set to a list reference
                    ($val, $parameters) = $self->extractMsdpArray($parameters);
                    # Sanity check failed if $val is undefined or if $parameters is now empty,
                    #   because it should still contain at least MSDP_ARRAY_CLOSE
                    if (! defined $val || ! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 331);
                        return @emptyList;
                    }

                } elsif ($type == $telConstHash{'MSDP_TABLE_OPEN'}) {

                    # $val is set to a hash reference
                    ($val, $parameters) = $self->extractMsdpTable($parameters);
                    # Sanity check failed if $val is undefined or if $parameters is now empty,
                    #   because it should still contain at least MSDP_TABLE_CLOSE
                    if (! defined $val || ! $parameters) {

                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 332);
                        return @emptyList;
                    }

                } else {

                    # Find the position of the first MSDP_VAR, MSDP_ARRAY_OPEN, MSDP_TABLE_OPEN or
                    #   MSDP_TABLE_CLOSE. Everything to the left of that character is <value>
                    $posn = index($parameters, chr($telConstHash{'MSDP_VAR'}));
                    if ($posn != -1) {

                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_ARRAY_OPEN'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_ARRAY_CLOSE'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_TABLE_OPEN'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    $posn = index($parameters, chr($telConstHash{'MSDP_TABLE_CLOSE'}));
                    if (
                        $posn != -1
                        && (! defined $setPosn || $setPosn > $posn)
                    ) {
                        $setPosn = $posn;
                    }

                    if (
                        ! defined $setPosn
                        # MSDP_TABLE_OPEN can't be followed by MSDP_ARRAY_CLOSE
                        || ord(substr($parameters, $setPosn, 1))
                                == $telConstHash{'MSDP_ARRAY_CLOSE'}
                    ) {
                        # Sanity check failed
                        $self->ivPoke('msdpSanityNum', 333);
                        return @emptyList;
                    }

                    $nextType = substr($parameters, $setPosn, 1);
                    $val = substr($parameters, 0, $setPosn);
                    $parameters = substr($parameters, $setPosn);   # Retain the next MSDP_VAR, etc
                }

                $varHash{$var} = $val;
            }

        } until (
            $exitFlag
            || (defined $nextType && $nextType eq chr($telConstHash{'MSDP_TABLE_CLOSE'}))
        );

        # Remove the MSDP_TABLE_CLOSE now at the beginning of $parameters (the calling function
        #   doesn't need it)
        $parameters = substr($parameters, 1);

        return (\%varHash, $parameters);
    }

    sub processMsdpData {

        # Called by $self->subOptCallback straight after a call to ->extractMsdpData
        # Processes the data extracted from the MSDP data string
        #
        # Expected arguments
        #   %msdpHash   - A hash in the form
        #                   $msdpHash{var} = value
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, %msdpHash) = @_;

        # Check for improper arguments
        if (! %msdpHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMsdpData', @_);
        }

        # Process each variable in turn
        foreach my $var (keys %msdpHash) {

            my (
                $val, $valRef, $ucVar, $genericFlag,
                @list, @modList, @reportList, @sendList,
            );

            $val = $msdpHash{$var};
            $valRef = ref $val;
            $ucVar = uc($var);      # ($var should already be upper-case)

            # Handle the server's response to a LIST command sent by the client
            if (
                $self->ivExists('msdpGenericListHash', $ucVar)      # generic supported list
                || $self->ivExists('msdpCustomListHash', $ucVar)    # custom supported list
            ) {
                # $val can be a simple scalar, or a list reference. Convert both into a simple list
                if ($valRef eq 'ARRAY') {
                    @list = @$val;
                } elsif ($valRef ne 'HASH') {
                    @list = ($val);
                }

                if ($ucVar eq 'COMMANDS') {

                    # Store commands supported by the server
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        # (Some worlds send lists containing an empty variable, for some reason, so
                        #   ignore any empty variables we are sent)
                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericCmdHash', $ucItem)) {

                                # Generic MSDP command
                                $self->ivAdd('msdpGenericCmdHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP command
                                $self->ivAdd('msdpCustomCmdHash', $ucItem, TRUE);
                            }
                        }
                    }

                } elsif ($ucVar eq 'LISTS') {

                    # Store lists supported by the server
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericListHash', $ucItem)) {

                                # Generic MSDP list
                                $self->ivAdd('msdpGenericListHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP list
                                $self->ivAdd('msdpCustomListHash', $ucItem, TRUE);
                            }
                        }
                    }

                } elsif ($ucVar eq 'CONFIGURABLE_VARIABLES') {

                    # Store variables that Axmud can configure
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericConfigFlagHash', $ucItem)) {

                                # Generic MSDP configurable variable
                                $self->ivAdd('msdpGenericConfigFlagHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP configurable variable
                                $self->ivAdd('msdpCustomConfigFlagHash', $ucItem, TRUE);
                            }
                        }
                    }

                    # For the generic configurable variables 'CLIENT_NAME' and 'CLIENT_VERSION', we
                    #   can send values to the server now (Axmud doesn't use PLUGIN_ID)
                    if ($self->ivShow('msdpGenericConfigFlagHash', 'CLIENT_NAME')) {

                        $self->optSendMsdpVars('CLIENT_NAME', uc($axmud::NAME_SHORT));
                    }

                    if ($self->ivShow('msdpGenericConfigFlagHash', 'CLIENT_VERSION')) {

                        $self->optSendMsdpVars('CLIENT_VERSION', uc($axmud::VERSION));
                    }

                } elsif ($ucVar eq 'REPORTABLE_VARIABLES') {

                    # Store variables that the server can report
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericReportableFlagHash', $ucItem)) {

                                # Generic MSDP reportable variable
                                $self->ivAdd('msdpGenericReportableFlagHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP reportable variable
                                $self->ivAdd('msdpCustomReportableFlagHash', $ucItem, TRUE);
                            }
                        }
                    }

                    # If the character is not yet logged in, we ask the server to REPORT variables
                    #   once the login is complete; otherwise we do it right away
                    if ($self->loginFlag) {

                        # Ask the server to begin reporting any generic reportable variables that it
                        #   supports right away; we can ask it to begin reporting custom reportable
                        #   variables later on, if necessary
                        foreach my $var ($self->ivKeys('msdpGenericReportableFlagHash')) {

                            if ($self->ivShow('msdpGenericReportableFlagHash', $var)) {

                                push (@reportList, $var);
                            }
                        }

                        if (@reportList) {

                            $self->optSendMsdpReport(@reportList);
                        }
                    }

                } elsif ($ucVar eq 'REPORTED_VARIABLES') {

                    # Store variables that the server is currently reporting (NB the
                    #   hashes are also updated by calls to $self->optSendMsdpReport and
                    #   $self->optSendMsdpUnreport)
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericReportedFlagHash', $ucItem)) {

                                # Generic MSDP reported variable
                                $self->ivAdd('msdpGenericReportedFlagHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP reported variable
                                $self->ivAdd('msdpCustomReportedFlagHash', $ucItem, TRUE);
                            }
                        }
                    }

                    # (We don't do anything with the reported variable data just yet)

                } elsif ($ucVar eq 'SENDABLE_VARIABLES') {

                    # Store variables that the server can send
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem) {

                            if ($self->ivExists('msdpGenericSendableFlagHash', $ucItem)) {

                                # Generic MSDP sendable variable
                                $self->ivAdd('msdpGenericSendableFlagHash', $ucItem, TRUE);

                            } else {

                                # Custom MSDP sendable variable
                                $self->ivAdd('msdpCustomSendableFlagHash', $ucItem, TRUE);
                            }
                        }
                    }

                    # If the character is not yet logged in, we ask the server to SEND variables
                    #   once the login is complete; otherwise we do it right away
                    if ($self->loginFlag) {

                        # Ask the server to begin sending any generic sendable variables that it
                        #   supports right away; we can ask it to begin sending custom sendable
                        #   variables later on, if necessary
                        foreach my $var ($self->ivKeys('msdpGenericSendableFlagHash')) {

                            if ($self->ivShow('msdpGenericSendableFlagHash', $var)) {

                                push (@sendList, $var);
                            }
                        }

                        if (@sendList) {

                            $self->optSendMsdpSend(@sendList);
                        }
                    }
                }

                # During the initial MSDP negotiations, after a list is sent, another is requested
                if ($ucVar eq 'COMMANDS' && $self->msdpInitMode eq 'sent_commands') {

                    # Send the next LIST command in the initial sequence
                    $self->optSendMsdpList('LISTS');
                    $self->ivPoke('msdpInitMode', 'sent_lists');

                } elsif ($ucVar eq 'LISTS' && $self->msdpInitMode eq 'sent_lists') {

                    # Now that we know the server's supported lists, ask for them in each in turn.
                    #   First remove from @list the two supported lists we already have
                    foreach my $item (@list) {

                        my $ucItem = uc($item);

                        if ($ucItem && $ucItem ne 'COMMANDS' && $ucItem ne 'LISTS') {

                            push (@modList, $ucItem);
                        }
                    }

                    if (@modList) {

                        # Send a request for the first supported list in @modList...
                        $self->optSendMsdpList(shift @modList);
                        # ...make a note of which supported lists to request, once a response has
                        #   been received
                        $self->ivPoke('msdpRequestList', @modList);     # @modList might be empty
                        $self->ivPoke('msdpInitMode', 'sent_other');
                    }

                } elsif ($self->msdpInitMode eq 'sent_other') {

                    if ($self->msdpRequestList) {

                        # Send a request for the next supported list
                        $self->optSendMsdpList($self->ivShift('msdpRequestList'));

                    } else {

                        # All supported lists have been requested
                        $self->ivPoke('msdpInitMode', 'ready');
                    }
                }

            # Handle server MSDP messages which aren't a response to a LIST command sent by the
            #   client (and which are normally the result of an earlier REPORT or SEND command)
            } else {

                # Store a generic variable's value
                if ($self->ivExists('msdpGenericReportableFlagHash', $ucVar)) {

                    # The entry in $self->msdpGenericReportableFlagHash should already be set, but
                    #   in case the server is sending unsolicited variable values, we'll set it
                    #   anyway
                    $self->ivAdd('msdpGenericReportableFlagHash', $ucVar, TRUE);
                    # ($val can be a scalar, or a list/hash reference)
                    $self->ivAdd('msdpGenericValueHash', $ucVar, $val);
                    $genericFlag = TRUE;

                } else {

                    $self->ivAdd('msdpCustomReportableFlagHash', $ucVar, TRUE);
                    $self->ivAdd('msdpCustomValueHash', $ucVar, $val);
                    $genericFlag = FALSE;
                }

                # Inform the Locator and Status tasks (save a bit of time by only sending the
                #   ROOM variable, but send everything to the Status task)
                if ($ucVar eq 'ROOM' && $self->locatorTask) {

                    $self->locatorTask->set_updateFromMsdp($ucVar, $val, $genericFlag);
                }

                if ($self->statusTask) {

                    $self->statusTask->set_updateFromMsdp($ucVar, $val, $genericFlag);
                }

                # Fire any hooks that are using the 'msdp' hook event
                $self->checkHooks('msdp', $ucVar, $val);
            }
        }

        return 1;
    }

    sub resetMsdpData {

        # Called by $self->new to initialise MSDP IVs
        # Also called by $self->optCallback when the server sends IAC WONT MSDP during a copyover,
        #   in which case all MSDP IVs must be reset to their initial state
        # Also called by $self->disableMudProtocol
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetMsdpData', @_);
        }

        $self->ivPoke('msdpMode', 'no_invite');
        $self->ivPoke(
            'msdpGenericCmdHash',
                # Assume that at least this one is supported...
                'LIST'                  => TRUE,
                # ...assume others not supported, until we know otherwise
                'REPORT'                => FALSE,
                'RESET'                 => FALSE,
                'SEND'                  => FALSE,
                'UNREPORT'              => FALSE,
        );
        $self->ivEmpty('msdpCustomCmdHash');
        $self->ivPoke(
            'msdpGenericListHash',
                # Assume that at least these two are supported...
                'COMMANDS'              => TRUE,
                'LISTS'                 => TRUE,
                # ...assume others not supported, until we know otherwise
                'CONFIGURABLE_VARIABLES'
                                        => FALSE,
                'REPORTABLE_VARIABLES'  => FALSE,
                'REPORTED_VARIABLES'    => FALSE,
                'SENDABLE_VARIABLES'    => FALSE,
        );
        $self->ivEmpty('msdpCustomListHash');
        $self->ivPoke('msdpInitMode', 'none_sent');
        $self->ivEmpty('msdpRequestList');
        $self->ivPoke(
            'msdpGenericConfigFlagHash',
                # Don't assume that any of them are supported
                'CLIENT_NAME'           => FALSE,
                'CLIENT_VERSION'        => FALSE,
                'PLUGIN_ID'             => FALSE,
        );
        $self->ivEmpty('msdpCustomConfigFlagHash');
        $self->ivEmpty('msdpGenericConfigValHash');
        $self->ivEmpty('msdpCustomConfigValHash');
        $self->ivPoke(
            'msdpGenericReportableFlagHash',
                # Don't assume that any of them are supported
                # General
                'ACCOUNT_NAME'          => FALSE,
                'CHARACTER_NAME'        => FALSE,
                'SERVER_ID'             => FALSE,
                'SERVER_TIME'           => FALSE,
                'SPECIFICATION'         => FALSE,
                # Character
                'AFFECTS'               => FALSE,
                'ALIGNMENT'             => FALSE,
                'EXPERIENCE'            => FALSE,
                'EXPERIENCE_MAX'        => FALSE,
                'EXPERIENCE_TNL'        => FALSE,
                'EXPERIENCE_TNL_MAX'    => FALSE,
                'HEALTH'                => FALSE,
                'HEALTH_MAX'            => FALSE,
                'LEVEL'                 => FALSE,
                'MANA'                  => FALSE,
                'MANA_MAX'              => FALSE,
                'MONEY'                 => FALSE,
                'MOVEMENT'              => FALSE,
                'MOVEMENT_MAX'          => FALSE,
                # Combat
                'OPPONENT_LEVEL'        => FALSE,
                'OPPONENT_HEALTH'       => FALSE,
                'OPPONENT_HEALTH_MAX'   => FALSE,
                'OPPONENT_NAME'         => FALSE,
                'OPPONENT_STRENGTH'     => FALSE,
                # Mapping
                'ROOM'                  => FALSE,  # Variables nested within the corresponding value
                # World
                'WORLD_TIME'            => FALSE,
        );
        $self->ivEmpty('msdpCustomReportableFlagHash');
        $self->ivPoke(
            'msdpGenericReportedFlagHash',
                $self->msdpGenericReportableFlagHash,
        );
        $self->ivEmpty('msdpCustomReportedFlagHash');
        $self->ivPoke(
            'msdpGenericSendableFlagHash',
                $self->msdpGenericSendableFlagHash,
        );
        $self->ivEmpty('msdpCustomSendableFlagHash');

        return 1;
    }

    sub extractMsspData {

        # Called by $self->subOptCallback to convert an MSSP data string in the form...
        #   MSSP_VAR <var> MSSP_VAL <value> MSSP_VAR <var> MSSP_VAL <value>...
        # ...into a hash in the form...
        #   $msspHash{var} = {last_matching_value}
        #
        # NB The MSSP protocol specifies that if a variable is followed by two or more values, the
        #   last one is considered the 'default' value. This function ignores all but the 'default'
        #   value for each variable.
        #
        # Expected arguments
        #   $parameters     - A MSSP data string to convert
        #
        # Return values
        #   An empty hash on improper arguments
        #   Otherwise returns the hash described above

        my ($self, $parameters, $check) = @_;

        # Local variables
        my (
            $currentVar, $warningMsg,
            %emptyHash, %telConstHash, %returnHash,
        );

        # Check for improper arguments
        if (! defined $parameters || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->extractMsspData', @_);
            return %emptyHash;
        }

        # Import the hash of telnet constants (for convenience)
        %telConstHash = $axmud::CLIENT->constTelnetHash;
        # Standard warning message if sanity check fails
        $warningMsg = 'There appears to be a problem with the MSSP data the current world has sent.'
                        . ' Please report this issue to the authors.',

        do {

            my ($type, $posnVar, $posnVal, $thisParam);

            # Sanity check
            if (
                ! defined $currentVar
                && ord(substr($parameters, 0, 1)) == $telConstHash{'MSSP_VAL'}
            ) {
                $self->writeWarning(
                    $warningMsg,
                    $self->_objClass . '->extractMsspData',
                );

                # Disable MSSP for this session
                $self->optSendDont($telConstHash{'TELOPT_MSSP'});
                $self->ivPoke('msspMode', 'client_refuse');

                return %emptyHash;
            }

            # Remove the MSSP_VAR or the MSSP_VAL from the start of $parameters
            $type = ord(substr($parameters, 0, 1));
            $parameters = substr($parameters, 1);

            # (Further sanity check)
            if ($type != $telConstHash{'MSSP_VAR'} && $type != $telConstHash{'MSSP_VAL'}) {

                $self->writeWarning(
                    $warningMsg,
                    $self->_objClass . '->extractMsspData',
                );
            }

            # Remove the <var> or <value> from the start of $parameters
            $posnVar = index($parameters, chr($telConstHash{'MSSP_VAR'}));
            $posnVal = index($parameters, chr($telConstHash{'MSSP_VAL'}));
            if ($posnVar == -1 && $posnVal == -1) {

                $thisParam = $parameters;
                $parameters = '';

            } elsif ($posnVar == -1) {

                $thisParam = substr($parameters, 0, $posnVal);
                $parameters = substr($parameters, $posnVal);

            } elsif ($posnVal == -1) {

                $thisParam = substr($parameters, 0, $posnVar);
                $parameters = substr($parameters, $posnVar);

            } elsif ($posnVar < $posnVal) {

                $thisParam = substr($parameters, 0, $posnVar);
                $parameters = substr($parameters, $posnVar);

            } else {

                $thisParam = substr($parameters, 0, $posnVal);
                $parameters = substr($parameters, $posnVal);
            }

            # MSSP_VAR <var>
            if ($type == $telConstHash{'MSSP_VAR'}) {

                # The next MSSP_VAL <value> will be assigned to this variable
                $currentVar = $thisParam;
                # If no <value> is specified, it's an empty string
                $returnHash{$currentVar} = '';

            } else {

                $returnHash{$currentVar} = $thisParam;
            }

        } until (! $parameters);

        return %returnHash;
    }

    sub processMsspData {

        # Called by $self->subOptCallback straight after a call to ->extractMsspData
        # Processes the data extracted from the MSSP data string
        #
        # Expected arguments
        #   %msspHash   - A hash in the form
        #                   $msspHash{var} = value
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, %msspHash) = @_;

        # Check for improper arguments
        if (! %msspHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMsspData', @_);
        }

        foreach my $var (keys %msspHash) {

            my $val = $msspHash{$var};

            if ($axmud::CLIENT->ivExists('constMsspVarHash', $var)) {

                # Official variables
                $self->currentWorld->ivAdd('msspGenericValueHash', $var, $val);

            } else {

                # Unofficial variables
                $self->currentWorld->ivAdd('msspCustomValueHash', $var, $val);
            }

            # Fire any hooks that are using the 'mssp' hook event
            $self->checkHooks('mssp', $var, $val);
        }

        return 1;
    }

    sub processZmpData {

        # Called by $self->subOptCallback
        # Processes the ZMP package
        #
        # Expected arguments
        #   $string     - The package/command name, in the form 'Package[.SubPackages][.Command]',
        #                   already converted to lower-case
        #
        # Optional arguments
        #   @paramList  - An optional list of data parameters. Each parameter is usually an ASCII or
        #                   UTF-8 string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $string, @paramList) = @_;

        # Local variables
        my (
            $packageName, $packageCmd, $packageObj, $funcRef,
            @list,
        );

        # Check for improper arguments
        if (! defined $string) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processZmpData', @_);
        }

        if ($axmud::CLIENT->debugZmpFlag) {

            $self->writeDebug('ZMP: ' . $string);
            if (! @paramList) {

                $self->writeDebug('   <no data>');

            } else {

                foreach my $param (@paramList) {

                    $self->writeDebug('   ' . $param);
                }
            }
        }

        # Respond to core commands
        if ($string eq 'zmp.ping') {

            # Should have no parameters, but if any parameters were specified, just ignore them
            # Send the core command 'zmp.time' in response
            $self->zmpSendTime();

        } elsif ($string eq 'zmp.time') {

            # Store the time received (no response required). If the parameter list is empty, keep
            #   the current value
            if (defined $paramList[0]) {

                $self->ivPoke('zmpTimeStamp', $paramList[0]);
            }

        } elsif ($string eq 'zmp.ident') {

            # Store the server's name, version and software (no response required). If the parameter
            #   list is incomplete (or empty), only store the values received, replacing any other
            #   values (because 'zmp.ident' is only supposed to be sent once)
            $self->ivPoke('zmpServerName', $paramList[0]);
            $self->ivPoke('zmpServerVersion', $paramList[1]);
            $self->ivPoke('zmpServerDescrip', $paramList[2]);

        } elsif ($string eq 'zmp.check') {

            # $paramList[0] is the package name/package command that Axmud either supports, or not

            # Check whether the package or command is supported, i.e. whether a package object
            #   (GA::Obj::Zmp) exists for it. If the parameter is missing, do nothing
            if (defined $paramList[0]) {

                if ($paramList[0] =~ m/\.$/) {

                    # Check whether a package is available
                    # $paramList[0] is in the form Package[.SubPackages].
                    # Remove the final dot
                    $packageName = lc(substr($paramList[0], 0, -1));

                } else {

                    # Check whether a package containing the specified command is available
                    # $paramList[0] is in the form Package[.SubPackages].Command
                    # Separate the package name and the command
                    if ($paramList[0] =~ m/^(.*)\.[^\.]+$/) {

                        $packageName = $1;
                        $packageCmd = $2;
                    }
                }

                # Find the matching package object
                if (
                    $axmud::CLIENT->ivExists(
                        'zmpPackageHash',
                        $packageName . '@' . $self->currentWorld->name,
                    )
                ) {
                    # Package available only when connected to this world
                    # Package name in the form 'PackageName@WorldName'
                    $packageObj = $axmud::CLIENT->ivShow(
                        'zmpPackageHash',
                        $packageName . '@' . $self->currentWorld->name,
                    );

                } else {

                    # Package available when connected to any world
                    # Package name in the form 'PackageName@'
                    $packageObj = $axmud::CLIENT->ivShow(
                        'zmpPackageHash',
                        $packageName . '@',
                    );
                }

                if ($packageObj) {

                    if (defined $packageCmd) {

                        if ($packageObj->ivExists('cmdHash', lc($packageCmd))) {
                            $self->optSendZmp('zmp.support', $paramList[0]);
                        } else {
                            $self->optSendZmp('zmp.no-support', $paramList[0]);
                        }

                    } else {

                        # If no commands are specified, Axmud regards the package as unsupported
                        if ($packageObj->cmdHash) {
                            $self->optSendZmp('zmp.support', $paramList[0]);
                        } else {
                            $self->optSendZmp('zmp.no-support', $paramList[0]);
                        }
                    }

                } else {

                    # No matching package object found
                    $self->optSendZmp('zmp.no-support', $paramList[0]);
                }

            } else {

                # ZMP spec says we must send something back, so do that even though the server's
                #   ZMP command is invalid
                $self->optSendZmp('zmp.no-support');
            }

        } else {

            # Not a ZMP core command

            # $string is in the form 'Package[.SubPackages].Command'
            # Extract the package name 'Package[.SubPackages]', the package command 'Command', and
            #   the matching package object (GA::Obj::Zmp)
            if ($string =~ m/^(.*)\.(.*?)$/) {

                $packageName = $1;
                $packageCmd = $2;

                # Find the matching package object
                if (
                    $axmud::CLIENT->ivExists(
                        'zmpPackageHash',
                        $packageName . '@' . $self->currentWorld->name,
                    )
                ) {
                    # Package available only when connected to this world
                    # Package name in the form 'PackageName@WorldName'
                    $packageObj = $axmud::CLIENT->ivShow(
                        'zmpPackageHash',
                        $packageName . '@' . $self->currentWorld->name,
                    );

                } else {

                    # Package available when connected to any world
                    # Package name in the form 'PackageName@'
                    $packageObj = $axmud::CLIENT->ivShow(
                        'zmpPackageHash',
                        $packageName . '@',
                    );
                }

                if (
                    $packageObj
                    && $packageObj->ivExists('cmdHash', $packageCmd)
                ) {
                    $funcRef = $packageObj->ivShow('cmdHash', $packageCmd);
                }
            }
        }

        if (defined $funcRef) {

            # Call the plugin's function, so it can respond to the package command
            &$funcRef($self, $packageCmd, @paramList);
        }

        # Fire any hooks that are using the 'zmp' hook event
        $self->checkHooks('zmp', $string);

        return 1;
    }

    sub zmpSendTime {

        # Called by $self->processZmpData or any other function
        # Sends the ZMP core command 'zmp.time'
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if ZMP isn't enabled in this session or if sending the
        #       command fails
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $second, $minute, $hour, $dayOfMonth, $month, $yearOffset, $dayOfWeek, $dayOfYear,
            $daylightSavings, $year, $string,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->zmpSendTime', @_);
        }

        if ($self->zmpMode ne 'client_agree') {

            return undef;
        }

        # Prepare a UTC timestamp in the format YYYY-MM-SS HH:MM:SS
        ($second, $minute, $hour, $dayOfMonth, $month, $yearOffset) = gmtime();

        $year = 1900 + $yearOffset;
        $string = sprintf(
            '%04d-%02d-%02d %02d:%02d:%02d',
            $year, $month, $dayOfMonth, $hour, $minute, $second,
        );

        # Send zmp.time to the server
        return $self->optSendZmp(
            'zmp.time',
            $string,
        );
    }

    sub zmpSendIdent {

        # Called by $self->optCallback (only; the command should not be sent a second time)
        # Sends the ZMP core command 'zmp.ident'
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if ZMP isn't enabled in this session or if sending the
        #       command fails
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($termTypeMode, $customClientName, $customClientVersion);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->zmpSendIdent', @_);
        }

        if ($self->zmpMode ne 'client_agree') {

            return undef;
        }

        # (Use the same rules about disclosing this client's identity, as are used in TTYPE
        #   negotiations)
        if ($self->currentWorld->ivExists('termOverrideHash', 'termTypeMode')) {
            $termTypeMode = $self->currentWorld->ivShow('termOverrideHash', 'termTypeMode');
        } else {
            $termTypeMode = $axmud::CLIENT->termTypeMode;
        }

        if ($self->currentWorld->ivExists('termOverrideHash', 'customClientName')) {

            $customClientName
                = $self->currentWorld->ivShow('termOverrideHash', 'customClientName');

        } else {

            $customClientName = $axmud::CLIENT->customClientName;
        }

        if ($self->currentWorld->ivExists('termOverrideHash', 'customClientVersion')) {

            $customClientVersion
                = $self->currentWorld->ivShow('termOverrideHash', 'customClientVersion');

        } else {

            $customClientVersion = $axmud::CLIENT->customClientVersion;
        }

        if ($termTypeMode eq 'send_client') {

            # Send zmp.ident to the server. If it's already been sent during this session,
            #   ->optSendZmp sends nothing and returns 'undef'
            return $self->optSendZmp(
                'zmp.ident',
                $axmud::SCRIPT,
            );

        } elsif ($termTypeMode eq 'send_client_version') {

            return $self->optSendZmp(
                'zmp.ident',
                $axmud::SCRIPT,
                $axmud::VERSION,
                $axmud::DESCRIP,
            );

        } elsif ($termTypeMode eq 'send_custom_client') {

            if ($customClientName) {

                if (! $customClientVersion) {

                    return $self->optSendZmp(
                        'zmp.ident',
                        $customClientName,
                    );

                } else {

                    return $self->optSendZmp(
                        'zmp.ident',
                        $customClientName,
                        $customClientVersion,
                        $customClientName,
                    );
                }
            }
        }
    }

    sub processAard102Data {

        # Called by $self->subOptCallback
        # Processes data from AARD102 telnet sequences
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $byte, $byte2   - Two bytes, already converted to numbers (e.g. 101, 1). If (for some
        #                       reason) either or both are 'undef', this function does nothing
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $byte, $byte2, $check) = @_;

        # Local variables
        my $status;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processAard102Data', @_);
        }

        # ($byte, $byte2) should be in the range (100, 1) to (100, 8)
        # Aardwolf also sends (100, 9), but I'm not sure what it signifies
        # ($byte, $byte2) can also be (101, 1), meaning 'the MUD has just ticked'

        # Set Session IVs accordingly
        if (defined $byte && defined $byte2) {

            if ($byte == 100) {

                if ($byte2 == 1) {
                    $status = 'at_login_screen';
                } elsif ($byte2 == 2) {
                    $status = 'at_motd';
                } elsif ($byte2 == 3) {
                    $status = 'player_active';
                } elsif ($byte2 == 4) {
                    $status = 'player_afk';
                } elsif ($byte2 == 5) {
                    $status = 'note_mode';
                } elsif ($byte2 == 6) {
                    $status = 'edit_mode';
                } elsif ($byte2 == 7) {
                    $status = 'page_prompt';
                } elsif ($byte2 == 8) {
                    $status = 'player_rest';
                } elsif ($byte2 == 9) {
                    $status = 'code_nine';
                } else {
                    $status = 'unknown_' . $byte2;     # e.g. 'unknown 9'
                }

                $self->ivPoke('aard102Status', $status);

                # Fire any hooks that are using the 'aard102' hook event
                $self->checkHooks('aard102', $status);

            } elsif ($byte == 101 && $byte2 == 1) {

                $self->ivPoke('aard102TickTime', $self->sessionTime);

                # Fire any hooks that are using the 'aard102' hook event
                $self->checkHooks('aard102', 'tick');
            }
        }

        return 1;
    }

    sub processAtcpData {

        # Called by $self->subOptCallback
        # Processes the ATCP package
        #
        # Expected arguments
        #   $name       - The package name, in the form Package[.SubPackages][.Message], already
        #                   converted to lower-case
        #   $data       - A scalar of undecoded JSON data, e.g. 'comm.repop { "zone": "town" }'
        #
        # Return values
        #   'undef' on improper arguments or if the ATCP package can't be processed
        #   1 otherwise

        my ($self, $name, $data, $check) = @_;

        # Local variables
        my $dataObj;

        # Check for improper arguments
        if (! defined $name || ! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processAtcpData', @_);
        }

        if ($axmud::CLIENT->debugAtcpFlag) {

            $self->writeDebug('ATCP: ' . $name);
            $self->writeDebug('   ' . $data);
        }

        $dataObj = $self->ivShow('atcpDataHash', $name);
        if (! $dataObj) {

            # Create a new GA::Obj::Atcp object
            $dataObj = Games::Axmud::Obj::Atcp->new($self, $name, $data);
            if (! $dataObj) {

                return undef;

            } else {

                $self->ivAdd('atcpDataHash', $name, $dataObj);
            }

        } else {

            # Update data stored in the existing GA::Obj::Atcp object
            if (! $dataObj->update($data)) {

                return undef;
            }
        }

        # Fire any hooks that are using the 'atcp' hook event
        $self->checkHooks('atcp', $dataObj->name);

        return 1;
    }

    sub processGmcpData {

        # Called by $self->subOptCallback
        # Processes the GMCP package
        #
        # Expected arguments
        #   $name       - The package name, in the form Package[.SubPackages][.Message], already
        #                   converted to lower-case
        #   $data       - A scalar of undecoded JSON data, e.g. 'comm.repop { "zone": "town" }'
        #
        # Return values
        #   'undef' on improper arguments or if the GMCP package can't be processed
        #   1 otherwise

        my ($self, $name, $data, $check) = @_;

        # Local variables
        my $dataObj;

        # Check for improper arguments
        if (! defined $name || ! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processGmcpData', @_);
        }

        if ($axmud::CLIENT->debugGmcpFlag) {

            $self->writeDebug('GMCP: ' . $name);
            $self->writeDebug('   ' . $data);
        }

        $dataObj = $self->ivShow('gmcpDataHash', $name);
        if (! $dataObj) {

            # Create a new GA::Obj::Gmcp object
            $dataObj = Games::Axmud::Obj::Gmcp->new($self, $name, $data);
            if (! $dataObj) {

                return undef;

            } else {

                $self->ivAdd('gmcpDataHash', $name, $dataObj);
            }

        } else {

            # Update data stored in the existing GA::Obj::Gmcp object
            if (! $dataObj->update($data)) {

                return undef;
            }
        }

        # Fire any hooks that are using the 'gmcp' hook event
        $self->checkHooks('gmcp', $dataObj->name);

        return 1;
    }

    # Textview object support functions

    sub textViewSizeUpdate {

        # Called by GA::Obj::TextView->updateVisibleSize whenever the size of the visible textview
        #   changes (including when the window size changes, when scrollbars are added or removed,
        #   and so on)
        # If the default textview has changed and NAWS is enabled, this function just needs to
        #   tell the world about the new size
        #
        # Expected arguments
        #   $textViewObj    - The GA::Obj::TextView whose size has changed
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $textViewObj, $check) = @_;

        # Check for improper arguments
        if (! defined $textViewObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->textViewSizeUpdate', @_);
        }

        if (
            $self->nawsMode eq 'client_agree'
            && $self->defaultTabObj
            && $self->defaultTabObj->textViewObj eq $textViewObj
        ) {
            $self->optSendNaws();
        }

        # Fire any hooks that are using the 'textview_resize' hook event
        $self->checkHooks('textview_resize', $textViewObj->number);

        return 1;
    }

    sub textViewCursorUpdate {

        # Called by $self->start to enable or disable visible cursors in the default tab's textview
        #   object
        # Also called by GA::Client->toggle_termSetting, when the value stored in
        #   GA::Client->useVisibleCursorFlag changes
        # Also called by GA::EditWin::Profile::World->saveChanges when the override settings are
        #   modified
        #
        # Responds to the changed visible cursor setting, updating the default textview
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $cursorFlag;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->textViewCursorUpdate', @_);
        }

        # Get the setting that applies - the override setting in the world profile, or the
        #   GA::Client setting
        if ($self->status eq 'offline') {

            # No visible cursors in offline mode - it looks bad
            $cursorFlag = FALSE;

        } elsif ($self->currentWorld->ivExists('termOverrideHash', 'useVisibleCursorFlag')) {

            $cursorFlag = $self->currentWorld->ivShow('termOverrideHash', 'useVisibleCursorFlag');

        } else {

            $cursorFlag = $axmud::CLIENT->useVisibleCursorFlag;
        }

        # Update the default textview. Any other parts of the code which use cursors in their
        #   textview objects will have to make other arrangements
        if ($self->defaultTabObj) {

            $self->defaultTabObj->textViewObj->set_cursorEnableFlag($cursorFlag);
        }

        return 1;
    }

    sub textViewKeysUpdate {

        # Called by GA::Client->toggle_termSetting, when the value stored in
        #   GA::Client->useDirectKeysFlag changes
        # Also called by GA::EditWin::Profile::World->saveChanges when the override settings are
        #   modified
        #
        # Responds to the changed direct keys, disabling or enabling special echo mode
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $directFlag,
            @list,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->textViewKeysUpdate', @_);
        }

        # Generate a list of this session's entry strip objects (the TRUE argument means 'only
        #   return 'internal' windows')
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if (
                $stripObj
                && $stripObj->winObj->visibleSession
                && $stripObj->winObj->visibleSession eq $self
            ) {
                push (@list, $stripObj);
            }
        }

        # Get the setting that applies - the override setting in the world profile, or the
        #   GA::Client setting
        if ($self->currentWorld->ivExists('termOverrideHash', 'useDirectKeysFlag')) {
            $directFlag = $self->currentWorld->ivShow('termOverrideHash', 'useDirectKeysFlag');
        } else {
            $directFlag = $axmud::CLIENT->useVisibleCursorFlag;
        }

        if (! $directFlag && $self->session->specialEchoMode eq 'enabled') {

            $self->ivPoke('specialEchoMode', 'disabled');
            foreach my $stripObj (@list) {

                $stripObj->set_specialEchoFlag(FALSE);
            }

        } elsif ($directFlag && $self->session->specialEchoMode eq 'disabled') {

            # Special echo mode can be reenabled, but only if it was disabled earlier in the
            #   session
            $self->ivPoke('specialEchoMode', 'enabled');
            foreach my $stripObj (@list) {

                $stripObj->set_specialEchoFlag(TRUE);
            }
        }

        return 1;
    }

    # Instructions

    sub doInstruct {

        # Executes an instruction (which could be a world command, a client command, a forced world
        #   command, an echo command, a Perl command, a script command, a multi command, a speedwalk
        #   command or a bypass command)
        #
        # Called by ->signal_connect in GA::Strip::Entry->setEntrySignals when the user types
        #   something in the 'main' window's command entry box and presses RETURN
        # Also called by $self->perlCmd to deal with the return value of a Perl programme that's
        #   been executed
        # Also called by $self->processLineSegment when an independent active trigger interface
        #   fires, which creates an instruction treated as if it had been typed by the user
        # Also called by $self->checkHooks when an independent active hook interface fires, which
        #   creates an instruction treated as if it it had been typed by the user
        # Also called by $self->checkTimers when an independent active timer interface fires, which
        #   creates an instruction treated as if it had been typed by the user
        #
        # Removes the sigil at the beginning of the instruction (if any), and responds. Client
        #   commands are executed, and world commands are sent to the world. Perl commands are
        #   executed as Perl programmes and echo commands are displayed in the session's
        #   default textview object
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $string         - The instruction to process. If 'undef', a warning is displayed (so
        #                       that the user sees a warning rather than a confusing improper args
        #                       error)
        #   $noPerlFlag     - Set to TRUE when called by $self->perlCmd or $self->checkTriggers, in
        #                       which case a $string beginning with '/' should be treated as a world
        #                       command, not another Perl programme (recursive calling of
        #                       ->doInstruct is not allowed)
        #   $preText        - Set when called by GA::Strip::Entry->setEntrySignals, and when the
        #                       user has specified some text that should be added to the beginning
        #                       of a world command; 'undef' otherwise. Ignored it $string is not a
        #                       world command, or if it is an empty string
        #   $postText       - Set when called by GA::Strip::Entry->setEntrySignals, and when the
        #                       user has specified some text that should be added to the beginning
        #                       of a world command; 'undef' otherwise. Ignored if $string is not a
        #                       world command, or if it is an empty string
        #
        # Return values
        #   An empty list on improper arguments, if removing the instruction sigil at the start of
        #       the string (';', ',,', '#', '/', '&', ':', '.' or '>') results in an empty string
        #       (but empty strings are otherwise allowed), or if the type of instruction is
        #       currently disabled, or if $string is not defined
        #   Otherwise returns a list in the form
        #       (result, instruct_type)
        #   ...where 'result' is result of the call to $self->clientCmd, $self->worldCmd,
        #       $self->perlCmd or $self->writeText, and 'instruct_type' is the string 'client',
        #       'world' (for both world commands and forced world commands), 'echo', 'perl',
        #       'script', 'multi', 'speed' or 'bypass'

        my ($self, $string, $noPerlFlag, $preText, $postText, $check) = @_;

        # Local variables
        my (
            $cmd, $existsFlag,
            @emptyList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInstruct', @_);
        }

        if (! defined $string) {

            # If the calling function sent an 'undef' command, due to some kind of error in the
            #   calling function, show a warning rather than an improper arguments message
            $self->writeWarning(
                'Session asked to process an undefined command (due to some error, somewhere)',
                $self->_objClass . '->doInstruct',
            );

            return @emptyList;
        }

        # Update buffer position IVs
        $self->ivUndef('instructBufferPosn');
        $axmud::CLIENT->set_instructBufferPosn();
        $self->ivUndef('cmdBufferPosn');
        $axmud::CLIENT->set_cmdBufferPosn();

        # Trim leading/trailing whitespace from the instruction
        $string = $axmud::CLIENT->trimWhitespace($string);

        # An instruction beginning with a double quote (") is an echo command
        if (index ($string, $axmud::CLIENT->constEchoSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'echo');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'echo'))
                || (! $existsFlag && ! $axmud::CLIENT->echoSigilFlag)
            ) {
                # Echo commands are currently disabled. Process the instruction is a world command
                return ($self->worldCmd($string), 'world');
            }

            # Remove the sigil and echo the remaining text (if any) to this session's default tab
            $cmd = substr($string, length($axmud::CLIENT->constEchoSigil));
            if ($cmd) {

                # Execute the echo command
                return ($self->echoCmd($cmd), 'echo');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with a forward slash (/) is a Perl command
        } elsif (! $noPerlFlag && index ($string, $axmud::CLIENT->constPerlSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'perl');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'perl'))
                || (! $existsFlag && ! $axmud::CLIENT->perlSigilFlag)
            ) {
                # Perl commands are currently disabled. Process the instruction is a world command
                return ($self->worldCmd($string), 'world');
            }

            # Remove the sigil and treat the remaining text (if any) as a Perl programme to be
            #   evaluated
            $cmd = substr($string, length($axmud::CLIENT->constPerlSigil));
            if ($cmd) {

                # Execute the Perl command
                return ($self->perlCmd($cmd), 'perl');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with an ampersand (&) is a script command
        } elsif (index ($string, $axmud::CLIENT->constScriptSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'script');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'script'))
                || (! $existsFlag && ! $axmud::CLIENT->scriptSigilFlag)
            ) {
                # Script commands are currently disabled. Process the instruction is a world command
                return ($self->worldCmd($string), 'world');
            }

            # Remove the sigil and treat the remaining text as an Axbasic script to be run
            $cmd = substr($string, length($axmud::CLIENT->constScriptSigil));
            if ($cmd) {

                # Run the Axbasic script
                return ($self->clientCmd('runscript ' . $cmd), 'script');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with a colon (:) is a multi command
        } elsif (index ($string, $axmud::CLIENT->constMultiSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'multi');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'multi'))
                || (! $existsFlag && ! $axmud::CLIENT->multiSigilFlag)
            ) {
                # Multi commands are currently disabled. Process the instruction is a world command
                return ($self->worldCmd($string), 'world');
            }

            # Remove the sigil and run the remaining text as a world command to be executed in
            #   multiple sessions
            $cmd = substr($string, length($axmud::CLIENT->constMultiSigil));
            if ($cmd) {

                # Execute the multi command
                return ($self->multiCmd($cmd), 'multi');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with a full stop (.) is a speedwalk command
        } elsif (index ($string, $axmud::CLIENT->constSpeedSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'speed');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'speed'))
                || (! $existsFlag && ! $axmud::CLIENT->speedSigilFlag)
            ) {
                # Speedwalk commands are currently disabled. Process the instruction is a world
                #   command
                return ($self->worldCmd($string), 'world');
            }

            # ($self->parseSpeedWalk is expecting a string beginning with a sigil, so in this case
            #   don't remove it)
            if ($string) {

                # Process the speedwalk command
                return ($self->speedWalkCmd($string), 'speed');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with a greater than symbol (>) is a bypass command
        } elsif (index ($string, $axmud::CLIENT->constBypassSigil) == 0) {

            $existsFlag = $self->currentWorld->ivExists('sigilOverrideHash', 'bypass');
            if (
                ($existsFlag && ! $self->currentWorld->ivShow('sigilOverrideHash', 'bypass'))
                || (! $existsFlag && ! $axmud::CLIENT->bypassSigilFlag)
            ) {
                # Bypass commands are currently disabled. Process the instruction is a world command
                return ($self->worldCmd($string), 'world');
            }

            # Remove the sigil and run the remaining text as a bypass command
            $cmd = substr($string, length($axmud::CLIENT->constBypassSigil));
            if ($cmd) {

                # Process the forced world command
                return (
                    $self->worldCmd(
                        $cmd,
                        undef,      # Not obscured
                        undef,      # Not a forced world command
                        TRUE,       # Bypass command
                    ),
                    'bypass',
                );

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with a semicolon (;) is a client command
        } elsif (index ($string, $axmud::CLIENT->constClientSigil) == 0) {

            # Remove the semicolon, and treat the remaining text (if any) as a client command
            $cmd = substr($string, length($axmud::CLIENT->constClientSigil));
            if ($cmd) {

                # Execute the client command
                return ($self->clientCmd($cmd), 'client');

            } else {

                # Null command
                return @emptyList;
            }

        # An instruction beginning with ',,' is a forced world command
        } elsif (index ($string, $axmud::CLIENT->constForcedSigil) == 0) {

            # Remove the double comma, and treat the remaining text as a world command
            $cmd = substr($string, length($axmud::CLIENT->constForcedSigil));
            if ($cmd) {

                # Process the forced world command
                return (
                    $self->worldCmd(
                        $cmd,
                        undef,      # Not obscured
                        TRUE,       # Forced world command
                        undef,      # Not a bypass command
                    ),
                    'world',
                )

            } else {

                # Null command
                return @emptyList;
            }

        } else {

            # Process the world command
            return ($self->worldCmd($string, undef, undef, undef, $preText, $postText), 'world');
        }
    }

    sub echoCmd {

        # Called by $self->doInstruct (and also by GA::Cmd::Echo->do)
        #
        # Executes an echo command (writes a string to the session's default tab as a system
        #   message)
        #
        # Expected arguments
        #   $inputString    - The string to display as a system message
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the results of the call to $self->writeText

        my ($self, $inputString, $check) = @_;

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->echoCmd', @_);
        }

        # Update the instruction buffer
        $self->updateInstructBuffer($axmud::CLIENT->constEchoSigil . $inputString, 'echo');

        # Display a system message in the session's default tab
        return $self->writeText($inputString);
    }

    sub perlCmd {

        # Called by $self->doInstruct (also called by $self->checkAliases)
        #
        # Executes a string as a Perl programme using the 'Safe' module (which prevents the user
        #   performing unsafe operations, like 'rm *')
        # The programme's return value (if any, and if there are no errors in the Perl code) will
        #   be treated as a client command (if it begins with ';'), or a world command - so if the
        #   return value begins with another '/', it's not treated as another Perl programme to
        #   execute
        #
        # NB When called by $self->checkAliases, the return value will be treated as a world command
        #   no matter with which character it begins
        # NB Perl errors are shown in the 'main' window, but Perl warnings are not. (Warnings are
        #   still visible in the terminal window, if open)
        #
        # NB A blessed reference's accessor methods are accessible to Perl commands, for example:
        #
        #   /$obj = $_client; return $obj                   # Returns a blessed reference
        #   /$obj = $_client->mainWin; return $obj;         # Returns a blessed reference
        #   /$num = $_session->number; return $num;         # Returns value stored in a scalar IV
        #   /@list = $_session->profPriorityList; return $list[0];
        #                                                   # Returns the first item in a list IV
        #
        # The object's functions are also accessible, as long as they are not inherited. For example
        #   the Perl command can't call $_client->ivPeek, because that function is inherited from
        #   the generic object, Games::Axmud. However, the Perl command can call ->peek, which is
        #   defined in GA::Client itself (especially for this purpose)
        #
        #   /$name = $_session->peek('world.deathmud.name'); return $name;
        #
        # Expected arguments
        #   $inputString    - A string containing the whole Perl programme, e.g. "$a = 5; $b + 2;"
        #
        # Return values
        #   'undef' on improper arguments, if the Safe->reval operation produces errors or if the
        #       operation produces no return value
        #   Otherwise returns the value of the call to $self->doInstruct

        my ($self, $inputString, $check) = @_;

        # Local variables
        my (
            $compart, $val, $successFlag,
            %hash,
        );

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->perlCmd', @_);
        }

        # Update the instruction buffer
        $self->updateInstructBuffer($axmud::CLIENT->constPerlSigil . $inputString, 'perl');

        # Create a Safe compartment
        $compart = Safe->new();

        # Import the hash of data stored when interfaces fire
        %hash = $self->perlCmdDataHash;

        # Share variables to the Perl programme

        # Script, version and script date
        local our $_scriptName = $axmud::SCRIPT;
        local our $_scriptVersion = $axmud::VERSION;
        local our $_scriptDate = $axmud::DATE;
        $compart->share('$_scriptName', '$_scriptVersion', '$_scriptData');

        # Client and Session objects
        local our $_client = $axmud::CLIENT;
        local our $_session = $self;

        $compart->share('$_client', '$_session');

        # Current profiles
        local our $_world = $self->currentWorld->name;

        local our $_guild;
        if (defined $self->currentGuild) {
            $_guild = $self->currentGuild->name;
        } else {
            $_guild = '';
        }

        local our $_race;
        if (defined $self->currentRace) {
            $_race = $self->currentRace->name;
        } else {
            $_race = '';
        }

        local our $_char;
        if (defined $self->currentChar) {
            $_char = $self->currentChar->name;
        } else {
            $_char = '';
        }

        $compart->share('$_world', '$_guild', '$_race', '$_char');

        # Which interface fired to produce this instruction ('undef' if none)
        local our $_interface;
        if (defined $hash{'_interface'}) {
            $_interface = $hash{'_interface'};
        } else {
            $_interface = '';
        }

        $compart->share('$_interface');

        # Trigger interfaces
        local our $_line;
        if (defined $hash{'_line'}) {
            $_line = $hash{'_line'};
        } else {
            $_line = '';
        }

        local our $_stripLine;
        if (defined $hash{'_stripLine'}) {
            $_stripLine = $hash{'_stripLine'};
        } else {
            $_stripLine = '';
        }

        local our $_modLine;
        if (defined $hash{'_modLine'}) {
            $_modLine = $hash{'_modLine'};
        } else {
            $_modLine = '';
        }

        local our %_tagHash = $self->perlCmdTagHash;
        local our @_grpStringList = $self->perlCmdGrpStringList;

        $compart->share('$_line', '$_stripLine', '$_modLine', '%_tagHash', '@_grpStringList');

        # Alias interfaces
        local our $_originalCmd;
        if (defined $hash{'_originalCmd'}) {
            $_originalCmd = $hash{'_originalCmd'};
        } else {
            $_originalCmd = '';
        }

        $compart->share('$_originalCmd');

        # Macro interfaces
        local our $_keycode;
        if (defined $hash{'_keycode'}) {
            $_keycode = $hash{'_keycode'};
        } else {
            $_keycode = '';
        }

        $compart->share('$_keycode');

        # Timer interfaces
        local our $_timerExpect;
        if (defined $hash{'_timerExpect'}) {
            $_timerExpect = $hash{'_timerExpect'};
        } else {
            $_timerExpect = '';
        }

        local our $_timerTime;
        if (defined $hash{'_timerTime'}) {
            $_timerTime = $hash{'_timerTime'};
        } else {
            $_timerTime = '';
        }

        $compart->share('$_timerExpect', '$_timerTime');

        # Hook interfaces
        local our $_hookEvent;
        if (defined $hash{'_hookEvent'}) {
            $_hookEvent = $hash{'_hookEvent'};
        } else {
            $_hookEvent = '';
        }

        local our $_hookVar;
        if (defined $hash{'_hookVar'}) {
            $_hookVar = $hash{'_hookVar'};
        } else {
            $_hookVar = '';
        }

        local our $_hookVal;
        if (defined $hash{'_hookVal'}) {
            $_hookVal = $hash{'_hookVal'};
        } else {
            $_hookVal = '';
        }

        $compart->share('$_hookEvent', '$_hookVar', '$_hookVal');

        # Execute the Perl programme, using the default operator mask (specified by :default optag)
        # We set $axmud::SAFE_MODE_FLAG to TRUE so that the error-trapping code in axmud.pl doesn't
        #   try to make various function calls it can't complete in Safe's allocated namespace
        $axmud::SAFE_MODE_FLAG = TRUE;
        $val = $compart->reval($inputString);
        $axmud::SAFE_MODE_FLAG = FALSE;

        if ($@) {

            if ($@ =~ m/trapped by operation mask/) {

                # For operators blocked by the operator mask, produce a nicer message than the
                #   default one
                return $self->writeError(
                    'Perl programme contains unsafe operators, so was not executed',
                    $self->_objClass . '->perlCmd',
                );

            } else {

                return $self->writeError(
                    'Perl programme failure: ' . $@,
                    $self->_objClass . '->perlCmd',
                );
            }

        } elsif (! defined $val) {

            return $self->writeError(
                'Perl programme failure: no return value to use',
                $self->_objClass . '->perlCmd',
            );

        } else {

            # Call $self->doInstruct with the return value. The TRUE value means that if the
            #   return value begins with an initial backslash, it will be treated as just another
            #   world command - recursive return values are not allowed
            ($successFlag) = $self->doInstruct($val, TRUE);

            return $successFlag;
        }
    }

    sub multiCmd {

        # Called by $self->doInstruct (and also by GA::Cmd::Echo->do)
        #
        # Executes a multi command (executes the same world command in multiple sessions)
        #
        # Expected arguments
        #   $inputString    - The string to execute as a world command in multiple sessions
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the results of the call to $self->worldCmd in this session

        my ($self, $inputString, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->multiCmd', @_);
        }

        # Compile a list of sessions, not including this one
        if (! $axmud::CLIENT->maxMultiCmdFlag) {

            # Only sessions with the same current world as this one
            @sessionList = $axmud::CLIENT->findSessions(
                $self->currentWorld->name,
                $self,
            );

        } else {

            # All sessions (except this one)
            @sessionList = $axmud::CLIENT->listSessions($self);
        }

        # Execute a forced world command in the other sessions...
        foreach my $otherSession (@sessionList) {

            $otherSession->worldCmd(
                $inputString,
                undef,          # Not obscured
                TRUE,           # Forced world command
            );
        }

        # Process the world command
        return $self->worldCmd(
            $inputString,
            undef,              # Not obscured
            TRUE,               # Forced world command
        );
    }

    sub speedWalkCmd {

        # Called by $self->doInstruct
        #
        # Exectues a speedwalk command, in the format specified by the comments in
        #   $self->parseSpeedWalk
        #
        # Expected arguments
        #   $inputString    - A string containing the speedwalk command, e.g. '.3nw2e' (which must
        #                       include the speedwalk command sigil, GA::Client->constSpeedSigil)
        #
        # Return values
        #   'undef' on improper arguments or if the speedwalk command can't be processed
        #   1 otherwise

        my ($self, $inputString, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->speedWalkCmd', @_);
        }

        # Check the compulsory speedwalk command sigil exists
        if (index ($inputString, $axmud::CLIENT->constSpeedSigil) != 0) {

            # Not a valid speedwalk command
            return undef;
        }

        # Parse $inputString into a list of world commands, with any movement commands marked
        # Because of the TRUE argument, $self->parseSpeedWalk returns a list in the form
        #       (move_mode, world_command, move_mode, world_command...)
        # ...where 'move_mode' is 'move' if 'world_command' is definitely a movement command,
        #   'not_move' if it's definitely not a movement command, or 'unknown' if the calling
        #   function should decide for itself
        @list = $self->parseSpeedWalk($inputString, TRUE);
        if (! @list) {

            # The speedwalk command could not be parsed
            return undef;
        }

        # Process each world command, one by one
        do {

            my ($moveMode, $cmd, $result);

            $moveMode = shift @list;
            $cmd = shift @list;

            if ($moveMode eq 'move') {
                $result = $self->moveCmd($cmd);
            } elsif ($moveMode eq 'not_move') {
                $result = $self->relayCmd($cmd);
            } else {
                $result = $self->worldCmd($cmd);
            }

            # Give up at the first error
            if (! $result) {

                return undef;
            }

        } until (! @list);

        # Operation complete
        return 1;
    }

    sub parseSpeedWalk {

        # Called by $self->speedWalkCmd or by any other code
        #
        # Parses a speedwalk like '.3nw2s', typically converting it into a list of world commands
        #   like ('north', 'north', 'north', 'west', 'south', 'south')
        #
        # The calling function supplies a string in the following format:
        #   srnmcnmc...
        #
        # 's' represents the speedwalk command sigil, GA::Client->constSpeedSigil (a full stop/
        #   period, by default). This function can be called even if speedwalk commands are turned
        #   off (GA::Client->speedSigilFlag is FALSE)
        # 'r' is an optional full stop (.) character that reverses the order of the world commands
        #   (e.g. '..3nw2s' represents ('south', 'south', 'west', 'north', 'north', 'north)
        #
        # 'nmc' represents a group of characters which are parsed into one or more world commands.
        #   A speedwalk command must contain at least one 'nmc' group; there is no limit to the
        #   number of 'nmc' groups
        # 'n' is an optional positive integer in the range 1-999, 'm' is an optional modifier
        #   character (or sequence of characters), and c is a single character (or sequence of
        #   characters) representing a single world command
        #
        # 'c' can be a single character in the range a-z, or a sequence of characters in the form
        #   (xxx) where 'xxx' is a movement command, or (xxx/yyy) where 'xxx' is a movement command
        #   and 'yyy' is the opposite movement command, or {zzz} where 'zzz' is any comment text
        # (NB Characters from non-Latin alphabets are acceptable)
        #
        # If 'c' is a single character in the range a-z, this function consults the current
        #   dictionary's ->speedDirHash. If 'c' exists as a key in that hash, the corresponding
        #   value is parsed as a movement command. If the corresponding value is a standard primary
        #   direction (e.g. 'north'), the equivalent custom primary direction is parsed (again, the
        #   dictionary is consulted). If it is not a standard primary direction, the corresponding
        #   value is parsed as a movement command without any changes
        # If 'c' is in the form (xxx), the string between the brackets is parsed as a movement
        #   command without referring to the current dictionary. If 'c' is in the form (xxx/yyy),
        #   the 'xxx' is parsed as a movement command, unless the optional 'r' was specified, in
        #   which case 'yyy' is parsed as a movement command
        # If 'c' is in the form {zzz}, it is treated as a comment and ignored
        #
        # 'm' can be a single character in the range A-Z, or a sequence of characters in the form
        #   [xxx] or [xxx/yyy]
        # (NB Characters from non-Latin alphabets are acceptable)
        #
        # If 'm' is a single character in the range A-Z, this function consults the current
        #   dictionary's ->speedModifierHash. If 'm' exists as a key in that hash, the corresponding
        #   value is handled as a standard command (matching a key GA::Cage::Cmd->cmdHash)
        # Typical standard commands are 'go', 'fly', 'sail', 'open_dir', 'unlock_dir' etc
        # Each standard command has a replacement command, which should contain the word
        #   'direction'. If so, the replacement command is interpolated with the direction specified
        #   by 'c' (e.g. standard command 'sail', replacement command 'sail direction', speedwalking
        #   command 'Sn', this function parses it as 'sail north')
        # If the standard command is a movement command ('go', 'fly', 'sail', this function parses
        #   the interpolated command as a movement command. If not, it's parsed as a non-movement
        #   command
        # If the replacement command does not contain the word 'direction', or if 'm' is a single
        #   character which doesn't exist in the dictionary hash, 'm' is ignored, and only the
        #   following 'c' character is parsed
        # If 'm' is in the form [xxx] or [xxx/yyy], then this function parses a command in the form
        #   'xxx c' (or 'yyy c', if 'r' is specified). For example, '[CLOSE]e' would be parsed as
        #   'close east', or something like that. The subsequent code decides for itself if it's a
        #   movement or non-movement command
        # If 'm' is not followed by a valid 'c', then 'm' is ignored
        #
        # 'n' can be a positive integer in the range 1-999. If a longer integer is specified (e.g.
        #   12345), only the last two digits are used (e.g. 45)
        # If 'n' is specified and is a positive integer, the command is parsed n times. If 0 is
        #   specified, the subsequent command isn't parsed at all
        #
        # If 'r', the optional full stop character, is specified at the start of the speedwalk
        #   command, all the commands are parsed in the reverse order
        #
        # Any whitespace characters between components are ignored
        # If there are any invalid characters between components, the speedwalk command is not
        #   parsed
        #
        # Examples (if type from the command line, the speedwalk command will start with a
        #   compulsory '.' sigil, which is not included in these example strings):
        #
        #   n           > n
        #   3n          > n;n;n
        #   3nw2e       > n;n;n;w;e;e
        #   .3nw2e      > w;w;e;s;s;s
        #
        #   Sn          > sail n
        #   3Sn         > sail n;sail n;sail n
        #
        #   3nOww       > n;n;n;open w door;w
        #   .3nOww      > e;open e door;s;s;s
        #
        #   (If the reversed path above won't work; one solution is to open the door on both sides)
        #   3nOwwOw     > n;n;n;open w door;w;open w door
        #   .3nOwwOw    > open e door;e;open e door;s;s;s
        #
        #   3n(sw)e     > n;n;n;sw;e
        #   .3n(sw)e    > w;sw;s;s;s    (does not work as intended)
        #   .3n(sw/ne)e > w;ne;s;s;s    (works as intended)
        #
        #   (if the world won't allow unlimited repeat commands, insert a few 'look' commands)
        #   3nle        > n;n;n;l;e
        #
        #   (for very long sequences, insert a comment to break it up)
        #   3nw2n{city gate}wne
        #               > n;n;n;w;n;n;w;n;e
        #
        #   (in case the speedwalk string includes the speedwalk sigil by mistake, any number of
        #       initial full stops can be specified; they always reverse the path:
        #   ..3n        > south;south;south
        #   ......3n    > south;south;south
        #
        # Expected arguments
        #   $string     - A string containing the speedwalk command, e.g. '.3nw2e'. The string must
        #                   begin with the speedwalk command sigil, GA::Client->constSpeedSigil (a
        #                   full stop/period, by default)
        #
        # Optional arguments
        #   $fullFlag   - Specifies the format in which the parsed list of world commands is
        #                   returned; see below
        #
        # Return values
        #   An empty list on improper arguments or if the speedwalk command can't be parsed
        #   Otherwise, returns a list. If $fullFlag is TRUE, returns a list that identifies which
        #       world commands are definitely movement commands. The list is in the form
        #           (move_mode, world_command, move_mode, world_command...)
        #       ...where 'move_mode' is 'move' if 'world_command' is definitely a movement command,
        #       'not_move' if it's definitely not a movement command, or 'unknown' if the calling
        #       function should decide for itself
        #   If $fullFlag is FALSE (or 'undef'), returns a simple list of world commands, i.e.
        #           (world_command, world_command...)

        my ($self, $string, $fullFlag, $check) = @_;

        # Local variables
        my (
            $reverseFlag, $cageObj, $dictObj, $wmObj,
            @emptyList, @returnList,
        );

        # Check for improper arguments
        if (! defined $string || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->parseSpeedWalk', @_);
            return @emptyList
        }

        # Remove the compulsory speedwalk command sigil
        if (index ($string, $axmud::CLIENT->constSpeedSigil) != 0) {

            # Not a valid speedwalk command
            return @emptyList;

        } else {

            $string = substr($string, length($axmud::CLIENT->constSpeedSigil));
        }

        # Remove the optional initial full stop(s)
        if (substr($string, 0, 1) eq '.') {

            $reverseFlag = TRUE;
            $string = substr($string, 1);
        }

        # If $string is now empty, we can't parse a speedwalk command
        if ($string eq '') {

            return @emptyList;
        }

        # Import the highest-priority command cage and (for convenience) the current dictionary and
        #   world model object
        $cageObj = $self->findHighestCage('cmd');
        $dictObj = $self->currentDict;
        $wmObj = $self->worldModelObj;

        # Otherwise, extract 'nmc' segments, one by one
        do {

            my (
                $moveMode, $initLength, $multiple, $modifier, $revModifier, $singleModFlag,
                $comment, $dir, $revDir, $singleDirFlag, $cmd, $revCmd, $standardCmd, $interpCmd,
                $revInterpCmd,
            );

            # $moveMode is set to 'move' if it's definitely a movement command, 'not_move' if it's
            #   definitely not a movement command, and remains set to 'unknown' if the subsequent
            #   code should decide for itself
            $moveMode = 'unknown';
            # Infinite loops shouldn't be possible, but check the length of $string at the beginning
            #   and end of every spin, just in case
            $initLength = length($string);

            # Remove all initial whitespace
            $string =~ s/^\s+//;

            # Extract the 'n' component, an integer. If higher than 999, use the last 3 digits. If
            #   0, the subsequent command isn't parsed at all
            if ($string =~ m/^(\d+)(.*)/) {

                $multiple = substr($1, -3);
                $string = $2;

            } else {

                $multiple = 1;
            }

            # Remove more whitespace
            $string =~ s/^\s+//;

            # Extract the 'm' component, a single character in the range A-Z, or a sequence of
            #   characters in the form [xxx] or [xxx/yyy]
            if ($string =~ m/^\[([[:word:]\/]+)\](.*)/) {

                $modifier = $1;
                $string = $2;

                # Split (xxx/yyy). If there is more than one '/' character, everything after the
                #   first one is part of the reverse modifier
                if ($modifier =~ m/^([[:word:]]+)\/([[:word:]]+)/) {

                    $modifier = $1;
                    $revModifier = $2;
                }

            } elsif ($string =~ m/^([[:upper:]])(.*)/) {

                $modifier = $1;
                $string = $2;
                # This modifier character must be checked against the current dictionary
                $singleModFlag = TRUE;
            }

            # Remove more whitespace
            $string =~ s/^\s+//;

            # Extract the 'c' component, a a single character in the range a-z, or a sequence of
            #   characters in the form (xxx) or (xxx/yyy) or {zzz}
            if ($string =~ m/^(\{[^\}]+\})(.*)/) {

                # Ignore the comment
                $string = $2;
                $dir = '';
                $multiple = 0;

            } elsif ($string =~ m/^\(([[:word:]\/]+)\)(.*)/) {

                $dir = $1;
                $string = $2;

                # Split (xxx/yyy). If there is more than one '/' character, everything after the
                #   first one is part of the reverse modifier
                if ($dir =~ m/^([[:word:]]+)\/([[:word:]]+)/) {

                    $dir = $1;
                    $revDir = $2;
                }

            } elsif ($string =~ m/^([[:lower:]])(.*)/) {

                $dir = $1;
                $string = $2;
                # This speedwalking character must be checked against the current dictionary
                $singleDirFlag = TRUE;
            }

            # To prase the speedwalk command, we must have a valid 'c' component, and the 'n' and
            #   'm' components (if specified) must also be valid
            if (! defined $dir) {

                # Invalid character between components; can't parse the speedwalk command
                return @emptyList;

            } elsif ($multiple) {

                # Parse a single-character 'c' component
                if ($singleDirFlag) {

                    $cmd = $dictObj->ivShow('speedDirHash', $dir);
                    # If $dir doesn't exist in the hash, $cmd is 'undef', and this 'nmc' sequence is
                    #   ignored completely (but we still parse the rest of $string)
                    if (defined $cmd) {

                        # This is definitely a movement command
                        $moveMode = 'move';

                        # If $cmd is a standard primary direction, replace it with the corresponding
                        #   custom primary direction
                        if ($dictObj->ivExists('primaryDirHash', $cmd)) {

                            $revCmd = $dictObj->ivShow('primaryOppAbbrevHash', $cmd);
                            $cmd = $dictObj->ivShow('primaryAbbrevHash', $cmd);

                        } else {

                            # Get the reverse direction while we're at it
                            ($revCmd) = $wmObj->reversePath($self, 'abbrev', $cmd);
                            if (! defined $revCmd) {

                                # Emergency default
                                $revCmd = $cmd;
                            }
                        }
                    }

                # Parse a 'c' component in the form (xxx) or (xxx/yyy)
                } elsif (defined $dir) {

                    $cmd = $dir;

                    if (defined $revDir) {
                        $revCmd = $revDir;
                    } else {
                        $revCmd = $dir;
                    }
                }
            }

            if ($multiple && defined $cmd) {

                # Parse a single-character 'm' component
                if ($singleModFlag) {

                    $standardCmd = $dictObj->ivShow('speedModifierHash', $modifier);
                    # If $modifier doesn't exist in the hash, or if it's not a recognised standard
                    #   command, the 'm' component is ignored and $cmd is not modified at all
                    if (defined $standardCmd) {

                        # Interpolate the replacement command
                        $interpCmd = $self->prepareCmd($standardCmd, 'direction', $cmd);
                        if (defined $interpCmd && $interpCmd ne '') {

                            $cmd = $interpCmd;
                        }

                        $revInterpCmd = $self->prepareCmd($standardCmd, 'direction', $revCmd);
                        if (defined $revInterpCmd && $revInterpCmd ne '') {

                            $revCmd = $revInterpCmd;
                        }

                        # We can also say that this is (or is not) a movement command
                        if (! defined $cageObj->ivFind('moveCmdList', $standardCmd)) {
                            $moveMode = 'not_move';
                        } else {
                            $moveMode = 'move';
                        }
                    }

                # Parse an 'm' component in the form (xxx) or (xxx/yyy)
                } elsif (defined $modifier) {

                    $cmd = $modifier . ' ' . $cmd;
                    if (defined $revModifier) {
                        $revCmd = $revModifier . ' ' . $revCmd;
                    } else {
                        $revCmd = $modifier . ' ' . $revCmd;
                    }
                }
            }

            # If $cmd is now 'undef', ignore this 'nmc' sequence altogether
            if (
                $multiple
                && defined $cmd
                && (! $reverseFlag || defined $revCmd)
            ) {
                # Compile a list in the form
                #   (move_mode, command, move_mode, command...)
                for (my $count = 0; $count < $multiple; $count++) {

                    if (! $fullFlag) {

                        if (! $reverseFlag) {
                            push (@returnList, $cmd);
                        } else {
                            unshift(@returnList, $revCmd);
                        }

                    } else {

                        if (! $reverseFlag) {
                            push (@returnList, $moveMode, $cmd);
                        } else {
                            unshift(@returnList, $moveMode, $revCmd);
                        }
                    }
                }
            }

            # Final check - avoid infinite loops by checking $string is shorter now than it was at
            #   the beginning of the loop iteration
            if (length ($string) >= $initLength) {

                return @emptyList;
            }


        } until (! $string);

        # Operation complete
        return @returnList;
    }

    sub clientCmd {

        # Called by $self->doInstruct or ->pseudoCmd
        # Executes a client command string. Given a string like 'setworld deathmud' (with any
        #   command sigils already removed), parses the command into a list of words (or groups of
        #   words), then calls the ->do function of the specified client command
        #
        # Expected arguments
        #   $inputString    - A string containing the whole client command, e.g. 'setworld deathmud'
        #                       (but not the command sigil, ';')
        #
        # Return values
        #   'undef' on improper arguments, if $inputString is empty, if the client command (the
        #       first word in the string) is neither a standard (built-in) client command or a
        #       recognised user command or if the session isn't connected to a world
        #   Otherwise returns the result of the call to GA::XXXCmd->do (which might be 'undef'
        #       or 1)

        my ($self, $inputString, $check) = @_;

        # Local variables
        my (
            $standardCmd, $userCmd, $worldCmd, $cmdObj, $result,
            @inputWord,
        );

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->clientCmd', @_);
        }

        # Update the instruction buffer
        $self->updateInstructBuffer($axmud::CLIENT->constClientSigil . $inputString, 'client');

        # Parse the command into an array of words (or groups of words) separated by whitespace
        @inputWord = $self->parseCmd($inputString);
        # Remember the actual ('user') command typed. Should be case insensitive (although the
        #   arguments might not be), so convert it to all lower-case letters
        $userCmd = lc($inputWord[0]);
        # Special arrangements for experienced spelunkers
        if ($userCmd eq 'xyzzy'|| $userCmd eq 'plugh') {

            return Games::Axmud::Generic::Cmd->xyzzy($self);
        }

        # Check that the command typed by the user, $userCmd, corresponds to a standard
        #   (built-in) client command
        if (! $axmud::CLIENT->ivExists('userCmdHash', $userCmd)) {

            # Special exception - any primary direction (standard or custom, abbreviated or not)
            #   is converted to ';allocateexit', for example ';north portal' is converted to
            #   ';allocateexit north portal'
            # In addition, a world command in that direction is executed (at the end of this
            #   function)
            if ($axmud::CLIENT->ivExists('constPrimaryDirList', $userCmd)) {

                # Standard primary direction
                $worldCmd = $self->currentDict->ivShow('primaryDirHash', $userCmd);
                unshift (@inputWord, $userCmd);
                $userCmd = $standardCmd = 'allocateexit';

            } elsif (defined $self->currentDict->checkPrimaryDir($userCmd)) {

                # Custom primary direction
                $worldCmd = $userCmd;
                unshift (@inputWord, $userCmd);
                $userCmd = $standardCmd = 'allocateexit';

            } else {

                return $self->writeError(
                    'Unrecognised client command \'' . $userCmd . '\'',
                    $self->_objClass . '->clientCmd',
                );
            }

        } else {

            # Get the corresponding standard (built-in) command
            $standardCmd = $axmud::CLIENT->ivShow('userCmdHash', $userCmd);
        }

        # Check that a Perl object for this command actually exists (no reason why it shouldn't,
        #   but we'll check anyway)
        if (! $axmud::CLIENT->ivExists('clientCmdHash', $standardCmd)) {

            return $self->writeError(
                'Missing client command \'' . $userCmd . '\' in registry',
                $self->_objClass . '->clientCmd',
            );

        } else {

            $cmdObj = $axmud::CLIENT->ivShow('clientCmdHash', $standardCmd);
        }

        # Many commands are not available after a disconnection (however, all commands are available
        #   in 'connect offline' mode)
        if ($self->status eq 'disconnected' && ! $cmdObj->disconnectFlag) {

            return $self->writeError(
                '\'' . $standardCmd . '\' command unavailable while disconnected from the'
                . ' world',
                $self->_objClass . '->clientCmd',
            );
        }

        # For commands whose ->noBracketFlag is TRUE, we have to re-parse $inputString, this time
        #   ignoring brackets (...) and diamond brackets <...>
        if ($cmdObj->noBracketFlag) {

            @inputWord = split(m/\s+/, $inputString);
        }

        # Replace the first word in @inputWord so that it's the standard (built-in) command, not the
        #   user command actually typed by the user
        $inputWord[0] = $standardCmd;

        # Call the corresponding command object's ->do function to execute the command
        $result = $cmdObj->do($self, $inputString, $userCmd, @inputWord);
        if (! $result) {

            return undef;

        } else {

            # Sensitise/desensitise menu bar/toolbar items, depending on current conditions
            $axmud::CLIENT->desktopObj->restrictWidgets();

            # If the user typed a command like ';north portal' which was translated into
            #   ';allocateexit north portal', execute the corresponding world command
            if ($worldCmd) {

                $self->worldCmd($worldCmd);
            }

            return 1;

        }
    }

    sub pseudoCmd {

        # Can be called by any function which wants to execute a string as a client command, as if
        #   it has been typed in the 'main' window's command entry box
        # e.g. The ';stopsession' command sometimes needs to call ';save'
        # e.g. Axbasic scripts often need to execute strings as client commands
        #
        # This function allows the calling function to optionally specify whether the standard
        #   GA::Generic::Cmd->complete / ->error / ->improper messages should be displayed, or
        #   whether they should be suppressed
        #
        # Expected arguments
        #   $cmd    - A string containing the pseudo command, e.g. 'setworld deathmud'. The first
        #               word should be a standard client command, not a user command (user commands
        #               will work, as long as they are still recognised, i.e. still exist in
        #               GA::Client->userCmdHash). The string should not begin with the client
        #               command sigil ';'
        #
        # Optional arguments
        #   $mode   - Specifies how to display messages produced by client commands (does not affect
        #               how messages produced by Games::Axmud->writeText, ->writeDebug and so on are
        #               displayed)
        #           - The Axmud GUI and Automapper windows use modes 'win_error' and 'win_only'
        #           - Axbasic uses mode 'hide_complete'
        #
        #               'show_all' - show all standard messages produced by the command (with calls
        #                   to GA::Generic::Cmd->complete, ->error and ->improper)
        #               'hide_complete' - suppress messages produced by a call to
        #                   GA::Generic::Cmd->complete (on the successful execution of a command),
        #                   but display error messages
        #               'hide_system' - suppress all standard messages produced by the command (with
        #                   calls to GA::Generic::Cmd->complete, ->error and ->improper)
        #               'win_error' - show messages produced by a call to GA::Generic::Cmd->complete
        #                   (on the successful execution of a command) in the 'main' window, but
        #                   show error message calls to ->error and ->improper in a 'dialogue'
        #                   window
        #               'win_only' suppress all messages produced by a call to
        #                   GA::Generic::Cmd->complete (on the successful execution of a command),
        #                   but show error message calls to ->error and ->improper in a 'dialogue'
        #                   window
        #
        # Return values
        #   'undef' on improper arguments or if an invalid $mode is specified
        #   Otherwise returns the result of the call to $self->clientCmd

        my ($self, $cmd, $mode, $check) = @_;

        # Local variables
        my ($oldMode, $result);

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->pseudoCmd', @_);
        }

        if (defined $mode) {

            # Check that $mode is valid
            if (
                $mode ne 'show_all' && $mode ne 'hide_complete' && $mode ne 'hide_system'
                && $mode ne 'win_error' && $mode ne 'win_only'
            ) {
                return $self->writeError(
                    'Invalid mode \'' . $mode . '\' - use \'show_all\', \'hide_complete\','
                    . ' \'hide_system\' . \'win_error\', \'win_only\'',
                    $self->_objClass . '->pseudoCmd',
                );
            }

            # Set an IV which tells GA::Generic::Cmd->complete, ->error and ->improper whether to
            #   show or suppress messages
            if ($self->cmdMode eq 'show_all' && $mode ne 'show_all') {

                $oldMode = $self->cmdMode;
                $self->ivPoke('cmdMode', $mode);
            }

        } else {

            # Default $mode is 'show_all'
            $mode = 'show_all';
        }

        # Execute the command
        $result = $self->clientCmd($cmd);
        # Restore the old value of $self->cmdMode, if it was changed
        if (defined $oldMode) {

            $self->ivPoke('cmdMode', $oldMode);
        }

        # Return the result of the call to $self->cmdMode
        return $result;
    }

    sub parseCmd {

        # Called by $self->clientCmd
        # Parses a client command into an array of 'words', retaining as a single 'word':
        #   - everything enclosed within a pair of diamond brackets <...>
        #   - everything enclosed by brackets (...) (unless it appears within a single pair of
        #       diamond brackets)
        #   - everything separated by whitespace (and not within arrow brackets or brackets)
        # Diamond brackets are removed from the 'word' they enclose, but (normal) brackets are not
        #
        # e.g. hello <what is your name> hi <fred> <no space between this><and this>
        #   > hello
        #   > what is your name
        #   > hi
        #   > fred
        #   > no space between this
        #   > and this
        #
        # e.g. hello (what is your name?) fred
        #   > hello
        #   > what is your name?
        #   > fred
        #
        # e.g. hello <what is (your name) today?>
        #   > hello
        #   > what is (your name) today?
        #
        # NB Both arrow brackets can be substituted for the other
        # e.g. hello <what is your name< >how are you?>
        #   > hello
        #   > what is your name?
        #   > how are you?
        #
        # Expected arguments
        #   $inputString    - A string containing the whole client command, e.g. 'setworld deathmud'
        #                       (without the initial command sigil, ';')
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns an array of 'words'

        my ($self, $inputString, $check) = @_;

        # Local variables
        my (
            $quoteFlag, $bracketFlag, $bracketWord,
            @wordList1, @wordList2, @returnArray, @emptyList,
        );

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->parseCmd', @_);
            return @emptyList;
        }

        # Split the command into groups of words, surrounded by pairs of diamond brackets (if there
        #   are no pairs of diamond brackets, @wordList1 will contain a single scalar)
        @wordList1 = split (/(\<|\>)/, $inputString);

        # Now go through the contents of @wordList1, splitting all the words separated by whitespace
        #   (which are NOT between a pair of quotes), and putting the results into @wordList2
        foreach my $word (@wordList1) {

            if (! $quoteFlag) {

                if ($word eq '<' || $word eq '>') {

                    $quoteFlag = TRUE;

                } elsif ($word =~ m/\S/) {

                    $_ = $word;
                    push (@wordList2, split);
                }

            } else {

                if ($word eq '<' || $word eq '>') {

                    $quoteFlag = FALSE;

                } else {

                    push (@wordList2, $word);
                }
            }
        }

        # All the words that were between a pair of brackets (which was not between a pair of
        #   quotes) have been split. Recombine them, putting the results into @returnArray
        $bracketWord = '';
        foreach my $word (@wordList2) {

            if (! $bracketFlag) {

                # Start of bracketed expression, which isn't within arrow brackets
                if (
                    substr($word, 0, 1) eq '(' && substr ($word, -1, 1) ne ')'
                    && index($word, ' ') == -1
                ) {
                    $bracketFlag = TRUE;
                    $bracketWord = $word;

                # Not part of a bracketed expression
                } else {

                    push @returnArray, $word;
                }

            } elsif ($bracketFlag) {

                if (index ($word, ' ') != -1) {

                    # An open bracket was followed by an open arrow bracket. Assume the bracketed
                    #   expression is finished
                    push (@returnArray, $bracketWord . ')', $word);
                    $bracketFlag = FALSE;
                    $bracketWord = '';

                } else {

                    $bracketWord .= ' '.$word;
                    if (substr($word, -1, 1) eq ')') {

                        # End of bracketed expression
                        push @returnArray, $bracketWord;
                        $bracketFlag = FALSE;
                        $bracketWord = '';
                    }
                }
            }
        }

        # Return the parsed array of 'words'
        return @returnArray;
    }

    sub worldCmd {

        # Called by $self->doInstruct or any other command
        # Prepares a string to send as a command to the world
        #
        # NB If this function is modified, also update ->teleportCmd!
        #
        # Expected arguments
        #   $inputString    - A string containing the whole world command, e.g. 'kill orc'
        #
        # Optional arguments
        #   $obscureString  - Defined if we're sending some kind of password, in which case it's
        #                       set to a substring in $inputString. The substring is replaced in
        #                       the 'main' window by asterisks, which obscures the password. In
        #                       addition, the command is not recorded (if a recording is in
        #                       progress), nor is it stored in any buffer or tested for aliases or
        #                       hooks
        #   $forcedFlag     - Flag set to TRUE if the instruction was a forced world command (i.e.
        #                       began with the ',,' sigil, before the sigil was stripped; forced
        #                       world commands are always processed whole)
        #   $bypassFlag     - Flag set to TRUE if the instruction was a forced bypass command (i.e.
        #                       begin with the '>' sigil, before the sigil was stripped; bypass
        #                       commands are always processed whole)
        #   $preText        - Set when called by GA::Strip::Entry->setEntrySignals, and when the
        #                       user has specified some text that should be added to the beginning
        #                       of a world command; 'undef' otherwise. Ignored if $inputString is an
        #                       empty string
        #   $postText       - Set when called by GA::Strip::Entry->setEntrySignals, and when the
        #                       user has specified some text that should be added to the beginning
        #                       of a world command; 'undef' otherwise. Ignored if $inputString is an
        #                       empty string
        #
        # Return values
        #   'undef' on improper arguments, if $inputString is an empty string or if an earlier call
        #       to this function has still not been processed
        #   1 otherwise

        my (
            $self, $inputString, $obscureString, $forcedFlag, $bypassFlag, $preText, $postText,
            $check,
        ) = @_;

        # Local variables
        my (
            $result, $cage, $cmdLimit, $cmdDelay, $cmdSep,
            @cmdList,
        );

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->worldCmd', @_);
        }

        # Add text to the beginning and end of a world command, if required (but don't add anything
        #   to an empty world command)
        if (defined $preText && $inputString ne '') {

            $preText =~ s/\s*$//;
            $inputString = $preText . ' ' . $inputString;
        }

        if (defined $postText && $inputString ne '') {

            $postText =~ s/^\s*//;
            $inputString .= ' ' . $postText;
        }

        # This section of code prevents a second call to this function before an earlier call has
        #   been completed
        if ($self->worldCmdProcessFlag) {

            # An earlier call to this function is still being processed. Temporarily store world
            #   commands, and let the earlier call deal with them when it's ready
            $self->ivPush(
                'emergencyCmdList',
                $inputString,
                $obscureString,
                $forcedFlag,
                $bypassFlag,
            );

            return undef;

        } else {

            # This is the only call to this function at the moment
            $self->ivPoke('worldCmdProcessFlag', TRUE);
        }

        # Deal with sending passwords, if the flag is set
        if ($obscureString) {

            $result =  $self->dispatchPassword($inputString, $obscureString);
            $self->ivPoke('worldCmdProcessFlag', FALSE);
            return $result;
        }

        # Update the instruction buffer
        $self->updateInstructBuffer($inputString, 'world');

        # In case $inputString contains several commands (perhaps after aliases have modified it),
        #   save a bit of time by getting the command cage associated with the highest-priority
        #   profile now
        $cage = $self->findHighestCage('cmd');

        # For slowwalking settings, crawl mode (if enabled) overrules the current world profile
        if ($self->crawlModeFlag) {

            $cmdLimit = $self->crawlModeCmdLimit;
            $cmdDelay = 1;

        } else {

            $cmdLimit = $self->currentWorld->excessCmdLimit;
            $cmdDelay = $self->currentWorld->excessCmdDelay;
        }

        # If $inputString is a compound command like 'north;kill orc;eat corpse', we need to split
        #   it into individual world commands
        $cmdSep = $axmud::CLIENT->cmdSep;
        if (length ($inputString) > 0) {

            if (! $forcedFlag && ! $bypassFlag) {

                @cmdList = split(/$cmdSep/, $inputString);

            } else {

                # Forced world commands and bypass commands are never split
                @cmdList = ($inputString);
            }

        } else {

            # Empty strings would be lost in the code just above
            @cmdList = ('');
        }

        # To avoid redrawing the ghost room dozens or hundreds of times (which seriously slows down
        #   the execution of this function), remember the automapper's current ghost room so we can
        #   mark it to be redrawn later
        $self->ivPoke('worldCmdGhostRoom', $self->mapObj->ghostRoom);

        # Process each individual world command in turn
        OUTER: foreach my $cmd (@cmdList) {

            my (
                $modCmd,
                @newCmdList,
            );

            # Convert one-word world commands which might possibly be in CAPITAL LETTERS (if the
            #   user has, for example, been typing elsewhere) into lower case, if allowed (because
            #   most world that recognise 'north' won't recognise 'NORTH')
            # Doesn't apply to forced world comments (starting with the sigil ,,), and passwords
            #   have already been processed by this function (hopefully)
            if (! $forcedFlag && $axmud::CLIENT->convertWorldCmdFlag && $cmd =~ m/^[[:upper:]]*$/) {

                $cmd = lc($cmd);
            }

            # Fire any hooks that are using the 'sending_cmd' hook event (the world command before
            #   any aliases have had a chance to modify it)
            $self->checkHooks('sending_cmd', $cmd);

            # Check aliases. If any of them fire, $self->checkAliases will return a string
            #   containing a command (e.g. 'north') and several commands (e.g.
            #   'open door;north;kill orc'). If 'undef' is returned, we must not send a command to
            #   the world
            # NB Aliases never act on forced world commands
            if (! $forcedFlag) {

                $modCmd = $self->checkAliases($cmd);
                if (! defined $modCmd) {

                    next OUTER;
                }

            } else {

                $modCmd = $cmd;
            }

            # Act on each world command in $modCmd, one by one
            if (length ($modCmd) > 0) {

                if (! $forcedFlag) {

                    @newCmdList = split(/$cmdSep/, $modCmd);

                } else {

                    # Forced world commands are never split
                    @newCmdList = ($modCmd);
                }

            } else {

                # Empty strings would be lost in the code above
                @newCmdList = ('');
            }

            INNER: foreach my $newCmd (@newCmdList) {

                # If the current world profile (or crawl mode) specifies a maximum number of
                #   commands for a certain time period, we have to apply it
                # (Doesn't apply to bypass commands)
                if (! $bypassFlag && $cmdLimit && $self->excessCmdCount >= $cmdLimit) {

                    # Can't send this command yet; store it, and allow $self->spinMaintainLoop to
                    #   send it as soon as possible
                    $self->ivPush('excessCmdList', $newCmd, $self->sessionTime + $cmdDelay);

                    # If the excess command list was empty, the command we just added should be sent
                    #   exactly $delay seconds from now
                    if ($self->excessCmdList == 2) {

                        $self->ivPoke('lastExcessCmdTime', $self->sessionTime);
                    }

                    next INNER;
                }

                # Otherwise, the command can be sent to the world now
                $self->processWorldCmd($newCmd, $cage);

                if ($self->overruleMoveFlag) {

                    # A movement command has been overruled in protected moves mode, and the world
                    #   model flag instructs us to stop processing world commands, now)
                    $self->ivPoke('overruleMoveFlag', FALSE);
                    last OUTER;
                }
            }
        }

        # Now we can redraw the ghost room, if necessary
        if ($self->worldCmdGhostRoom) {

            $self->mapObj->setGhostRoom(
                $self->mapObj->ghostRoom,       # Don't actually change the current ghost room...
                $self->worldCmdGhostRoom,       # ...but make sure the earlier one is redrawn
            );

            $self->ivUndef('worldCmdGhostRoom');
        }

        # That's the end of the call to this function...
        $self->ivPoke('worldCmdProcessFlag', FALSE);
        # ...but if a second call to this function was received, while processing this one, we must
        # act on those commands now
        if ($self->emergencyCmdList) {

            # (If there were three or even more calls, they are processed recursively)
            $self->worldCmd(
                $self->ivShift('emergencyCmdList'),     # $inputString
                $self->ivShift('emergencyCmdList'),     # $obscureString
                $self->ivShift('emergencyCmdList'),     # $forcedFlag
                $self->ivShift('emergencyCmdList'),     # $bypassFlag
            );
        }

        return 1;
    }

    sub processWorldCmd {

        # Called by $self->worldCmd when a command is about to be sent to the world, and by
        #   $self->spinMaintainLoop when a command stored in $self->excessCmdList is about to be
        #   sent to the world
        #
        # NB If this function is modified, also update ->teleportCmd!
        #
        # Expected arguments
        #   $cmd    - The world command about to be sent
        #   $cage  - The highest priority profile's command cage
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $cmd, $cage, $check) = @_;

        # Check for improper arguments
        if (! defined $cmd || ! defined $cage || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processWorldCmd', @_);
        }

        # If the command hasn't already been marked as definitely NOT being a move...
        if ($self->moveMode ne 'not_move') {

            # Deal with redirect mode, if it is on
            if ($self->redirectString && $self->checkRedirect($cmd, $cage)) {

                # $newCmd was processed under redirect mode; $self->dispatchCmd has already been
                #   called, so move on to the next command
                return 1;

            # If assisted moves are turned on and the automapper knows the current location, deal
            #   with assisted moves (not if $newCmd was processed under redirect mode, because
            #   redirect mode takes priority over assisted moves)
            # (Actually, we don't check the current location, GA::Obj::Map->currentRoom, but the
            #   location where the automapper believes the character to be, once all previous world
            #   commands have been processed by the world, which is stored in
            #   GA::Obj::Map->ghostRoom)
            } elsif (
                $self->worldModelObj->assistedMovesFlag
                && $self->mapObj->ghostRoom
                && $self->checkAssistedMove($cmd, $cage)
            ) {
                # $newCmd was processed as an assisted move; $self->dispatchCmd has already been
                #   called, so move on to the next command
                return 1;
            }
        }

        # Otherwise, display the command in the 'main' window, update the world command buffer and
        #   any recording in progress, send the command to the world and fire any 'send_cmd' hooks
        $self->dispatchCmd($cmd, $cage);
        # When the session's status is 'offline', and when the automapper knows the current
        #   location, send the command to the automapper for processing (if it matches one of the
        #   room's exits, the character 'moves' using the exit)
        if ($self->status eq 'offline' && $self->mapObj->currentRoom) {

            $self->mapObj->pseudoWorldCmd();
        }

        return 1;
    }

    sub teleportCmd {

        # Called by GA::Cmd::Teleport->do (or by equivalent code)
        # This is a slimmed down version of $self->worldCmd and $self->processWorldCmd (combined
        #   into a single function), which processes a world command which is definitely a
        #   teleportation command (and whose destination room may or may not correspond to a world
        #   model room)
        #
        # Expected arguments
        #   $cmd        - A string containing the whole world command, e.g. 'teleport tower'. This
        #                   function assumes that it contains a single world command, so a string
        #                   like 'teleport tower;open door;in' should not be used
        #
        # Optional arguments
        #   $destRoom   - The world model destination room, if known ('undef' if not, in which case
        #                   the automapper will get lost)
        #
        # Return values
        #   'undef' on improper arguments or if $inputString is an empty string
        #   1 otherwise

        my ($self, $cmd, $destRoom, $check) = @_;

        # Local variables
        my ($cage, $bufferObj);

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->teleportCmd', @_);
        }

        # Update the instruction buffer
        $self->updateInstructBuffer($cmd, 'world');

        # Fire any hooks that are using the 'sending_cmd' hook event (the world command before
        #   any aliases have had a chance to modify it)
        $self->checkHooks('sending_cmd', $cmd);

        # (Teleport commands are exempt from any maximum number of commands specified by the
        #   world profile or by crawl mode, if enabled)

        # Get the highest-priority command cage
        $cage = $self->findHighestCage('cmd');

        # Create a command buffer object, with the command marked as a teleportation command
        $bufferObj = $self->updateCmdBuffer(
            $cmd,
            $cage,
            undef,
            undef,
            undef,
            undef,
            TRUE,           # This function is the one calling
            $destRoom,
        );

        # Display the command in the 'main' window, update the world command buffer and any
        #   recording in progress, send the command to the world and fire any 'send_cmd' hooks
        $self->dispatchCmd($cmd, $cage, $bufferObj);

        return 1;
    }

    sub send {

        # Can be called by anything
        # The function that normally sends commands to the world is $self->dispatchCmd. However,
        #   ->dispatchCmd carries out a few jobs, such as updating the world command buffer. In a
        #   few rare cases it is better to call this function instead, which simply sends the world
        #   command (and nothing else)
        # An example of this is $self->dispatchPassword, which calls this function when it wants to
        #   send a password; obviously, we don't want to display the unobscured password in the
        #   current textview, nor do we want to save it to the world command buffer
        #
        # Sends a string to the world. Unlike $self->put(), a newline character is added
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   @list   - A list of lines to send. If the list is empty, nothing is sent
        #
        # Return values
        # 1

        my ($self, @list) = @_;

        # (No improper arguments to check)

        # Don't send anything if the connection isn't open, or if no commands were specified
        if ($self->status eq 'connected' && @list) {

            $self->connectObj->print(@list);
        }

        return 1;
    }

    sub put {

        # Can be called by anything (currently only called by a ->signal_connect in
        #   GA::Win::Internal->setKeyPressEvent)
        # Sends a string to the world. Unlike $self->send(), no newline character is added
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $string     - The string to send. If undefined, nothing is sent
        #   $encodeFlag - If TRUE, $string is encoded using the session's current character set,
        #                   before being sent
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $string, $encodeFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->put', @_);
        }

        # Don't send anything if the connection isn't open, or if an undefined string was specified
        if ($self->status eq 'connected' && defined $string) {

            if ($encodeFlag) {
                $self->connectObj->put(Encode::encode($self->sessionCharSet, $string));
            } else {
                $self->connectObj->put($string);
            }
        }

        return 1;
    }

    # Interpolated / movement world commands

    sub prepareCmd {

        # Can be called by any code that wants to design a single command, that will work with any
        #   world whose command cage has been set up correctly
        # (Also called by $self->sendModCmd, which sends this function's return value as a world
        #   command)
        #
        # Command cages define a list of standard commands, e.g. 'look', 'put', 'jump_obj',
        #   'sell_all', 'rob_corpse', 'wave_victim'
        # The cage's ->cmdHash IV has these standard commands as keys; the corresponding values
        #   are the equivalent command on the world
        #
        # This function can interpolate any of the words in the corresponding command, based on the
        #   arguments it receives; a list in the form
        #       (standard_command, word, replacement, word, replacement...)
        # e.g. In a world, the standard command 'put' corresponds to the world command 'put object
        #   in target' - and so, this function would be called with the arguments
        #       ('put', 'object', 'jewel', 'target', 'box')
        # This function then returns the interpolated command, e.g.
        #       'put jewel in box'
        #
        # Expected arguments
        #   $cmd    - Matches a standard command in GA::Cage::Cmd->cmdHash
        #
        # Optional arguments
        #   @args   - List of arguments in the form
        #               (word, replacement, word, replacement...)
        #
        # Return values
        #   'undef' on improper arguments, if $cmd isn't a standard command in the command cage or
        #       if @args contains an odd number of elements
        #   Otherwise, returns the interpolated command (which might be an empty string, if the
        #       command cage states that the command isn't available on the world)

        my ($self, $cmd, @args) = @_;

        # Local variables
        my (
            $count, $modCmd,
            @wordList,
        );

        # Check for improper arguments
        $count = scalar @args;
        if (! defined $cmd || ($count > 0 && ($count / 2) != int ($count / 2))) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareCmd', @_);
        }

        # See if the command cages recognise $cmd as a standard command and, if so, get the
        #   equivalent world command from the highest-priority cage
        $modCmd = $self->findCmd($cmd);
        if (! defined $modCmd) {

            # Don't interpolate the command
            return undef;

        } else {

            # If there are any word-replacement pairs in @args, interpolate them into $modCmd
            OUTER: while (@args) {

                my ($word, $replacement);

                $word = shift @args;
                $replacement = shift @args;

                # If $word doesn't exist in world command provided by the cage, show a warning
                if (index($modCmd, $word) == -1) {

                    $self->writeWarning(
                        'Possible mis-spelled word-replacement pair \'' . $word . '\'/\''
                        . $replacement . '\' in standard command \'' . $cmd . '\'',
                        $self->_objClass . '->prepareCmd',
                    );
                }

                # Special case: if the replacement is 'undef', remove the word from $modCmd
                if (! defined $replacement) {

                    $modCmd =~ s/$word//;

                # Otherwise, substitute the word for its replacement
                } else {

                    $modCmd =~ s/$word/$replacement/;
                }
            }

            # Need to auto-interpolate any standard words (like 'all' or 'mall')
            @wordList = split(/\s/, $modCmd);
            foreach my $word (@wordList) {

                my $modWord = $self->findWord($word);
                if ($modWord) {

                    # Substitute the $word in $modCmd for its standard form
                    $modCmd =~ s/$word/$modWord/;
                }
            }

            # Remove any leading/trailing whitespace between words due to interpolation
            return $axmud::CLIENT->trimWhitespace($modCmd);
        }
    }

    sub sendModCmd {

        # Can be called by any task that wants to send a single command, that will work with any
        #   world whose command cage has been set up correctly
        # Calls $self->prepareCmd to interpolate the command, which produces a return value which
        #   can be sent as a world command to the current world (see the comments in
        #   $self->prepareCmd for more information about this process)
        #
        # Expected arguments
        #   $cmd    - Matches a standard command in GA::Cage::Cmd->cmdHash, e.g. 'get', 'move_dir',
        #               'loot_corpse'
        #
        # Optional arguments
        #   @args   - List of arguments in the form
        #               (word, replacement, word, replacement...)
        #
        # Return values
        #   'undef' on improper arguments, if $cmd isn't a standard command in the command cage, if
        #       @args contains an odd number of elements or if the interpolated command can't be
        #       sent to the world
        #   Otherwise, returns the interpolated command that was submitted to $self->worldCmd (which
        #       might have been modified by aliases before actually being sent, and which might be
        #       an empty string, if the command cage states that the command isn't available on the
        #       world)

        my ($self, $cmd, @args) = @_;

        # Local variables
        my ($count, $obscureString, $modCmd);

        # Check for improper arguments
        $count = scalar @args;
        if (! defined $cmd || ($count > 0 && ($count / 2) != int ($count / 2))) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendModCmd', @_);
        }

        # Special arrangements for the standard command 'connect'. If world commands are visible in
        #   the session's default textview, we don't want the password to be unboscured
        if ($cmd eq 'connect' && $self->currentChar) {

            # Value may be set to, or remain as, 'undef', in which case $self->worldCmd won't
            #   obscure anything
            $obscureString = $self->initPass;
        }

        # Interpolate the command
        $modCmd = $self->prepareCmd($cmd, @args);
        if ($modCmd) {

            # Send the world command
            if (! $self->worldCmd($modCmd, $obscureString)) {

                # Interpolated command could not be sent
                return undef;

            } else {

                # Return the command that was submitted (which may have been altered by aliases, and
                #   may be an empty string
                return $modCmd;
            }

        } else {

            # $cmd could not be interpolated
            return undef;
        }
    }

    sub relayCmd {

        # Can be called by any task that wants to send a single command WITHOUT having it
        #   interpolated (as $self->sendModCmd would do) and WITHOUT having it interpreted as a
        #   movement command (as $self->moveCmd would do)
        #
        # e.g. If the command cage translates the standard command 'put' into a world command
        #   'put object in target', and if we want to send the world command 'put off my appointment
        #   in the hospital until tomorrow', then we obviously don't want the command to be
        #   interpolated
        # e.g. If the command cage translates the standard command 'go' into a world command
        #   'go direction', and if we want to send the the world command 'go f*** yourself', we
        #   obviously don't want it treated as a movement command
        #
        # Expected arguments
        #   $cmd    - The command to send
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns $cmd

        my ($self, $cmd, $check) = @_;

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->relayCmd', @_);
        }

        # Briefly set an IV to inform GA::Buffer::Cmd->interpretCmd that this is definitely NOT a
        #   movement command
        $self->ivPoke('moveMode', 'not_move');

        # Send the command
        $self->worldCmd($cmd);

        # Reset the IV to its normal state
        $self->ivPoke('moveMode', 'unknown');

        return $cmd;
    }

    sub moveCmd {

        # Can be called by any task that wants to send a single command WITHOUT having it
        #   interpolated (as $self->sendModCmd would do) and which isn't obviously a movement
        #   command, but which definitely is one (e.g. 'opening', 'jump', etc)
        #
        # Expected arguments
        #   $cmd    - The command to send
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns $cmd

        my ($self, $cmd, $check) = @_;

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->moveCmd', @_);
        }

        # Briefly set an IV to inform GA::Buffer::Cmd->interpretCmd that this is definitely a
        #   movement command
        $self->ivPoke('moveMode', 'is_move');

        # Send the command
        $self->worldCmd($cmd);

        # Reset the IV to its normal state
        $self->ivPoke('moveMode', 'unknown');

        return $cmd;
    }

    # Normal world commands / redirect mode commands / assisted moves

    sub dispatchCmd {

        # Called by $self->worldCmd after aliases have been checked to actually send a command to
        #   the world
        # Also called by $self->checkRedirect and ->checkAssistedMove, and directly by
        #   a ->signal_connect in GA::Strip::Entry->setEntrySignals in special echo mode
        #
        # Displays the command in the current textview, updates the command buffer and any recording
        #   in progress, actually sends the command and fires any 'send_cmd' hooks
        # NB If $self->disableWorldCmdFlag has been set to TRUE temporarily (by ';simulatecommand'),
        #   everything happens as normal, but the command is not actually sent to the world
        #
        # Expected arguments
        #   $cmd            - The world command to send
        #
        # Optional arguments
        #   $cage           - The highest-priority command cage. If 'undef', this function fetches
        #                       the cage
        #   $bufferObj      - The GA::Buffer::Cmd already created when the calling function is
        #                       ->checkRedirect or ->checkAssistedMove, in which case this function
        #                       doesn't need to create another one
        #
        # Optional arguments
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $cmd, $cage, $bufferObj, $check) = @_;

        # Local variables
        my (
            $encodeCmd, $decodeCmd, $stripCmd,
            @charList,
        );

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->dispatchCmd', @_);
        }

        # Fetch the highest-priority command cage, if the calling function didn't specify it
        if (! $cage) {

            $cage = $self->findHighestCage('cmd');
        }

        # Deal with non-standard charsets
        if (
            $self->sessionCharSet ne $axmud::CLIENT->constCharSet
            && $self->sessionCharSet ne 'null'
        ) {
            $encodeCmd = Encode::encode($self->sessionCharSet, $cmd);
            $decodeCmd = Encode::decode($self->sessionCharSet, $encodeCmd);

        } else {

            $encodeCmd = $cmd;
            $decodeCmd = $cmd;
        }

        # If ECHO is turned on, only update a recording after $self->doLogin is called
        if (
            $self->echoMode ne 'client_agree'
            || $self->specialEchoMode eq 'enabled'
            || $self->specialEchoMode eq 'disabled'
        ) {
            # If there's a recording in progress, store the command about to be sent
            if ($self->recordingFlag && ! $self->recordingPausedFlag) {

                # If there's a defined insertion point, insert the command there
                if (
                    defined $self->recordingPosn
                    && $self->recordingPosn <= scalar $self->recordingList
                ) {
                    # Insert the command at the insertion point
                    $self->ivSplice(
                        'recordingList',
                        $self->recordingPosn,
                        0,
                        '> ' . $cmd,
                    );

                    # The new insertion point is the line after this one
                    $self->ivIncrement('recordingPosn');

                } else {

                    # No insertion point specified, or the insertion point is at a line number
                    #   that's bigger than the size of the recording, so $newCmd must be added
                    #   to the end of the recording
                    $self->ivPush('recordingList', '> ' . $cmd);
                    # In case $self->recordingPosn was outside the list, make sure future
                    #   commands are just added to the end of the list
                    $self->ivUndef('recordingPosn');
                }
            }
        }

        # If ECHO is turned on, don't display the command in the textview (at any time)
        if ($self->echoMode ne 'client_agree') {

            # Display the command in the current textview object
            if ($self->disableWorldCmdFlag && $axmud::CLIENT->confirmWorldCmdFlag) {

                # During a ';simulatecommand' client command, don't send the world command
                $self->writeText('SIM CMD: ' . $cmd);

            } elsif ($axmud::CLIENT->confirmWorldCmdFlag) {

                $self->currentTabObj->textViewObj->insertCmd($decodeCmd);

            } elsif ($self->promptFlag) {

                # Sending a newline character cancels any prompt; even if the world command
                #   isn't explicitly echoed in the textview, the newline must be
                $self->currentTabObj->textViewObj->insertCmd('');
            }
        }

        # If ECHO is turned on, only update the world command buffer after $self->doLogin is called
        if (
            $self->echoMode ne 'client_agree'
            || $self->specialEchoMode eq 'enabled'
            || $self->specialEchoMode eq 'disabled'
        ) {
            # Update the world command buffer (but not for redirect mode commands or assisted moves,
            #   as the buffer has already been updated)
            if (! $bufferObj) {

                $self->updateCmdBuffer($cmd, $cage);
            }
        }

        # If $self->promptFlag is set, the most recently-received text is a command prompt
        # A world command, displayed in the 'main' window's default textview, requires a newline
        #   character in that textview; but we only add a newline character to the received text
        #   (stored in the display buffer) if that received text ends in a command prompt
        # (If we added a newline character all the time, a vital line in a room statement might be
        #   split in two, and then the Locator task won't be able to read it and the automapper will
        #   get lost)
        # Exception - we don't insert a newline into the display buffer if echo mode is turned on;
        #   that's the world's responsibility
        if ($self->promptFlag) {

            $self->ivPoke('promptFlag', FALSE);
            $self->ivPoke('promptInsertFlag', FALSE);

            if ($self->displayBufferCount && $self->echoMode ne 'client_agree') {

                $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
                if ($bufferObj && ! $bufferObj->newLineFlag) {

                    $bufferObj->ivPoke('newLineFlag', TRUE);
                }
            }
        }

        # (Reset this IV in either case)
        $self->ivUndef('promptCheckTime');

        # If the connection is open, send the command to the world
        if ($self->status eq 'connecting' || $self->status eq 'connected') {

            # (Unless we're in the middle of a ';simulatecommand' operation)
            if (! $self->disableWorldCmdFlag) {

                # In special echo mode just send a newline character
                if ($self->specialEchoMode eq 'enabled') {

                    $stripCmd = '';

                # Telnet specifies that only US-ASCII is allowed. Filter out everything else
                } elsif (
                    $self->sessionCharSet ne $axmud::CLIENT->constCharSet
                    && $self->sessionCharSet ne 'null'
                ) {
                    # Exception - if using a non-standard character set, trust the Perl Encode
                    #   module to take care of that stuff
                    $stripCmd = $encodeCmd;

                } else {

                    $stripCmd = '';
                    foreach my $char (split(//, $cmd)) {

                        if (ord($char) >= 0 && ord($char) <= 127) {

                            $stripCmd .= $char;
                        }
                    }
                }

                # If MCP is enabled, in-band lines starting either '#$#' or '#$"' must be quoted,
                #   before being sent to the world
                if (substr($stripCmd, 0, 3) eq '#$#' || substr($stripCmd, 0, 3) eq '#$"') {

                    # Quote the in-band line by preceding it with '#$"'
                    $stripCmd = '#$"' . $stripCmd;
                }

                # Send the command to the world
                # Occasionally encounter an error in which this function was called to send a world
                #   command to a GA::Obj::Telnet object whose filehandle had just closed, so need
                #   to check for that
                if (! $self->connectObj->eof()) {

                    $self->connectObj->print($stripCmd);
                }

                # Turn on the window blinker, and update IVs
                $self->turnOnBlinker(2);
            }

            $self->ivIncrement('excessCmdCount');

            # Fire any hooks that are using the 'send_cmd' hook event
            $self->checkHooks('send_cmd', $cmd);
        }

        # Update the connection info strip object for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            # Update information stored in each 'internal' window's connection info strip, if
            #   visible
            $winObj->setTimeLabel($self->getTimeLabelText());
        }

        # Convert text to speech, if required
        if ($axmud::CLIENT->systemAllowTTSFlag && $axmud::CLIENT->ttsWorldCmdFlag) {

            # Make sure the received text is visible in the 'main' window...
            $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->dispatchCmd');

            # ...before converting text to speech
            if (
                $axmud::CLIENT->ttsVerboseFlag
                && defined $self->session->ttsLastType
                && $self->session->ttsLastType ne 'command'
            ) {
                # Last TTS conversion was something other than a world command
                $axmud::CLIENT->tts('Sent: ' . $cmd, 'command', 'command', $self);

            } else {

                # (Don't read out 'sent' again and again and again!
                $axmud::CLIENT->tts($cmd, 'command', 'command', $self);
            }
        }

        # Write to logs
        $axmud::CLIENT->writeLog(
            $self,
            TRUE,                           # Not world-specific logs
            $cmd,
            FALSE,                          # Don't precede with a newline character
            TRUE,                           # Use final newline character
            'main',                         # Write to these files
        );

        return 1;
    }

    sub dispatchPassword {

        # Called by $self->worldCmd
        # Sends a command to the world which should be obscured in the current textview, because it
        #   contains a password. Unlike in a call to $self->dispatchCmd, the command is not recorded
        #   (if a recording is in progress), nor is it stored in any buffer or tested for aliases or
        #   hooks
        # NB If $self->echoMode is set to 'client_agree', nothing is displayed in the 'main' window
        #   at all (as usual)
        #
        # Expected arguments
        #   $inputString    - A string containing the whole world command, e.g. 'kill orc'
        #   $obscureString  - A substring in $inputString. The substring is replaced in the 'main'
        #                       window by asterisks, which obscures the password
        #
        # Return values
        #   'undef' on improper arguments or if $inputString is an empty string
        #   1 otherwise

        my ($self, $inputString, $obscureString, $check) = @_;

        # Local variables
        my $bufferObj;

        # Check for improper arguments
        if (! defined $inputString || ! defined $obscureString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->dispatchPassword', @_);
        }

        # Deal with non-standard charsets
        if (
            $self->sessionCharSet ne $axmud::CLIENT->constCharSet
            && $self->sessionCharSet ne 'null'
        ) {
            $inputString = Encode::encode($self->sessionCharSet, $inputString);
        }

        # If the connection is open, send the command to the world
        if ($self->status eq 'connecting' || $self->status eq 'connected') {

            # If MCP is enabled, in-band lines starting either '#$#' or '#$"' must be quoted,
            #   before being sent to the world
            if (substr($inputString, 0, 3) eq '#$#' || substr($inputString, 0, 3) eq '#$"') {

                # Quote the in-band line by preceding it with '#$"'
                $self->send('#$"' . $inputString);

            } else {

                $self->send($inputString);
            }
        }

        # Obscure the command in the current textview (but if the server has suggested that the
        #   client stop ECHOing, and the client has agreed, don't show anything in the current
        #   textview)
        if ($axmud::CLIENT->confirmWorldCmdFlag) {

            if ($self->echoMode ne 'client_agree') {

                $inputString =~ s/$obscureString/\*\*\*\*\*\*\*\*/g;
                $self->currentTabObj->textViewObj->insertCmd($inputString);

            } else {

                # Officially, telnet clients are not supposed to insert a newline character when the
                #   server suggests the client stop ECHOing, and the client agrees. However, many
                #   worlds fail to supply their own newline character as they're supposed to. Set a
                #   flag that tells $self->processIncomingData to insert a newline character, if the
                #   packet it processes doesn't begin with one
                $self->ivPoke('nlEchoFlag', TRUE);
            }

        } elsif ($self->promptFlag) {

            # Sending a newline character cancel any prompt; even if the world command isn't
            #   explicitly echoed in the textview, the newline must be
            $self->currentTabObj->textViewObj->insertCmd('');
        }

        # If $self->promptFlag is set, the most recently-received text is a command prompt
        # A world command, displayed in the 'main' window's default textview, requires a newline
        #   character in that textview; but we only add a newline character to the received text
        #   (stored in the display buffer) if that received text ends in a command prompt
        # (If we added a newline character all the time, a vital line in a room statement might be
        #   split in two, and then the Locator task won't be able to read it and the automapper will
        #   get lost)
        # Exception - we don't insert a newline into the display buffer if echo mode is turned on;
        #   that's the world's responsibility
        if ($self->promptFlag) {

            $self->ivPoke('promptFlag', FALSE);
            $self->ivPoke('promptInsertFlag', FALSE);

            if ($self->displayBufferCount && $self->echoMode ne 'client_agree') {

                $bufferObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
                if ($bufferObj && ! $bufferObj->newLineFlag) {

                    $bufferObj->ivPoke('newLineFlag', TRUE);
                }
            }
        }

        # (Reset this IV in either case)
        $self->ivUndef('promptCheckTime');

        # Turn on the window blinker, and update IVs
        $self->turnOnBlinker(2);

        # Update the connection info strip object for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            # Update information stored in each 'internal' window's connection info strip, if
            #   visible
            $winObj->setTimeLabel($self->getTimeLabelText());
        }

        # Convert text to speech, if required
        if ($axmud::CLIENT->systemAllowTTSFlag && $axmud::CLIENT->ttsWorldCmdFlag) {

            # Make sure the received text is visible in the 'main' window...
            $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->dispatchPassword');
            # ...before converting text to speech
            $axmud::CLIENT->tts('Sent password', 'command', 'command', $self);
        }

        return 1;
    }

    sub checkRedirect {

        # Called by $self->worldCmd to see whether a command should be processed in redirect mode.
        #   If so, processes the command; otherwise returns 'undef' so that the command can be
        #   processed as normal
        # The calling function has already checked that redirect mode is on
        #
        # Expected arguments
        #   $cmd        - The command to check
        #   $cage      - The highest-priority command cage
        #
        # Return values
        #   'undef' on improper arguments or if $cmd cannot be processed in redirect mode
        #   1 if the $cmd is processed in redirect mode

        my ($self, $cmd, $cage, $check) = @_;

        # Local variables
        my ($dirType, $flag, $redirectString, $bufferObj);

        # Check for improper arguments
        if (! defined $cmd || ! defined $cage || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkRedirect', @_);
        }

        # See if $cmd matches any recognised primary and/or secondary directions
        $dirType = $self->currentDict->ivShow('combDirHash', $cmd);

        # Redirect mode 'primary_only' - redirect primary directions
        if (
            $self->redirectMode eq 'primary_only'
            && defined $dirType
            && ($dirType eq 'primaryDir' || $dirType eq 'primaryAbbrev')
        ) {
            $flag = TRUE;

        # Redirect mode 'primary_secondary' - redirect primary and secondary directions (but not
        #   relative directions)
        } elsif (
            $self->redirectMode eq 'primary_secondary'
            && defined $dirType
            && $dirType ne 'relativeDir'
            && $dirType ne 'relativeAbbrev'
        ) {
            $flag = TRUE;

        # Redirect mode 'all_exits' - redirect primary and secondary directions, plus any command
        #   matching an exit in the current room (if set; actually the automapper's ->ghostRoom)
        } elsif ($self->redirectMode eq 'all_exits') {

            if (defined $dirType) {

                $flag = TRUE;

            } elsif (
                $self->mapObj
                && $self->mapObj->ghostRoom
                && $self->mapObj->ghostRoom->ivExists('exitNumHash', $cmd)
            ) {
                $flag = TRUE;
            }
        }

        # If redirect mode should be applied...
        if ($flag) {

            # Import the redirect string, in the form 'sail @', where every occurence of @ is
            #   replace by $cmd
            $redirectString = $self->redirectString;
            # The string should contain a @ character but, if for some reason it doesn't...
            if (! ($redirectString =~ m/@/)) {

                # ...then don't process $cmd as a redirect command
                return undef;
            }

            # Perform the (global) substitution
            $redirectString =~ s/@/$cmd/g;

            # Update the world command buffer
            $bufferObj = $self->updateCmdBuffer($cmd, $cage, $redirectString);

            # Send the modified world command
            $self->dispatchCmd($redirectString, $cage, $bufferObj);
            # When the session's status is 'offline', and when the automapper knows the current
            #   location, send the command to the automapper for processing (if it matches one of
            #   the room's exits, the character 'moves' using the exit)
            if ($self->status eq 'offline' && $self->mapObj->currentRoom) {

                $self->mapObj->pseudoWorldCmd($bufferObj);
            }

            return 1;

        } else {

            # $cmd was not processed in redirect mode
            return undef;
        }
    }

    sub checkAssistedMove {

        # Called by $self->worldCmd to see whether a command should be treated as an assisted move.
        #   If so, processes the assisted move; otherwise returns 'undef' so that the command can
        #   be processed as normal
        # The calling function has already checked that the command shouldn't be processed under
        #   redirect mode (which takes priority over assisted moves), that assisted moves are turned
        #   on, and that the automapper knows the current location
        #
        # If both assisted moves and protected moves are turned on, and if the automapper knows the
        #   character's current location, refuse to process an assisted move which leads to an
        #   unknown room (or in a direction for which there is no world model exit)
        #
        # Expected arguments
        #   $cmd        - The command to check
        #   $cage       - The highest-priority command cage
        #
        # Return values
        #   'undef' on improper arguments or if $cmd cannot be processed as an assisted move
        #   1 if the $cmd is processed as an assisted move

        my ($self, $cmd, $cage, $check) = @_;

        # Local variables
        my (
            $cmdSep, $dictObj, $currentObj, $taskRoomObj, $ghostObj, $standard, $exitObj, $assisted,
            $relativeDir, $bufferObj,
        );

        # Check for improper arguments
        if (! defined $cmd || ! defined $cage || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkAssistedMove', @_);
        }

        # Import the standard command separator (to use in a regex) and the current dictionary (for
        #   convenience)
        $cmdSep = $axmud::CLIENT->cmdSep;
        $dictObj = $self->currentDict;

        # GA::Obj::Map->currentRoom stores the automapper's current location, based on room
        #   statements received by the Locator task. However, if previously-sent world commands have
        #   not yet been processed by the world - specifically, if we type 'north' 10 times and
        #   the world hasn't yet sent ten room statements in reply - ->currentRoom will be out of
        #   date
        $currentObj = $self->mapObj->currentRoom;
        # GA::Obj::Map->ghostRoom stores the character's current location, based on the commands
        #   actually sent; we check this IV, not ->currentRoom
        # Import the ghost room
        $ghostObj = $self->mapObj->ghostRoom;
        # Also import the Locator task's current room, in case it's requried
        $taskRoomObj = $self->session->locatorTask->roomObj;
        # Check whether $cmd is a custom primary direction (if so, we can substitute the user's
        #   primary direction command, like 'n', for the equivalent assisted move, e.g. 'enter
        #   cave')
        $standard = $self->currentDict->convertStandardDir($cmd);

        # Find a matching exit
        OUTER: foreach my $thisExitNum ($ghostObj->ivValues('exitNumHash')) {

            my (
                $thisExitObj, $thisAssisted,
                @cmdList,
            );

            $thisExitObj = $self->worldModelObj->ivShow('exitModelHash', $thisExitNum);

            if ($standard) {

                # If the user typed 'n' and there's an exit which uses it as its ->mapDir, then
                #   that's the matching exit
                if ($thisExitObj->mapDir && $thisExitObj->mapDir eq $standard) {

                    $exitObj = $thisExitObj;
                    $assisted = $exitObj->getAssisted($self);       # May be 'undef'
                    last OUTER;
                }

            } else {

                $thisAssisted = $thisExitObj->getAssisted($self);
                if ($thisAssisted) {

                    # If the user typed 'enter cave' and there's an exit which uses it as an
                    #   assisted move, then that's the matching exit
                    if ($thisAssisted eq $cmd) {

                        $exitObj = $thisExitObj;
                        $assisted = $thisAssisted;
                        $standard = $thisExitObj->mapDir;
                        last OUTER;

                    # If the user typed 'enter cave' and there's an exit which uses an assisted move
                    #   consisting of two or more world command, and 'enter cave' is the last one,
                    #   then that's the matching exit
                    } else {

                        @cmdList = split(m/$cmdSep/, $thisAssisted);
                        if (@cmdList > 1 && $cmdList[-1] eq $cmd) {

                            $exitObj = $thisExitObj;
                            $assisted = $thisAssisted;
                            $standard = $thisExitObj->mapDir;
                            last OUTER;
                        }
                    }
                }
            }
        }

        # If a matching exit was found...
        if ($exitObj) {

            # If protected moves are turned on, don't allow a move through an impassable or myster
            #   exit
            if (
                $self->worldModelObj->protectedMovesFlag
                && ($exitObj->exitOrnament eq 'impass' || $exitObj->exitOrnament eq 'mystery')
            ) {
                if ($exitObj->exitOrnament eq 'impass') {

                    $self->writeText(
                        'PROTECTED MOVES: \'' . $cmd . '\' uses an impassable exit, so it has been'
                        . ' blocked (use \';setprotectedmoves off\' to stop these messages)',
                    );

                } else {

                    $self->writeText(
                        'PROTECTED MOVES: \'' . $cmd . '\' uses a mystery exit, so it has been'
                        . ' blocked (use \';setprotectedmoves off\' to stop these messages)',
                    );
                }

                if ($self->worldModelObj->superProtectedMovesFlag) {

                    # Temporarily set a flag, so that $self->worldCmd and ->spinMaintainLoop know
                    #   that they should stop processing world commands)
                    $self->ivPoke('overruleMoveFlag', TRUE);
                }

                # (By returning 1, we signal that this move was process as an assisted move - even
                #   though the move was blocked. The world command is not added to the command
                #   buffer.)
                return 1;
            }

            if (
                ! $assisted
                && $standard       # Not set if $exitObj->mapDir is 'undef'
                && $self->currentDict->ivShow('primaryDirHash', $standard) eq $exitObj->dir
            ) {
                # If the current dictionary contains relative directions, and if the current and
                #   ghost rooms are the same, then we can potentially convert a primary direction
                #   like 'north' into a relative direction like 'forward'
                if (
                    ($dictObj->relativeDirHash || $dictObj->relativeAbbrevHash)
                    && $self->mapObj->facingDir
                    && $taskRoomObj
                    && $currentObj
                    && $ghostObj
                    && $currentObj eq $ghostObj
                ) {
                    # Given the direction the character is about to moving, and the direction
                    #   they're facing, get the equivalent relative direction
                    $relativeDir = $dictObj->fetchRelativeDir($standard, $self->mapObj->facingDir);
                    if (
                        defined $relativeDir
                        && $taskRoomObj->ivExists('exitNumHash', $relativeDir)
                    ) {
                        $assisted = $relativeDir;
                    }
                }

                if (! $assisted) {

                    # No need to process this as an assisted move - the user typed something
                    #   like 'north' (or 'n'), and the exit's nominal direction is north
                    return undef;
                }

           } elsif (! $assisted) {

                # Use the exit's nominal direction, which might be something completely different,
                #   e.g. 'enter cave'
                $assisted = $exitObj->dir;
           }

           # Update the world command buffer
           $bufferObj = $self->updateCmdBuffer(
                $cmd,
                $cage,
                undef,          # Not a redirect mode command
                $standard,
                $assisted,
                $exitObj,
            );

            # Send world commands, one by one
            foreach my $worldCmd ($bufferObj->assistedList) {

                # (The TRUE argument means this is an assisted move)
                $self->dispatchCmd($worldCmd, $cage, $bufferObj);
            }

            # When the session's status is 'offline', and when the automapper knows the current
            #   location, send the command to the automapper for processing (if it matches one of
            #   the room's exits, the character 'moves' using the exit)
            if ($self->status eq 'offline' && $self->mapObj->currentRoom) {

                $self->mapObj->pseudoWorldCmd($bufferObj);
            }

            # This command was processed as an assisted move
            return 1;

        } elsif (
            $standard
            && $self->worldModelObj->protectedMovesFlag
            # Protected moves mode is not required for rooms in wilderness mode
            && $ghostObj->wildMode eq 'normal'
        ) {
            $self->writeText(
                'PROTECTED MOVES: \'' . $cmd . '\' does not correspond to a world model exit, so it'
                . ' has been blocked (use \';setprotectedmoves off\' to stop these messages)',
            );

            if ($self->worldModelObj->superProtectedMovesFlag) {

                # Temporarily set a flag, so that $self->worldCmd and ->spinMaintainLoop know that
                #   they should stop processing world commands)
                $self->ivPoke('overruleMoveFlag', TRUE);
            }

            # (By returning 1, we signal that this move was process as an assisted move - even
            #   though the move was blocked. The world command is not added to the command buffer.)
            return 1;

        } else {

            # $cmd was not processed as an assisted move
            return undef;
        }
    }

    sub navigateBuffer {

        # Called by ->signal_connect in GA::Win::Internal when the user presses the 'up'/'down'
        #   arrow keys and $axmud::CLIENT->autoCompleteMode is set to 'auto'
        # Decides which instruction/world command should be displayed in the window's command entry
        #   box next, and returns the corresponding buffer object
        # The 'up' arrow returns the buffer object before the one last returned by this function (or
        #   the last buffer object altogether, if the position IV is 'undef'). The 'down' arrow
        #   returns the buffer object after the last one returned by this function
        # Updates client or session IVs accordingly
        #
        # Expected arguments
        #   $keycode    - 'up' or 'down'
        #
        # Return values
        #   'undef' on improper arguments, if the appropriate buffer is empty or if there's an error
        #   Otherwise returns a buffer object (GA::Buffer::Instruct or GA::Buffer::Cmd)

        my ($self, $keycode, $check) = @_;

        # Local variables
        my ($first, $last, $posn, $bufferObj);

        # Check for improper arguments
        if (! defined $keycode || ($keycode ne 'up' && $keycode ne 'down') || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->navigateBuffer', @_);
        }

        # Import IVs from the correct buffer object registry
        if ($axmud::CLIENT->autoCompleteType eq 'instruct') {

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                $first = $axmud::CLIENT->instructBufferFirst;
                $last = $axmud::CLIENT->instructBufferLast;
                $posn = $axmud::CLIENT->instructBufferPosn;

            } else {

                $first = $self->instructBufferFirst;
                $last = $self->instructBufferLast;
                $posn = $self->instructBufferPosn;
            }

        } else {

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                $first = $axmud::CLIENT->cmdBufferFirst;
                $last = $axmud::CLIENT->cmdBufferLast;
                $posn = $axmud::CLIENT->cmdBufferPosn;

            } else {

                $first = $self->cmdBufferFirst;
                $last = $self->cmdBufferLast;
                $posn = $self->cmdBufferPosn;
            }
        }

        if (! defined $first) {

            # Buffer is still empty
            return undef;

        } elsif (! defined $posn) {

            # Use the last-but-one instruction/world command in the buffer
            $posn = $last - 1;

        } elsif ($keycode eq 'up') {

            # Up arrow
            if ($posn <= $first) {

                # Use the first instruction/world command in the buffer
                $posn = $first;

            } else {

                # Use the previous instruction/world command in the buffer
                $posn--;
            }

        } else {

            # Down arrow
            if ($posn >= $last) {

                # Use the last instruction/world command in the buffer
                $posn = $last;

            } else {

                # Use the next instruction/world command in the buffer
                $posn++;
            }
        }

        # Store the new position
        if ($axmud::CLIENT->autoCompleteType eq 'instruct') {

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                $axmud::CLIENT->set_instructBufferPosn($posn);
                $bufferObj = $axmud::CLIENT->ivShow('instructBufferHash', $posn);

            } else {

                $self->ivPoke('instructBufferPosn', $posn);
                $bufferObj = $self->ivShow('instructBufferHash', $posn);
            }

        } else {

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                $axmud::CLIENT->set_cmdBufferPosn($posn);
                $bufferObj = $axmud::CLIENT->ivShow('cmdBufferHash', $posn);

            } else {

                $self->ivPoke('cmdBufferPosn', $posn);
                $bufferObj = $self->ivShow('cmdBufferHash', $posn);
            }
        }

        return $bufferObj;
    }

    sub autoCompleteBuffer {

        # Called by ->signal_connect in GA::Win::Internal when the user presses the 'tab' key and
        #   $axmud::CLIENT->autoCompleteMode is set to 'auto'
        # Decides which instruction/world command should be displayed in the window's command entry
        #   box next, and returns the corresponding buffer object
        # The buffer is searched for the instruction/world command which is longer ('down' arrow)
        #   than one currently visible in the command entry box
        # Updates client or session IVs accordingly
        #
        # Expected arguments
        #   $currentText    - The text currently displayed in the command entry box (may be an empty
        #                       string)
        #   $originalText   - GA::Strip::Entry->originalEntryText for the command entry box, set the
        #                       first time the 'up'/'down' arrow keys are pressed, and reset when
        #                       the user presses RETURN
        #
        # Return values
        #   'undef' on improper arguments or if there's an error
        #   Otherwise returns a buffer object (GA::Buffer::Instruct or GA::Buffer::Cmd)

        my ($self, $currentText, $originalText, $check) = @_;

        # Local variables
        my (
            $iv, $first, $last, $matchNum, $matchString, $longMatchNum, $longMatchString,
            %bufferHash,
        );

        # Check for improper arguments
        if (! defined $currentText || ! defined $originalText || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->autoCompleteBuffer', @_);
        }

        # Import IVs from the correct buffer object registry
        if ($axmud::CLIENT->autoCompleteType eq 'instruct') {

            $iv = 'instruct';

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                %bufferHash = $axmud::CLIENT->instructBufferHash;
                $first = $axmud::CLIENT->instructBufferFirst;
                $last = $axmud::CLIENT->instructBufferLast;

            } else {

                %bufferHash = $self->instructBufferHash;
                $first = $self->instructBufferFirst;
                $last = $self->instructBufferLast;
            }

        } else {

            $iv = 'cmd';

            if ($axmud::CLIENT->autoCompleteParent eq 'combined') {

                %bufferHash = $axmud::CLIENT->cmdBufferHash;
                $first = $axmud::CLIENT->cmdBufferFirst;
                $last = $axmud::CLIENT->cmdBufferLast;

            } else {

                %bufferHash = $self->cmdBufferHash;
                $first = $self->cmdBufferFirst;
                $last = $self->cmdBufferLast;
            }
        }

        # Check every line in the instruction buffer, looking for an instruction which starts with
        #   $currentText, and continues with one or more extra characters
        if (%bufferHash) {

            for (my $num = $last; $num >= $first; $num--) {

                my ($bufferObj, $string, $diff);

                $bufferObj = $bufferHash{$num};
                if ($bufferObj) {

                    $string = $bufferObj->$iv;
                    $diff = length($string) - length($currentText);

                    if (
                        (
                            $diff > 0
                            && $string ne $currentText
                            && substr($string, 0, length($originalText)) eq $originalText
                        ) || (
                            $diff == 0
                            && $string ne $currentText
                            && $string gt $currentText
                        )
                    ) {
                        # Potential match found. Use it if...
                        if (
                            # It's the first match found
                            ! defined $matchString
                            # It is shorter than the previous matched string
                            || length($string) < length($matchString)
                            # In alphabetical order, it appears earlier than the matched string
                            || (
                                length($string) == length($matchString)
                                && $string lt $matchString
                            )
                        ) {
                            $matchString = $string;
                            $matchNum = $num;
                        }
                    }
                }
            }
        }

        if (! defined $matchString) {

            # No matches found; use an empty string in the command entry box
                return undef;

        } else {

            return $bufferHash{$matchNum};
        }
    }

    # Buffers

    sub updateDisplayBuffer {

        # Called by $self->processLineSegment when a complete line of text has been received from
        #   the world and displayed in a textview
        # Updates the display buffer
        #
        # Expected arguments
        #   $line           - The original line of text received from the world
        #   $stripLine      - $line after being stripped of escape sequences
        #   $modLine        - $stripLine after being modified by any matching interfaces (identical
        #                       to $stripLine if none match)
        #   $newLineFlag    - TRUE if $line ends with a newline character, FALSE if it doesn't
        #   $offsetListRef  - Reference to a sorted list containing the offsets (positions in
        #                       $modLine) at which escape sequences occured, before they were
        #                       stripped away
        #   $offsetHashRef  - Reference to a hash in the form
        #                       $tagHash{offset} = reference_to_list_of_colour_and_style_tags
        #                   - Each offset represents the position of a character in $modLine
        #                   - Axmud colour and style tags each correspond to an escape sequence
        #   $appliedListRef - Reference to a list of Axmud colour/style tags that actually applied
        #                       at the beginning of the line (may be an empty list)
        #   $mxpFlagTextHashRef
        #                   - Reference to the contents of $self->mxpFlagTextStoreHash, just before
        #                       it was reset (may be an empty hash)
        #
        # Return values
        #   'undef' on improper arguments, if the session is not connected to a world or if the
        #       buffer can't be updated
        #   Otherwise returns the new buffer object created (or the existing buffer object
        #       modified)

        my (
            $self, $line, $stripLine, $modLine, $newLineFlag, $offsetListRef, $offsetHashRef,
            $appliedListRef, $mxpFlagTextHashRef, $check,
        ) = @_;

        # Local variables
        my (
            $lastObj, $thisObj,
            %tagHash,
        );

        # Check for improper arguments
        if (
            ! defined $line || ! defined $stripLine || ! defined $modLine || ! defined $newLineFlag
            || ! defined $offsetListRef || ! defined $offsetHashRef || ! defined $appliedListRef
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateDisplayBuffer', @_);
        }

        # Don't update the buffer after a disconnection (but do update it in 'connect offline'
        #   mode); in addition, only text displayed in the default tab is added to the display
        #   buffer
        if ($self->status eq 'disconnected' || $self->currentTabObj ne $self->defaultTabObj) {

            return undef;
        }

        if (! defined $self->displayBufferFirst) {

            # This the first line ever received
            $self->ivPoke('displayBufferFirst', 0);
        }

        # Use $offsetListRef and $offsetHashRef to compile a new hash of Axmud colour/style tags.
        #   The new hash is in the form
        #   $tagHash{tag} = reference_to_list_of_offsets_where_the_tag_occurs
        foreach my $offset (@$offsetListRef) {

            my $tagListRef = $$offsetHashRef{$offset};

            foreach my $tag (@$tagListRef) {

                my $newListRef;

                if (exists $tagHash{$tag}) {

                    $newListRef = $tagHash{$tag};
                    push (@$newListRef, $offset);

                } else {

                    $tagHash{$tag} = [$offset];
                }
            }
        }

        # If the previous received line didn't end with a newline character, we add this
        #   line to its GA::Buffer::Display object. Otherwise, we create a new
        #   GA::Buffer::Display object
        if ($self->displayBufferCount) {

            $lastObj = $self->ivShow('displayBufferHash', $self->displayBufferLast);
        }

        if (! $lastObj || $lastObj->newLineFlag) {

            # Previous line did end with a newline character (or this is the first line in the
            #   buffer)

            # Create a new buffer object for this line
            $thisObj = Games::Axmud::Buffer::Display->new(
                $self,
                'session',
                $self->displayBufferCount,
                $line,
                $stripLine,
                $modLine,
                $self->sessionTime,
                $newLineFlag,
                $offsetHashRef,
                \%tagHash,
                $appliedListRef,
                $mxpFlagTextHashRef,
            );

            if (! $thisObj) {

                return undef;

            } else {

                # Update the display buffer
                $self->ivAdd('displayBufferHash', $thisObj->number, $thisObj);
                $self->ivIncrement('displayBufferCount');
                $self->ivPoke('displayBufferLast', ($self->displayBufferCount - 1));

                # If the buffer is full, remove the oldest line
                if ($self->displayBufferCount > $axmud::CLIENT->customDisplayBufferSize) {

                    $self->ivDelete('displayBufferHash', $self->displayBufferFirst);
                    $self->ivIncrement('displayBufferFirst');
                }
            }

        } else {

            # Previous line didn't end with a newline character. Append the new text to the
            #   previous line
            $lastObj->update(
                $line,
                $stripLine,
                $modLine,
                $newLineFlag,
                $offsetHashRef,
                \%tagHash,
                $mxpFlagTextHashRef,
            );
        }

        # Set the time at which text was most recently received from the world and displayed in the
        #   default tab
        if ($self->defaultTabObj eq $self->currentTabObj) {

            $self->ivPoke('lastDisplayTime', $self->sessionTime);
        }

        # Allow the 'world_idle' hook event to happen ($self->constHookIdleTime seconds from now)
        $self->ivPoke('disableWorldIdleFlag', FALSE);

        if ($thisObj) {
            return $thisObj;
        } else {
            return $lastObj;
        }
    }

    sub updateInstructBuffer {

        # Called by $self->doInstruct after the user types an instruction in a 'main' window (when
        #   this is the window's visible session), or when any other part of the code calls
        #   $self->doInstruct
        # Also called directly by a ->signal_connect in GA::Strip::Entry->setEntrySignals, when in
        #   special echo mode (as that function doesn't call $self->doInstruct)
        #
        # Updates the instruction buffer
        #
        # Expected arguments
        #   $instruct   - The instruction itself (e.g. ';setworld deathmud' or 'north;kill orc')
        #   $type       - The type of instruction: 'client' for a client command, 'world' for a
        #                   world command, 'perl' for a Perl command and 'echo' for an echo command
        #
        # Return values
        #   'undef' on improper arguments, if the session is not connected to a world or if the
        #       buffer is not updated
        #   Otherwise returns the buffer object created

        my ($self, $instruct, $type, $check) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (! defined $instruct || ! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateInstructBuffer', @_);
        }

        # Don't update the buffer after a disconnection (but do update it in 'connect offline' mode)
        if ($self->status eq 'disconnected') {

            return undef;
        }

        if (! defined $self->instructBufferFirst) {

            # This the first instruction ever processed
            $self->ivPoke('instructBufferFirst', 0);
        }

        # Create a new buffer object for this instruction
        $obj = Games::Axmud::Buffer::Instruct->new(
            $self,
            'session',
            $self->instructBufferCount,
            $instruct,
            $type,
            $self->sessionTime,
        );

        if (! $obj) {

            return undef;

        } else {

            # Update the instruction buffer
            $self->ivAdd('instructBufferHash', $obj->number, $obj);
            $self->ivIncrement('instructBufferCount');
            $self->ivPoke('instructBufferLast', ($self->instructBufferCount - 1));

            # If the buffer is full, remove the oldest line
            if ($self->instructBufferCount > $axmud::CLIENT->customInstructBufferSize) {

                $self->ivDelete('instructBufferHash', $self->instructBufferFirst);
                $self->ivIncrement('instructBufferFirst');
            }

            # Also add a separate buffer object (with a different ->number) to the equivalent
            #   registry in the GA::Client
            $axmud::CLIENT->updateInstructBuffer($self, $instruct, $type);
        }

        # Set the time at which the last instruction was executed
        $self->ivPoke('lastInstructTime', $self->sessionTime);

        return $obj;
    }

    sub updateCmdBuffer {

        # Called by $self->dispatchCmd, ->teleportCmd, and also by ->checkRedirect and
        #   ->checkAssistedMove
        # Also called by $self->teleportCmd, after an earlier call by GA::Cmd::Teleport->do
        #
        # Updates the world command buffer
        #
        # Expected arguments
        #   $cmd            - The world command itself (e.g. 'north', 'kill orc')
        #
        # Optional arguments
        #   $cage          - The highest-priority command cage (quite unlikely that this is set to
        #                       'undef')
        #   $redirectCmd    - For redirect mode commands, the substitute command (e.g. if $cmd is
        #                       'north', $redirectCmd might be 'sail north')
        #   $standardCmd    - For assisted moves, the standard primary direction equivalent to the
        #                       custom primary direction stored in $cmd. Set to 'undef' for
        #                       everything else
        #   $assistedCmd    - For assisted moves, the sequence of world commands corresponding to
        #                       the standard primary direction, $cmd (e.g. 'open door;north'). Set
        #                       to 'undef' for everything else
        #   $exitObj        - For assisted moves, the GA::Obj::Exit used for the movement (an exit
        #                       somewhere in the exit model). Set to 'undef' for everything else
        #   $teleportFlag   - When called by $self->teleportCmd, flag set to TRUE ('undef'
        #                       otherwise)
        #   $destRoom       - When called by $self->teleportCmd, the world model number of the
        #                       destination room (if known; 'undef' if not, or if not called by
        #                       $self->teleportCmd)
        #
        # Return values
        #   'undef' on improper arguments, if the session is not connected to a world or if the
        #       buffer can't be updated
        #   Otherwise returns the buffer object created

        my (
            $self, $cmd, $cage, $redirectCmd, $standardCmd, $assistedCmd, $exitObj,
            $teleportFlag, $destRoom, $check
        ) = @_;

        # Local variables
        my ($obj, $newGhost, $dir, $unabbrevDir, $exitNum);

        # Check for improper arguments
        if (! defined $cmd || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateCmdBuffer', @_);
        }

        # Don't update the buffer after a disconnection (but do update it in 'connect offline' mode)
        if ($self->status eq 'disconnected') {

            return undef;
        }

        if (! defined $self->cmdBufferFirst) {

            # This the first world command ever sent
            $self->ivPoke('cmdBufferFirst', 0);
        }

        # Create a new buffer object for this world command
        $obj = Games::Axmud::Buffer::Cmd->new(
            $self,
            'session',
            $self->cmdBufferCount,
            $cmd,
            $self->sessionTime,
        );

        if (! $obj) {

            return undef;

        } else {

            # Update the world command buffer
            $self->ivAdd('cmdBufferHash', $obj->number, $obj);
            $self->ivIncrement('cmdBufferCount');
            $self->ivPoke('cmdBufferLast', ($self->cmdBufferCount - 1));

            # If the buffer is full, remove the oldest line
            if ($self->cmdBufferCount > $axmud::CLIENT->customCmdBufferSize) {

                $self->ivDelete('cmdBufferHash', $self->cmdBufferFirst);
                $self->ivIncrement('cmdBufferFirst');
            }

            # Also add a separate buffer object (with a different ->number) to the equivalent
            #   registry in the GA::Client
            $axmud::CLIENT->updateCmdBuffer($self, $cmd);
        }

        # For redirect mode commands...
        if ($redirectCmd) {

            # Mark the buffer object as being generated by a redirect mode command
            $obj->addRedirect($redirectCmd);

        # For assisted moves (in this function, both $standardCmd and $assistedCmd are only set when
        #   it's an assisted move)
        } elsif ($standardCmd) {

            # Mark the buffer object as being generated by an assisted move
            $obj->addAssisted($standardCmd, $assistedCmd, $exitObj);

        # For calls by $self->teleportCmd...
        } elsif ($teleportFlag) {

            # Mark the buffer object as being generated by a teleport command
            $obj->addTeleport($destRoom);

        # For everything else (and assuming that a command cage has been specified)...
        } elsif ($cage) {

            # Decide whether the command is a look/glance command, a movement command, or something
            #   else (for the benefit of the Locator task)
            $obj->interpretCmd($cage);
        }

        # Update the automapper's ghost room, if it is set
        if ($obj->moveFlag && $self->mapObj->ghostRoom) {

            # For assisted moves, we already know the exit being taken
            if ($obj->assistedFlag) {

                # Check that this exit is actually in the automapper object's ghost room
                if ($exitObj->parent == $self->mapObj->ghostRoom->number) {

                    # The new ghost room is the exit's destination room (may be set to 'undef')
                    $newGhost = $exitObj->destRoom;
                }

            # For teleport commands, we might already know the destination room (if not, the
            #   automapper will become lost)
            } elsif ($teleportFlag) {

                if ($destRoom) {

                    $newGhost = $destRoom;
                }

            } else {

                # For redirect mode commands, the direction of movement is stored in ->cmd
                if ($obj->redirectFlag) {

                    $dir = $obj->cmd;

                # For everything else, the direction of movement is stored in ->moveDir
                } else {

                    $dir = $obj->moveDir;
                }

                # Improve our chances of finding a match by un-abbreviating $dir (if it's a
                #   primary direction)
                $unabbrevDir = $self->currentDict->unabbrevDir($dir);
                if ($unabbrevDir) {

                    $dir = $unabbrevDir;
                }

                # Does the ghost room have an exit in this direction?
                if ($self->mapObj->ghostRoom->ivExists('exitNumHash', $dir)) {

                    $exitNum = $self->mapObj->ghostRoom->ivShow('exitNumHash', $dir);
                    $exitObj = $self->worldModelObj->ivShow('exitModelHash', $exitNum);
                    # The new ghost room is the exit's destination room (may be set to 'undef')
                    $newGhost = $exitObj->destRoom;
                }
            }

            if ($newGhost) {

                # Set the automapper's ghost room
                $self->mapObj->setGhostRoom($self->worldModelObj->ivShow('modelHash', $newGhost));

#           # v1.0.284 - resetting the ghost room here leaves Cryosphere (and other similar worlds)
#           #   unable to accept commands like 'w' and 'east' as alternatives for 'port' and
#           #   'starboard' - for now, we'll leave the ghost room unaltered, and let GA::Obj::Map
#           #   make any necessary changes
#            } else {
#
#                # Reset the automapper's ghost room
#                $self->mapObj->setGhostRoom();
            }
        }

        # If the Locator task is running (i.e. not paused), inform it that a new GA::Buffer::Cmd
        #   object has been added to the command buffer
        if (
            $self->locatorTask
            && $self->locatorTask->status eq 'running'
            && $self->status ne 'offline'
        ) {
            $self->locatorTask->add_cmdObj($obj);
        }

        # Set the time at which the most recent world command was sent
        $self->ivPoke('lastCmdTime', $self->sessionTime);
        # Allow the 'user_idle' hook event to happen ($self->constHookIdleTime seconds from now)
        $self->ivPoke('disableUserIdleFlag', FALSE);

        return $obj;
    }

    sub updateBufferSize {

        # Called by GA::Cmd::SetDisplayBuffer->do, GA::Cmd::SetInstructionBuffer->do and
        #   GA::Cmd::SetCommandBuffer->do
        # When a default buffer size is changed (i.e. when GA::Client->customDisplayBufferSize,
        #   etc, are modified), checks whether this session's buffer IVs must be updated
        # (Specifically, if the buffer has been made smaller, then buffer lines must be deleted)
        #
        # Expected arguments
        #   $type           - 'display', 'instruct' or 'cmd'
        #   $size           - The new default buffer size, matching
        #                       GA::Client->customDisplayBufferSize, etc
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $type, $size, $check) = @_;

        # Local variables
        my $newFirst;

        # Check for improper arguments
        if (! defined $type || ! defined $size || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateCmdBuffer', @_);
        }

        # Don't update buffers after a disconnection (but do update them in 'connect offline' mode)
        if ($self->status eq 'disconnected') {

            return undef;
        }

        if ($type eq 'display') {

            if (
                $self->displayBufferCount
                && ($self->displayBufferLast - $self->displayBufferFirst + 1) > $size
            ) {
                $newFirst = $self->displayBufferLast - $size + 1;

                for (my $count = $self->displayBufferFirst; $count < $newFirst; $count++) {

                    $self->ivDelete('displayBufferHash', $count);
                }

                $self->ivPoke('displayBufferFirst', $newFirst);
            }

        } elsif ($type eq 'instruct') {

            if (
                $self->instructBufferCount
                && ($self->instructBufferLast - $self->instructBufferFirst + 1) > $size
            ) {
                $newFirst = $self->instructBufferLast - $size + 1;

                for (my $count = $self->instructBufferFirst; $count < $newFirst; $count++) {

                    $self->ivDelete('instructBufferHash', $count);
                }

                $self->ivPoke('instructBufferFirst', $newFirst);
            }

        } elsif ($type eq 'cmd') {

            if (
                $self->cmdBufferCount
                && ($self->cmdBufferLast - $self->cmdBufferFirst + 1) > $size
            ) {
                $newFirst = $self->cmdBufferLast - $size + 1;

                for (my $count = $self->cmdBufferFirst; $count < $newFirst; $count++) {

                    $self->ivDelete('cmdBufferHash', $count);
                }

                $self->ivPoke('cmdBufferFirst', $newFirst);
            }
        }

        return 1;
    }

    # Blinkers

    sub updateBlinkers {

        # Called by GA::Client->spinClientLoop
        # Updates blinker states for this session
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my %hash;

        # Check for improper arguments
        if (defined $check) {

            return $self->writeImproper($self->_objClass . '->updateBlinkers', @_);
        }

        # Import the blinker state hash (for convenience)
        %hash = $self->blinkerStateHash;

        foreach my $blinkerNum (keys %hash) {

            my $blinkerState = $hash{$blinkerNum};

            # Update the blinker's state; the calling function will actually make the call
            #   GA::Strip::ConnectInfo->drawBlinker for every blinker that needs to be redrawn
            if (defined $blinkerState) {

                if ($blinkerState < $axmud::CLIENT->clientTime) {

                    # Turn the blinker off
                    $hash{$blinkerNum} = undef;
                }
            }
        }

        # Update IVs
        $self->ivPoke('blinkerStateHash', %hash);

        return 1;
    }

    sub turnOnBlinker {

        # Called by $self->processIncomingData, ->dispatchCmd, ->dispatchPassword,
        #   ->processEscSequence, ->optCallback and ->subOptCallback
        # Marks one (or all) of the blinkers to be turned on by changing their state
        #   (GA::Client->spinClientLoop actually draws a new blinker, as required)
        # Does nothing when $self->status is 'offline' or 'disconnected'
        #
        # Expected arguments
        #   $choice     - Which blinker to turn on. -1 to turn on all blinkers, or one of the keys
        #                   in $self->blinkerStateHash (matching GA::Obj::Blinker->number)
        #
        # Return values
        #   'undef' on improper arguments, if the specified blinker doesn't exist or when
        #       disconnected/in offline mode
        #   1 otherwise

        my ($self, $choice, $check) = @_;

        # Local variables
        my $time;

        # Check for improper arguments
        if (! defined $choice || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->turnOnBlinker', @_);
        }

        # Do nothing when not connected/connecting, of if the specified blinker doesn't exist
        if (
            $self->status eq 'offline'
            || $self->status eq 'disconnected'
            || ($choice != -1 && ! $self->ivExists('blinkerStateHash', $choice))
        ) {
            return undef;
        }

        # If turning on multiple blinkers, use the same 'turn off' time for each of them
        $time = $axmud::CLIENT->getTime() - $axmud::CLIENT->clientLoopObj->startTime
                    + $axmud::CLIENT->blinkerDelay;

        # Update IVs
        if ($choice == -1) {

            foreach my $blinkerNum ($self->ivKeys('blinkerStateHash')) {

                $self->ivAdd('blinkerStateHash', $blinkerNum, $time);
            }

        } else {

            $self->ivAdd('blinkerStateHash', $choice, $time);
        }

        return 1;
    }

    sub turnOffBlinker {

        # Called by $self->reactDisconnect
        # Marks one (or all) of the blinkers to be turned off by changing their state
        #   (GA::Client->spinClientLoop actually draws a new blinker, as required)
        #
        # Expected arguments
        #   $choice     - Which blinker to turn off. -1 to turn off all blinkers, or one of the keys
        #                   in $self->blinkerStateHash (matching GA::Obj::Blinker->number)
        #
        # Return values
        #   'undef' on improper arguments or if the specified blinker doesn't exist
        #   1 otherwise

        my ($self, $choice, $check) = @_;

        # Check for improper arguments
        if (! defined $choice || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetBlinkers', @_);
        }

        # Do nothing when not connected/connecting, of if the specified blinker doesn't exist
        if (
            $self->status eq 'offline'
            || $self->status eq 'disconnected'
            || ($choice != -1 && ! $self->ivExists('blinkerStateHash', $choice))
        ) {
            return undef;
        }

        # Update IVs
        if ($choice == -1) {

            foreach my $blinkerNum ($self->ivKeys('blinkerStateHash')) {

                $self->ivAdd('blinkerStateHash', $blinkerNum, undef);
            }

        } else {

            $self->ivAdd('blinkerStateHash', $choice, undef);
        }

        return 1;
    }

    # Peek/Poke operations

    sub parsePeekPoke {

        # Called by GA::Cmd::Peek->do, Poke->do and various Axbasic statements
        #
        # Axmud uses a system by which the instance variables (IVs) of most Perl objects can be
        #   accessed by the ';peek' and ';poke' client commands (and their equivalents in Axbasic)
        # The handful of global variables which Axmud doesn't store as Perl objects can also be
        #   accessed in this way, for convenience
        #
        # Expected arguments
        #   This function expects a single argument, a string such as 'prof.deathmud.port'
        #
        #   The string is separated into a compulsory object part (e.g. 'prof.deathmud') and an
        #       optional IV part (e.g. 'port')
        #   The object part is translated into the blessed reference of the object. If an IV part is
        #       specified, the value of the IV is returned. If no IV part is specified, the blessed
        #       reference is returned
        #
        #   A complete list of strings that this function can be found in
        #       .../share/help/misc/peekpoke
        #
        #   In some cases, the string corresponds to a particular IV in a particular object, e.g.
        #       'world.current' refers to GA::Session->currentWorld
        #   In some cases, the string refers to a whole object, e.g. 'dict.current' which refers to
        #       GA::Session->currentDict. In those cases, you can add any valid IV to the string,
        #       e.g. 'dict.current.weaponHash'. The IV part is case-sensitive, so
        #       'dict.current.weaponhash' will not work
        #   Axmud global variables like $axmud::SCRIPT are used here as if they were IVs in the
        #       GA::Client object (for convenience)
        #
        #   The values returned by many strings depend on the session. For example, 'dict.current'
        #       will return this session's current dictionary, not some other session's current
        #       dictionary
        #
        #   Although it's possible to directly modify an IV's value with ';poke' (which calls this
        #       function), doing so is STRONGLY DISCOURAGED
        #   It is almost always better to use client commands and Axmud's 'edit', 'pref', 'wiz' and
        #       'viewer' windows, which take care of all the complications of modifying a value
        #       which might not be obvious to you
        #   If you do need to modify values directly (for testing purposes, perhaps), you should at
        #       least read the source code for the comments in the object's ->new function
        #   All Axmud objects have a ->_privFlag IV. If the value is TRUE, that object's IVs are
        #       'private' and cannot be modified by ';poke' at all. If the value is FALSE, that
        #       object's IVs are 'public' and can be modified by ';poke'
        #   Most objects whose ->_privFlag is FALSE are designed (for the most part) as simple
        #       stores of data, so it's usually safe to modify them directly (but still read the
        #       source code first)
        #   All IVs beginning with an underline, such as ->_privFlag itself, are 'standard' IVs that
        #       can't be modified in any circumstances
        #
        # Return values
        #   An empty list on improper arguments
        #   On failure, a list consisting of two elements:
        #       - 'undef' to show failure
        #       - An error message
        #   On success, a list consisting of six elements:
        #       - 1 to show success
        #       - The blessed reference of the matching Perl object, if the object part of $string
        #           refers to one ('undef' for some strings that refer to an Axmud global variable
        #           or for strings that don't represent a simple object->IV relationship. e.g.
        #           'plugin.load' extracts a sorted list of keys from GA::Client->pluginHash, but
        #           doesn't return the hash itself)
        #       - The name of the IV, if the string specifies one ('undef' if not). In some cases
        #           (e.g. 'exit.count') the name of the IV not the same as the last part of $string
        #       - The value stored in the IV or Axmud global variable. A scalar, list reference or
        #           hash reference. 'undef' if the string specifies no IV part
        #       - A flag set to TRUE if the string represents a Perl object, FALSE otherwise
        #       - A flag to show whether data can be overwritten or not - TRUE if data cannot be
        #           overwritten, or if $string refers to an object whose IVs are private; FALSE if
        #           data can be overwritten, or if $string refers to an object whose IVs are public.
        #           All strings that can't be reduced to a simple $blessed or $blessed->$iv
        #           relationship are read-only by default

        my ($self, $string, $check) = @_;

        # Local variables
        my (
            $wmObj, $genError, $size, $first, $second, $third, $fourth, $fifth, $last, $obj,
            $blessed, $var, $ivName, $privFlag, $objFlag, $error,
            @emptyList, @compList, @deRefList,
            %deRefHash,
        );

        # Check for improper arguments
        if (! defined $string || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->parsePeekPoke', @_);
            return @emptyList;
        }

        # Import the world model object (for convenience)
        $wmObj = $self->worldModelObj;
        # General error message
        $genError = 'Cannot parse \'' . $string . '\'';

        # Split $string into its components (e.g. split 'current.world' into the components
        #   'current' and 'world')
        @compList = split(/\./, $string);
        $size = scalar @compList;
        # No strings have less than two components
        if ($size <= 1) {

            return (undef, $genError);
        }

        # We need the first and last component for certain operations
        # It's convenient to use a shorthand for other components, too, though in many cases they
        #   will be 'undef'
        $first = $compList[0];
        $second = $compList[1];
        $third = $compList[2];
        $fourth = $compList[3];
        $fifth = $compList[4];
        # (We're going to put it back in just a moment...)
        $last = pop @compList;
        # Need the object part of the specified string, too
        $obj = join('.', @compList);
        push (@compList, $last);

        # By default, $privFlag is set to FALSE for 'both read and write'; can be set to TRUE
        #   representing 'read only'
        $privFlag = FALSE;
        # Also set a default value for $objFlag while we're at it
        $objFlag = FALSE;

        # Match a string to a Perl object, and return the two- or six-element array described above
        if ($first eq 'aard102') {

            # S aard102.status
            # S aard102.tick
            if ($size != 2) {

                $error = $genError;

            } else {

                $blessed = $self;
                if ($last eq 'status') {
                    $ivName = 'aard102Status';
                } elsif ($last eq 'tick') {
                    $ivName = 'aard102TickTime';
                }

                if ($ivName) {

                    $var = $blessed->{$ivName};
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'atcp') {

            if ($second eq 'list') {

                my (@list, @modList);

                @list = sort {lc($a) cmp lc($b)} ($self->ivKeys('atcpDataHash'));

                # L atcp.list
                # L atcp.list.PACKAGE
                # L atcp.list.PACKAGE.SUBPACKAGE
                if ($size == 2) {

                    $blessed = undef;
                    $var = \@list;
                    $ivName = 'atcpDataHash';
                    $privFlag = TRUE;

                } elsif ($size == 3) {

                    foreach my $item (@list) {

                        if ($item eq $third || $item =~ m/^$third\./) {

                            push (@modList, $item);
                        }
                    }

                    $blessed = undef;
                    $var = \@modList;
                    $ivName = 'atcpDataHash';
                    $privFlag = TRUE;

                } elsif ($size == 4) {

                    foreach my $item (@list) {

                        if (
                            $item eq $third . '.' . $fourth
                            || $item =~ m/^$third\.$fourth\./
                        ) {
                            push (@modList, $item);
                        }
                    }

                    $blessed = undef;
                    $var = \@modList;
                    $ivName = 'atcpDataHash';
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }

            } elsif ($second eq 'data') {

                my $packString;

                # S atcp.data.PACKAGE[.SUBPACKAGE][.MESSAGE]
                if ($size < 3 || $size > 5) {

                    $error = $genError;

                } else {

                    $packString = $third;
                    if ($size > 3) {

                        $packString .= '.' . $fourth;
                    }

                    if ($size > 4) {

                        $packString .= '.' . $fifth;
                    }

                    if (! $self->ivExists('atcpDataHash', $packString)) {

                        $error = 'ATCP package \'' . $packString . '\' doesn\'t exist';

                    } else {

                        $blessed = $self->ivShow('atcpDataHash', $packString);
                        $var = $blessed->{origData};
                        $ivName = 'origData';
                        $privFlag = TRUE;
                    }
                }

            } elsif ($second eq 'val') {

                # X atcp.val.PACKAGE[.SUBPACKAGE][.MESSAGE]
                # X atcp.val.PACKAGE[.SUBPACKAGE][.MESSAGE][.json]

                my (
                    $obj, $modString,
                    @testList, @modList,
                );

                if ($size < 4) {

                    $error = $genError;

                } else {

                    # Because 'json' can be an arbitrary length, we can't say if $string specifies
                    #   'PACKAGE', 'PACKAGE.SUBPACKAGE' or 'PACKAGE.SUBPACKAGE.MESSAGE'. Try theM
                    #   in reverse order, using the first match we find
                    push (@testList, "$third.$fourth", $third);

                    if (defined $fifth) {

                        unshift (@testList, "$third.$fourth.$fifth");
                    }

                    OUTER: foreach my $item (@testList) {

                        if ($self->ivExists('atcpDataHash', $item)) {

                            $obj = $self->ivShow('atcpDataHash', $item);

                            # Get a list of one or more components, representing 'json'
                            $modString = $string;
                            $modString =~ s/$first\.$second\.$item[\.]?//;
                            @modList = split(/\./, $modString);

                            last OUTER;
                        }
                    }

                    if (! defined $obj) {

                        $error = 'ATCP package \'' . $testList[-1] . '\' doesn\'t exist';

                    } else {

                        # Interpret the 'json' part, which can be made up of any number of
                        #   '.INDEX' and '.KEY' components

                        # The JSON data is stored as a scalar, or a list/hash reference, with
                        #   further list/hash references embedded within
                        $var = $obj->{data};
                        OUTER: foreach my $comp (@modList) {

                            if ((ref $var) eq 'ARRAY') {

                                # Interpret $comp as '.INDEX', unless it's not an integer, in
                                #   which case stop here
                                if (! $axmud::CLIENT->intCheck($comp)) {

                                    $error = 'ATCP data contains an array, but \'' . $string
                                            . '\' doesn\'t specify an array index at that point';
                                    last OUTER;

                                } else {

                                    $var = $$var[$comp];
                                    if (! defined $var) {

                                        # Specified index doesn't exist, so stop here
                                        $error = 'ATCP data contains an array, but \'' . $string
                                            . '\' specifies an array index that doesn\'t exist';

                                        last OUTER;
                                    }
                                }

                            } elsif ((ref $var eq 'HASH')) {

                                # Interpret $comp as '.KEY'
                                $var = $$var{$comp};
                                if (! defined $var) {

                                    # Specified index doesn't exist, so stop here
                                    $error = 'ATCP data contains a hash, but \'' . $string
                                            . '\' specifies a hash key that doesn\'t exist';

                                    last OUTER;
                                }
                            }
                        }

                        if (! $error) {

                            $blessed = undef;
                            $ivName = 'atcpDataHash';
                            $privFlag = TRUE;
                        }
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'buffer') {

            # s buffer.size.display
            # s buffer.size.instruct
            # s buffer.size.cmd
            if ($second eq 'size') {

                if ($size != 3) {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    if ($last eq 'display') {
                        $ivName = 'customDisplayBufferSize';
                    } elsif ($last eq 'instruct') {
                        $ivName = 'customInstructBufferSize';
                    } elsif ($last eq 'cmd') {
                        $ivName = 'customCmdBufferSize';
                    }

                    if ($ivName) {

                        $var = $blessed->{$ivName};
                        $privFlag = FALSE;

                    } else {

                        $error = $genError;
                    }
                }

            # O buffer.client.instruct.NUMBER
            # S buffer.client.instruct.count
            # S buffer.client.instruct.first
            # S buffer.client.instruct.last
            # O buffer.client.cmd.NUMBER
            # S buffer.client.cmd.count
            # S buffer.client.cmd.first
            # S buffer.client.cmd.last
            # O buffer.session.display.NUMBER
            # S buffer.session.display.count
            # S buffer.session.display.first
            # S buffer.session.display.last
            # O buffer.session.instruct.NUMBER
            # S buffer.session.instruct.count
            # S buffer.session.instruct.first
            # S buffer.session.instruct.last
            # O buffer.session.cmd.NUMBER
            # S buffer.session.cmd.count
            # S buffer.session.cmd.first
            # S buffer.session.cmd.last
            } elsif ($second eq 'client' || $second eq 'session') {

                my $numFlag;

                if (
                    defined $fourth && $fourth ne 'count' && $fourth ne 'first' && $fourth ne 'last'
                ) {
                    $numFlag = TRUE;
                }

                if ($size < 4 || (! $numFlag && $size > 4) || ($numFlag && $size > 5)) {

                    $error = $genError;

                } elsif ($third ne 'display' && $third ne 'instruct' && $third ne 'cmd') {

                    $error = $genError;

                # buffer.client.display.XXX doesn't exist
                } elsif ($second eq 'client' && $third eq 'display') {

                    $error = $genError;

                } else {

                    if ($second eq 'client') {
                        $blessed = $axmud::CLIENT;
                    } elsif ($second eq 'session') {
                        $blessed = $self;
                    }

                    if ($third eq 'display') {

                        if ($last eq 'count') {
                            $ivName = 'displayBufferCount';
                        } elsif ($last eq 'first') {
                            $ivName = 'displayBufferFirst';
                        } elsif ($last eq 'last') {
                            $ivName = 'displayBufferLast';
                        } else {
                            $ivName = 'displayBufferHash';
                        }

                    } elsif ($third eq 'instruct') {

                        if ($last eq 'count') {
                            $ivName = 'instructBufferCount';
                        } elsif ($last eq 'first') {
                            $ivName = 'instructBufferFirst';
                        } elsif ($last eq 'last') {
                            $ivName = 'instructBufferLast';
                        } else {
                            $ivName = 'instructBufferHash';
                        }

                    } elsif ($third eq 'cmd') {

                        if ($last eq 'count') {
                            $ivName = 'cmdBufferCount';
                        } elsif ($last eq 'first') {
                            $ivName = 'cmdBufferFirst';
                        } elsif ($last eq 'last') {
                            $ivName = 'cmdBufferLast';
                        } else {
                            $ivName = 'cmdBufferHash';
                        }
                    }

                    if (! $numFlag) {

                        $var = $blessed->{$ivName};
                        $privFlag = TRUE;

                    } elsif (! $blessed->ivExists($ivName, $fourth)) {

                        $error = 'Buffer object number \'' . $fourth . '\' doesn\'t exist';

                    } else {

                        # Change $blesed from GA::Client/GA::Session to a buffer object
                        $blessed = $blessed->ivShow($ivName, $fourth);
                        $privFlag = $blessed->_privFlag;

                        if ($size > 4) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                            $ivName = undef;
                        }
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'cage') {

            # O cage.current.TYPE.CATEGORY
            if ($second eq 'current') {

                my ($profObj, $uniqueName);

                if ($size < 4 || $size > 5) {

                    $error = $genError;

                } elsif (! $self->ivExists('currentProfHash', $fourth)) {

                    $error = 'Current cage for profile category \'' . $fourth . '\' doesn\'t exist';

                } else {

                    $profObj = $self->ivShow('currentProfHash', $fourth);
                    $uniqueName = $third . '_' . $fourth . '_' . $profObj->name;

                    if (! $self->ivExists('currentCageHash', $uniqueName)) {

                        $error = 'Current cage \'' . $uniqueName . '\' doesn\'t exist';

                    } else {

                        $blessed = $self->ivShow('currentCageHash', $uniqueName);
                        $privFlag = $blessed->_privFlag;
                        if (defined $fifth) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                        }
                    }
                }

            # O cage.name.NAME
            } elsif ($second eq 'name') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('cageHash', $third)) {

                    if ($size == 3) {
                        $error = 'Cage \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Cage \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('cageHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # L cage.type.list
            } elsif ($string eq 'cage.type.list') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{cageTypeList};
                $ivName = 'cageTypeList';
                $privFlag = TRUE;

            # O cage.TYPE.PROF
            } else {

                my ($profObj, $uniqueName);

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('profHash', $third)) {

                    if ($size == 3) {
                        $error = 'Profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $profObj = $self->ivShow('profHash', $third);
                    $uniqueName = $second . '_' . $profObj->category . '_' . $profObj->name;

                    if (! $self->ivExists('cageHash', $uniqueName)) {

                        $error = 'Cage \'' . $uniqueName . '\' doesn\'t exist';

                    } else {

                        $blessed = $self->ivShow('cageHash', $uniqueName);
                        $privFlag = $blessed->_privFlag;
                        if (defined $fourth) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                        }
                    }
                }
            }

        } elsif ($first eq 'char') {

            # o char.current
            if ($second eq 'current') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->currentChar) {

                    $error = 'No current character profile set';

                } else {

                    $blessed = $self->currentChar;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o char.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->ivExists('profHash', $second)) {

                    if ($size == 2) {
                        $error = 'Character profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Character profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('profHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if ($blessed->category ne 'char') {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a character profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a character profile';
                        }

                    } elsif (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'chat') {

            # o chat.contact.NAME
            if ($second eq 'contact') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('chatContactHash', $third)) {

                    if ($size == 3) {
                        $error = 'Chat contact with name \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Chat contact with name \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('chatContactHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                # S chat.name
                # S chat.email
                # S chat.mode
                # S chat.icon
                if ($size > 2) {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    $privFlag = TRUE;

                    if ($second eq 'name') {

                        $var = $blessed->{chatName};
                        $ivName = 'chatName';

                    } elsif ($second eq 'email') {

                        $var = $blessed->{chatEmail};
                        $ivName = 'chatEmail';

                    } elsif ($second eq 'mode') {

                        $var = $blessed->{chatAcceptMode};
                        $ivName = 'chatAcceptMode';

                    } elsif ($second eq 'icon') {

                        $var = $blessed->{chatIcon};
                        $ivName = 'chatIcon';

                    } else {

                        $error = $genError;
                    }
                }
            }

        } elsif ($first eq 'client') {

            my (%globalHash, %clientHash);

            # Save a lot of lines of code by loading IV names/values into hashes

            # S client.name
            # S client.name.short
            # S client.name.article
            # S client.version
            # S client.date
            # S client.basic.name
            # S client.basic.article
            # S client.basic.version
            # S client.authors
            # S client.copyright
            # S client.url
            # S client.file.name
            # L client.file.list
            # S client.dir.base
            # S client.dir.data
            # L client.dir.list
            # L client.ext.list
            # F client.mode.blind
            # F client.mode.safe
            # F client.mode.test
            # L client.license
            # L client.credit
            %globalHash = (
                'client.name'           => $axmud::SCRIPT,
                'client.name.short'     => $axmud::NAME_SHORT,
                'client.name.article'   => $axmud::NAME_ARTICLE,
                'client.version'        => $axmud::VERSION,
                'client.date'           => $axmud::DATE,
                'client.basic.name'     => $axmud::BASIC_NAME,
                'client.basic.article'  => $axmud::BASIC_ARTICLE,
                'client.basic.version'  => $axmud::BASIC_VERSION,
                'client.authors'        => $axmud::AUTHORS,
                'client.copyright'      => $axmud::COPYRIGHT,
                'client.url'            => $axmud::URL,
                'client.file.name'      => $axmud::NAME_FILE,
                'client.file.list'      => \@axmud::COMPAT_FILE_LIST,
                'client.dir.base'       => $axmud::SHARE_DIR,
                'client.dir.data'       => $axmud::DATA_DIR,
                'client.dir.list'       => \@axmud::COMPAT_DIR_LIST,
                'client.ext.list'       => \@axmud::COMPAT_EXT_LIST,
                'client.mode.blind'     => $axmud::BLIND_MODE_FLAG,
                'client.mode.safe'      => $axmud::SAFE_MODE_FLAG,
                'client.mode.test'      => $axmud::TEST_MODE_FLAG,
                'client.license'        => \@axmud::LICENSE_LIST,
                'client.credit'         => \@axmud::CREDIT_LIST,
            );

            # S client.start.time
            # S client.start.clock
            # S client.start.date
            # S client.start.clockstring
            # S client.start.datestring
            # S client.delay.prompt
            # S client.delay.login
            # L client.iv
            # L client.reserved
            %clientHash = (
                'client.start.time'     => 'startTime',
                'client.start.clock'    => 'startClock',
                'client.start.date'     => 'startDate',
                'client.start.clockstring'
                                        => 'startClockString',
                'client.start.datestring'
                                        => 'startDateString',
                'client.delay.prompt'   => 'promptWaitTime',
                'client.delay.login'    => 'loginWarningTime',
                'client.iv'             => 'constIVHash',
                'client.reserved'       => 'constReservedHash',
            );

            if (exists $globalHash{$string}) {

                $blessed = undef;
                $var = $globalHash{$string};

                %globalHash = (
                    'client.name'           => 'SCRIPT',
                    'client.name.short'     => 'NAME_SHORT',
                    'client.name.article'   => 'NAME_ARTICLE',
                    'client.version'        => 'VERSION',
                    'client.date'           => 'DATE',
                    'client.basic.name'     => 'BASIC_NAME',
                    'client.basic.article'  => 'BASIC_ARTICLE',
                    'client.basic.version'  => 'BASIC_VERSION',
                    'client.authors'        => 'AUTHORS',
                    'client.copyright'      => 'COPYRIGHT',
                    'client.url'            => 'URL',
                    'client.file.name'      => 'NAME_FILE',
                    'client.file.list'      => 'COMPAT_FILE_LIST',
                    'client.dir.base'       => 'BASE_DIR',
                    'client.dir.data'       => 'DATA_DIR',
                    'client.dir.list'       => 'COMPAT_DIR_LIST',
                    'client.ext.list'       => 'COMPAT_EXT_LIST',
                    'client.mode.blind'     => 'BLIND_MODE_FLAG',
                    'client.mode.safe'      => 'SAFE_MODE_FLAG',
                    'client.mode.test'      => 'TEST_MODE_FLAG',
                    'client.license'        => 'LICENSE_LIST',
                    'client.credit'         => 'CREDIT_LIST',
                );

                $ivName = $globalHash{$string};
                $privFlag = TRUE;

            } elsif (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'colour' || $first eq 'color') {

            # o colour.scheme.NAME
            # o color.scheme.NAME
            if ($second eq 'scheme') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('colourSchemeHash', $third)) {

                    if ($size == 3) {
                        $error = 'Colour scheme with name \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Colour scheme with name \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('colourSchemeHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # S colour.system.cmd
            # S color.system.cmd
            # S colour.system.text
            # S color.system.text
            # S colour.system.error
            # S color.system.error
            # S colour.system.warning
            # S color.system.warning
            # S colour.system.debug
            # S color.system.debug
            # S colour.system.improper
            # S color.system.improper
            } elsif ($second eq 'system') {

                if ($size != 3) {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    if ($last eq 'cmd') {
                        $ivName = 'customInsertCmdColour';
                    } elsif ($last eq 'text') {
                        $ivName = 'customShowSystemTextColour';
                    } elsif ($last eq 'error') {
                        $ivName = 'customShowErrorColour';
                    } elsif ($last eq 'warning') {
                        $ivName = 'customShowWarningColour';
                    } elsif ($last eq 'debug') {
                        $ivName = 'customShowDebugColour';
                    } elsif ($last eq 'improper') {
                        $ivName = 'customShowImproperColour';
                    }

                    if ($ivName) {

                        $var = $blessed->{$ivName};
                        $privFlag = TRUE;

                    } else {

                        $error = $genError;
                    }
                }

            # F colour.flag.invisible
            # F color.flag.invisible
            } elsif ($second eq 'flag') {

                if ($string ne 'colour.flag.invisible') {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    $ivName = 'convertInvisibleFlag';
                    $var = $blessed->{$ivName};
                    $privFlag = TRUE;
                }

            # S colour.rgb.TAG
            # S color.rgb.TAG
            } elsif ($second eq 'rgb') {

                my $modCol;

                if ($size != 3) {

                   $error = $genError;

                } else {

                    # For convenience, remove the 'ul_' or 'UL_' if the user quoted it
                    $modCol = $third;
                    if ($modCol =~ m/^ul_/i) {

                        $modCol = substr($modCol, 3);
                    }

                    $blessed = undef;
                    $privFlag = TRUE;

                    if ($axmud::CLIENT->ivExists('colourTagHash', $modCol)) {

                        $ivName = 'colourTagHash';
                        $var = $axmud::CLIENT->ivShow('colourTagHash', $modCol);

                    } elsif ($axmud::CLIENT->ivExists('boldColourTagHash', $modCol)) {

                        $ivName = 'boldColourTagHash';
                        $var = $axmud::CLIENT->ivShow('boldColourTagHash', $modCol);

                    } else {

                        $error = 'Colour tag \'' . $third . '\' isn\'t a standard colour tag';
                    }
                }

            # S colour.misc.xterm
            # S color.misc.xterm
            # F colour.misc.osc
            # F color.misc.osc
            } elsif ($second eq 'misc') {

                if ($size != 3) {

                   $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    $privFlag = TRUE;

                    if ($third eq 'xterm') {
                        $ivName = 'currentColourCube';
                    } elsif ($third eq 'osc') {
                        $ivName = 'oscPaletteFlag';
                    }

                    if ($ivName) {

                        $var = $blessed->{$ivName};

                    } else {

                        $error = $genError;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'custom') {

            # o custom.current.CATEGORY
            if ($second eq 'current') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('currentProfHash', $third)) {

                    if ($size == 3) {
                        $error = 'Current profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Current profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('currentProfHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (! $blessed->isa('Games::Axmud::Profile::Custom')) {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a custom profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a custom profile';
                        }

                    } elsif (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o custom.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->ivExists('profHash', $second)) {

                    if ($size == 2) {
                        $error = 'Custom profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Custom profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('profHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if (! $blessed->isa('Games::Axmud::Profile::Custom')) {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a custom profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a custom profile';
                        }

                    } elsif (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

       } elsif ($first eq 'debug') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # F debug.protocol.escape
            # F debug.protocol.telnet
            # F debug.protocol.telnet.short
            # F debug.protocol.log
            # F debug.protocol.msdp
            # F debug.protocol.mxp
            # F debug.protocol.mxp.comment
            # F debug.protocol.pueblo
            # F debug.protocol.peublo.comment
            # F debug.line.numbers
            # F debug.line.tags
            # F debug.locator.some
            # F debug.locator.all
            # F debug.locator.exit
            # F debug.locator.move
            # F debug.obj.parse
            # F debug.obj.compare
            # F debug.error.plugin
            # F debug.error.iv
            # F debug.error.table
            # F debug.error.trap
            %clientHash = (
                'debug.protocol.escape' => 'debugEscSequenceFlag',
                'debug.protocol.telnet' => 'debugTelnetFlag',
                'debug.protocol.telnet.short'
                                        => 'debugTelnetMiniFlag',
                'debug.protocol.log'    => 'debugTelnetLogFlag',
                'debug.protocol.msdp'   => 'debugMsdpFlag',
                'debug.protocol.mxp'    => 'debugMxpFlag',
                'debug.protocol.mxp.comment'
                                        => 'debugMxpCommentFlag',
                'debug.protocol.pueblo' => 'debugPuebloFlag',
                'debug.protocol.pueblo.comment'
                                        => 'debugPuebloCommentFlag',
                'debug.protocol.zmp'    => 'debugZmpFlag',
                'debug.protocol.atcp'   => 'debugAtcpFlag',
                'debug.protocol.gmcp'   => 'debugGmcpFlag',
                'debug.protocol.mcp'    => 'debugMcpFlag',
                'debug.line.numbers'    => 'debugLineNumsFlag',
                'debug.line.tags'       => 'debugLineTagsFlag',
                'debug.locator.some'    => 'debugLocatorFlag',
                'debug.locator.all'     => 'debugMaxLocatorFlag',
                'debug.locator.exit'    => 'debugExitFlag',
                'debug.locator.move'    => 'debugMoveListFlag',
                'debug.obj.parse'       => 'debugParseObjFlag',
                'debug.obj.compare'     => 'debugCompareObjFlag',
                'debug.error.plugin'    => 'debugExplainPluginFlag',
                'debug.error.iv'        => 'debugCheckIVFlag',
                'debug.error.table'     => 'debugTableFitFlag',
                'debug.error.trap'      => 'debugTrapErrorFlag',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            } else {

                $error = $genError;
            }

       } elsif ($first eq 'desktop') {

            # S desktop.panel.left
            # S desktop.panel.right
            # S desktop.panel.top
            # S desktop.panel.bottom
            # S desktop.controls.left
            # S desktop.controls.right
            # S desktop.controls.top
            # S desktop.controls.bottom
            if (
                $size != 3
                || ($second ne 'panel' && $second ne 'controls')
                || ($third ne 'left' && $third ne 'right' && $third ne 'top' && $third ne 'bottom')
            ) {
                $error = $genError;

            } else {

                $blessed = $axmud::CLIENT;
                if ($second eq 'panel') {

                    if ($third eq 'left') {
                        $ivName = 'customPanelLeftSize';
                    } elsif ($third eq 'right') {
                        $ivName = 'customPanelRightSize';
                    } elsif ($third eq 'top') {
                        $ivName = 'customPanelTopSize';
                    } elsif ($third eq 'bottom') {
                        $ivName = 'customPanelBottomSize';
                    }

                } elsif ($second eq 'controls') {

                    if ($third eq 'left') {
                        $ivName = 'customControlsLeftSize';
                    } elsif ($third eq 'right') {
                        $ivName = 'customControlsRightSize';
                    } elsif ($third eq 'top') {
                        $ivName = 'customControlsTopSize';
                    } elsif ($third eq 'bottom') {
                        $ivName = 'customControlsBottomSize';
                    }
                }

                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;
            }

        } elsif ($first eq 'dict') {

            # O dict.current
            if ($second eq 'current') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->currentDict) {

                    $error = 'No current dictionary set';

                } else {

                    $blessed = $self->currentDict;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O dict.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('dictHash', $second)) {

                    if ($size == 2) {
                        $error = 'Dictionary object named \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Dictionary object named \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('dictHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'exit') {

            # o exit.number.NUMBER
            if ($second eq 'number') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('exitModelHash', $third)) {

                    if ($size == 3) {
                        $error = 'Exit model exit number \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Exit model exit number \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow('exitModelHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o exit.newest
            } elsif ($second eq 'newest') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! defined $wmObj->mostRecentExitNum) {

                    $error = 'No recently-created exit model object found';

                } else {

                    $blessed = $wmObj->ivShow('exitModelHash', $wmObj->mostRecentExitNum);
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # S exit.count
            } elsif ($second eq 'count') {

                if ($size != 2) {

                    $error = $genError;

                } else {

                    $blessed = $wmObj;
                    $var = $blessed->{exitObjCount};
                    $ivName = 'exitObjCount';
                    $privFlag = TRUE;
                }

            # S exit.actual
            } elsif ($second eq 'actual') {

                if ($size != 2) {

                    $error = $genError;

                } else {

                    $blessed = $wmObj;
                    $var = $blessed->{exitActualCount};
                    $ivName = 'exitActualCount';
                    $privFlag = TRUE;
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'external') {

            # S external.browser
            # S external.email
            # S external.audio
            # S external.editor
            if ($size != 2) {

                $error = $genError;

            } else {

                $blessed = $axmud::CLIENT;

                if ($second eq 'browser') {
                    $ivName = 'browserCmd';
                } elsif ($second eq 'email') {
                    $ivName = 'emailCmd';
                } elsif ($second eq 'audio') {
                    $ivName = 'audioCmd';
                } elsif ($second eq 'editor') {
                    $ivName = 'textEditCmd';
                }

                if ($ivName) {

                    $var = $blessed->{$ivName};
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'file') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # F file.config.load
            # F file.config.save
            # F file.data.load
            # F file.data.save
            # F file.start.delete
            # F file.load.fail
            # F file.save.backup
            # F file.save.modify
            # F file.autosave.flag
            # S file.autosave.time
            %clientHash = (
                'file.config.load'      => 'loadConfigFlag',
                'file.config.save'      => 'saveConfigFlag',
                'file.data.load'        => 'loadDataFlag',
                'file.data.save'        => 'saveDataFlag',
                'file.start.delete'     => 'deleteFilesAtStartFlag',
                'file.load.fail'        => 'fileFailFlag',
                'file.save.backup'      => 'autoRetainFileFlag',
                'file.save.modify'      => 'showModFlag',
                'file.autosave.flag'    => 'autoSaveFlag',
                'file.autosave.time'    => 'autoSaveWaitTime',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # O file.client.NAME
            } elsif ($second eq 'client') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('fileObjHash', $third)) {

                    if ($size == 3) {
                        $error = 'Client file object \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Client file object \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('fileObjHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O file.session.NAME
            } elsif ($second eq 'session') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('sessionFileObjHash', $third)) {

                    if ($size == 3) {
                        $error = 'Session file object \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Session file object \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('sessionFileObjHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'gmcp') {

            if ($second eq 'list') {

                my (@list, @modList);

                @list = sort {lc($a) cmp lc($b)} ($self->ivKeys('gmcpDataHash'));

                # L gmcp.list
                # L gmcp.list.PACKAGE
                # L gmcp.list.PACKAGE.SUBPACKAGE
                if ($size == 2) {

                    $blessed = undef;
                    $var = \@list;
                    $ivName = 'gmcpDataHash';
                    $privFlag = TRUE;

                } elsif ($size == 3) {

                    foreach my $item (@list) {

                        if ($item eq $third || $item =~ m/^$third\./) {

                            push (@modList, $item);
                        }
                    }

                    $blessed = undef;
                    $var = \@modList;
                    $ivName = 'gmcpDataHash';
                    $privFlag = TRUE;

                } elsif ($size == 4) {

                    foreach my $item (@list) {

                        if (
                            $item eq $third . '.' . $fourth
                            || $item =~ m/^$third\.$fourth\./
                        ) {
                            push (@modList, $item);
                        }
                    }

                    $blessed = undef;
                    $var = \@modList;
                    $ivName = 'gmcpDataHash';
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }

            } elsif ($second eq 'data') {

                my $packString;

                # S gmcp.data.PACKAGE[.SUBPACKAGE][.MESSAGE]
                if ($size < 3 || $size > 5) {

                    $error = $genError;

                } else {

                    $packString = $third;
                    if ($size > 3) {

                        $packString .= '.' . $fourth;
                    }

                    if ($size > 4) {

                        $packString .= '.' . $fifth;
                    }

                    if (! $self->ivExists('gmcpDataHash', $packString)) {

                        $error = 'GMCP package \'' . $packString . '\' doesn\'t exist';

                    } else {

                        $blessed = $self->ivShow('gmcpDataHash', $packString);
                        $var = $blessed->{origData};
                        $ivName = 'origData';
                        $privFlag = TRUE;
                    }
                }

            } elsif ($second eq 'val') {

                # X gmcp.val.PACKAGE[.SUBPACKAGE][.MESSAGE][.json]

                my (
                    $obj, $modString,
                    @testList, @modList,
                );

                if ($size < 4) {

                    $error = $genError;

                } else {

                    # Because 'json' can be an arbitrary length, we can't say if $string specifies
                    #   'PACKAGE', 'PACKAGE.SUBPACKAGE' or 'PACKAGE.SUBPACKAGE.MESSAGE'. Try theM
                    #   in reverse order, using the first match we find
                    push (@testList, "$third.$fourth", $third);

                    if (defined $fifth) {

                        unshift (@testList, "$third.$fourth.$fifth");
                    }

                    OUTER: foreach my $item (@testList) {

                        if ($self->ivExists('gmcpDataHash', $item)) {

                            $obj = $self->ivShow('gmcpDataHash', $item);

                            # Get a list of one or more components, representing 'json'
                            $modString = $string;
                            $modString =~ s/$first\.$second\.$item[\.]?//;
                            @modList = split(/\./, $modString);

                            last OUTER;
                        }
                    }

                    if (! defined $obj) {

                        $error = 'GMCP package \'' . $testList[-1] . '\' doesn\'t exist';

                    } else {

                        # Interpret the 'json' part, which can be made up of any number of
                        #   '.INDEX' and '.KEY' components

                        # The JSON data is stored as a scalar, or a list/hash reference, with
                        #   further list/hash references embedded within
                        $var = $obj->{data};
                        OUTER: foreach my $comp (@modList) {

                            if ((ref $var) eq 'ARRAY') {

                                # Interpret $comp as '.INDEX', unless it's not an integer, in
                                #   which case stop here
                                if (! $axmud::CLIENT->intCheck($comp)) {

                                    $error = 'GMCP data contains an array, but \'' . $string
                                            . '\' doesn\'t specify an array index at that point';
                                    last OUTER;

                                } else {

                                    $var = $$var[$comp];
                                    if (! defined $var) {

                                        # Specified index doesn't exist, so stop here
                                        $error = 'GMCP data contains an array, but \'' . $string
                                            . '\' specifies an array index that doesn\'t exist';

                                        last OUTER;
                                    }
                                }

                            } elsif ((ref $var eq 'HASH')) {

                                # Interpret $comp as '.KEY'
                                $var = $$var{$comp};
                                if (! defined $var) {

                                    # Specified index doesn't exist, so stop here
                                    $error = 'GMCP data contains a hash, but \'' . $string
                                            . '\' specifies a hash key that doesn\'t exist';

                                    last OUTER;
                                }
                            }
                        }

                        if (! $error) {

                            $blessed = undef;
                            $ivName = 'gmcpDataHash';
                            $privFlag = TRUE;
                        }
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'guild') {

            # o guild.current
            if ($second eq 'current') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->currentGuild) {

                    $error = 'No current guild profile set';

                } else {

                    $blessed = $self->currentGuild;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o guild.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->ivExists('profHash', $second)) {

                    if ($size == 2) {
                        $error = 'Guild profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Guild profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('profHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if ($blessed->category ne 'guild') {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a guild profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a guild profile';
                        }

                    } elsif (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'iface') {

            # O iface.name.NAME
            if ($second eq 'name') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('interfaceHash', $third)) {

                    if ($size == 3) {
                        $error = 'Active interface named \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Active interface named \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('interfaceHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O iface.number.NUMBER
            } elsif ( $second eq 'number') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('interfaceNumHash', $third)) {

                    if ($size == 3) {
                        $error = 'Active interface number \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Active interface number \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('interfaceNumHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'imodel') {

            # O imodel.TYPE
            if ($size > 3) {

                $error = $genError;

            } elsif (! $axmud::CLIENT->ivExists('interfaceModelHash', $second)) {

                if ($size == 2) {
                    $error = 'Interface model type \'' . $string . '\' doesn\'t exist';
                } else {
                    $error = 'Interface model type \'' . $obj . '\' doesn\'t exist';
                }

            } else {

                $blessed = $axmud::CLIENT->ivShow('interfaceModelHash', $second);
                $privFlag = $blessed->_privFlag;
                if (defined $third) {

                    $var = $blessed->{$last};
                    $ivName = $last;

                } else {

                    $objFlag = TRUE;
                }
            }

        } elsif ($first eq 'instruct') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # S instruct.sigil.client
            # S instruct.sigil.forced
            # S instruct.sigil.echo
            # S instruct.sigil.perl
            # S instruct.sigil.script
            # S instruct.sigil.multi
            # S instruct.sigil.speedwalk
            # S instruct.sigil.bypass
            # F instruct.enable.echo
            # F instruct.enable.perl
            # F instruct.enable.script
            # F instruct.enable.multi
            # F instruct.enable.speed
            # F instruct.enable.bypass
            # S instruct.separator
            # F instruct.world.confirm
            # F instruct.world.convert
            # F instruct.world.preserve
            # F instruct.other.preserve
            # F instruct.multi.max
            # F instruct.complete.mode
            # F instruct.complete.type
            # F instruct.complete.parent
            %clientHash = (
                'file.config.load'      => 'loadConfigFlag',

                'instruct.sigil.client' => 'constClientSigil',
                'instruct.sigil.forced' => 'constForcedSigil',
                'instruct.sigil.echo'   => 'constEchoSigil',
                'instruct.sigil.perl'   => 'constPerlSigil',
                'instruct.sigil.script' => 'constScriptSigil',
                'instruct.sigil.multi'  => 'constMultiSigil',
                'instruct.sigil.speedwalk'
                                        => 'constSpeedSigil',
                'instruct.sigil.bypass' => 'constBypassSigil',
                'instruct.enable.echo'  => 'echoSigilFlag',
                'instruct.enable.perl'  => 'perlSigilFlag',
                'instruct.enable.script'
                                        => 'scriptSigilFlag',
                'instruct.enable.multi' => 'multiSigilFlag',
                'instruct.enable.speed' => 'speedSigilFlag',
                'instruct.enable.bypass'
                                        => 'bypassSigilFlag',
                'instruct.separator'    => 'cmdSep',
                'instruct.world.confirm'
                                        => 'confirmWorldCmdFlag',
                'instruct.world.convert'
                                        => 'convertWorldCmdFlag',
                'instruct.world.preserve'
                                        => 'preserveWorldCmdFlag',
                'instruct.other.preserve'
                                        => 'preserveOtherCmdFlag',
                'instruct.multi.max'    => 'maxMultiCmdFlag',
                'instruct.complete.mode'
                                        => 'autoCompleteMode',
                'instruct.complete.type'
                                        => 'autoCompleteType',
                'instruct.complete.parent'
                                        => 'autoCompleteParent',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'log') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # F log.allow
            # F log.delete.standard
            # F log.delete.world
            # F log.start.day
            # F log.start.client
            # F log.prefix.date
            # F log.prefix.time
            # F log.image
            # S log.event.before
            # S log.event.after
            %clientHash = (
                'log.allow'             => 'allowLogsFlag',
                'log.delete.standard'   => 'deleteStandardLogsFlag',
                'log.delete.world'      => 'deleteWorldLogsFlag',
                'log.start.day'         => 'logDayFlag',
                'log.start.client'      => 'logClientFlag',
                'log.prefix.date'       => 'logPrefixDateFlag',
                'log.prefix.time'       => 'logPrefixTimeFlag',
                'log.image'             => 'logImageFlag',
                'log.event.before'      => 'statusEventBeforeCount',
                'log.event.after'       => 'statusEventAfterCount',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # F log.client.FILE
            } elsif ($second eq 'client') {

                if ($size !=  3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('logPrefHash', $third)) {

                    $error = 'Client logfile named \'' . $third . '\' doesn\'t exist';

                } else {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivShow('logPrefHash', $third);
                    $ivName = 'logPrefHash';
                    $privFlag = TRUE;
                }

            # F log.world.FILE
            } elsif ($second eq 'world') {

                if ($size !=  3) {

                    $error = $genError;

                } elsif (! $self->currentWorld->ivExists('logPrefHash', $third)) {

                    $error = 'World logfile named \'' . $third . '\' doesn\'t exist';

                } else {

                    $blessed = undef;
                    $var = $self->currentWorld->ivShow('logPrefHash', $third);
                    $ivName = 'logPrefHash';
                    $privFlag = TRUE;
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'loop') {

            if ($second eq 'client') {

                # O loop.client.obj
                if ($third eq 'obj') {

                    if ($size > 4) {

                        $error = $genError;

                    } else {

                        $blessed = $axmud::CLIENT->clientLoopObj;
                        $privFlag = $blessed->_privFlag;
                        if (defined $fourth) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                        }
                    }

                # S loop.client.time
                # F loop.client.spin
                # S loop.client.delay
                } else {

                    if ($size != 3) {

                        $error = $genError;

                    } else {

                        $blessed = $axmud::CLIENT;
                        $privFlag = TRUE;

                        if ($third eq 'time') {
                            $ivName = 'clientTime';
                        } elsif ($third eq 'spin') {
                            $ivName = 'clientLoopSpinFlag';
                        } elsif ($third eq 'delay') {
                            $ivName = 'clientLoopDelay';
                        }

                        if ($ivName) {

                            $var = $blessed->{$ivName};

                        } else {

                            $error = $genError;
                        }
                    }
                }

            } elsif ($second eq 'session') {

                # S loop.session.obj
                if ($third eq 'obj') {

                    if ($size > 4) {

                        $error = $genError;

                    } else {

                        $blessed = $self->sessionLoopObj;
                        $privFlag = $blessed->_privFlag;
                        if (defined $fourth) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                        }
                    }

                # S loop.session.time
                # F loop.session.spin
                # F loop.session.child
                # S loop.session.delay
                } else {

                    if ($size != 3) {

                        $error = $genError;

                    } else {

                        $blessed = $self;
                        $privFlag = TRUE;

                        if ($third eq 'time') {
                            $ivName = 'sessionTime';
                        } elsif ($third eq 'spin') {
                            $ivName = 'sessionLoopSpinFlag';
                        } elsif ($third eq 'child') {
                            $ivName = 'childLoopSpinFlag';
                        } elsif ($third eq 'delay') {
                            $ivName = 'sessionLoopDelay';
                        }

                        if ($ivName) {

                            $var = $blessed->{$ivName};

                        } else {

                            $error = $genError;
                        }
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'misc') {

            # L misc.months
            # L misc.days
            if ($size != 2 || ($second ne 'months' && $second ne 'days')) {

                $error = $genError;

            } elsif ($second eq 'months') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{customMonthList};
                $ivName = 'customMonthList';
                $privFlag = TRUE;

            } elsif ($second eq 'days') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{customDayList};
                $ivName = 'customDayList';
                $privFlag = TRUE;
            }

        } elsif ($first eq 'model') {

            if ($size > 4) {

                $error = $genError;

            # s model.author
            # s model.date
            # s model.version
            # l model.descripList
            } elsif (
                $second eq 'author' || $second eq 'date' || $second eq 'version'
                || $second eq 'descripList'
            ) {
                if ($size > 2) {

                    $error = $genError;

                } else {

                    $blessed = $wmObj;
                    $var = $blessed->{$last};
                    $ivName = $last;
                    $privFlag = FALSE;
                }

            # o model.number.NUMBER
            } elsif ($second eq 'number') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('modelHash', $third)) {

                    if ($size == 3) {
                        $error = 'World model object number \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'World model object number \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow('modelHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o model.region.NUMBER
            # o model.room.NUMBER
            # o model.weapon.NUMBER
            # o model.armour.NUMBER
            # o model.garment.NUMBER
            # o model.char.NUMBER
            # o model.minion.NUMBER
            # o model.sentient.NUMBER
            # o model.creature.NUMBER
            # o model.portable.NUMBER
            # o model.decoration.NUMBER
            # o model.custom.NUMBER
            } elsif (
                $second eq 'region' || $second eq 'room' || $second eq 'weapon'
                || $second eq 'armour' || $second eq 'garment' || $second eq 'char'
                || $second eq 'minion' || $second eq 'sentient' || $second eq 'creature'
                || $second eq 'portable' || $second eq 'decoration' || $second eq 'custom'
            ) {
                my $ivType = $second . 'ModelHash';      # e.g. 'regionModelHash'

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists($ivType, $third)) {

                    if ($size == 3) {

                        $error = 'World model ' . $second . ' object number \'' . $string
                            . '\' doesn\'t exist';

                    } else {

                        $error = 'World model ' . $second . ' object number \'' . $obj
                            . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow($ivType, $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O model.regionmap.NAME
            } elsif ($second eq 'regionmap') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('regionmapHash', $third)) {

                    if ($size == 3) {
                        $error = 'World model regionmap object \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'World model regionmap object \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow('regionmapHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o model.kchar.NAME
            } elsif ($second eq 'kchar') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('knownCharHash', $third)) {

                    if ($size == 3) {

                        $error = 'World model known character object \'' . $string
                            . '\' doesn\'t exist';

                    } else {

                        $error = 'World model known character object \'' . $obj
                        . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow('knownCharHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o model.mstring.STRING
            } elsif ($second eq 'mstring') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('minionStringHash', $third)) {

                    if ($size == 3) {
                        $error = 'World model minion string \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'World model minion string \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $wmObj->ivShow('minionStringHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o model.tag.TAG
            } elsif ($second eq 'tag') {

                my $number;

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $wmObj->ivExists('roomTagHash', lc($third))) {

                    if ($size == 3) {
                        $error = 'World model room tag \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'World room tag \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $number = $wmObj->ivShow('roomTagHash', lc($third));
                    $blessed = $wmObj->ivShow('roomModelHash', $number);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # model.newest  - Most recently-created world model object (from GA::Obj::WorldModel)
            } elsif ($second eq 'newest') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! defined $wmObj->mostRecentNum) {

                    $error = 'No recently-created world model object found';

                } else {

                    $blessed = $wmObj->ivShow('modelHash', $wmObj->mostRecentNum);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # S model.count
            } elsif ($second eq 'count') {

                if ($size > 2) {

                    $error = $genError;

                } else {

                    $blessed = $wmObj;
                    $var = $blessed->{modelObjCount};
                    $ivName = 'modelObjCount';
                    $privFlag = TRUE;
                }

            # S model.actual
            } elsif ($second eq 'actual') {

                if ($size > 2) {

                    $error = $genError;

                } else {

                    $blessed = $wmObj;
                    $var = $blessed->{modelActualCount};
                    $ivName = 'modelActualCount';
                    $privFlag = TRUE;
                }

            } else {

                my %checkHash;

                # (The remaining components have a maximum of 2 arguments, and are the same as IVs
                #   in the world model)

                # S model.lightStatus
                # S model.defaultGridWidthBlocks
                # S model.defaultGridHeightBlocks
                # S model.defaultBlockWidthPixels
                # S model.defaultBlockHeightPixels
                # S model.defaultRoomWidthPixels
                # S model.defaultRoomHeightPixels
                # S model.maxGridWidthBlocks
                # S model.maxGridHeightBlocks
                # S model.maxBlockWidthPixels
                # S model.maxBlockHeightPixels
                # S model.maxRoomWidthPixels
                # S model.maxRoomHeightPixels
                # S model.defaultMapWidthPixels
                # S model.defaultMapHeightPixels
                # S model.currentRoomMode
                # S model.roomInteriorMode
                # S model.drawExitMode
                # F model.drawOrnamentsFlag
                # S model.horizontalExitLengthBlocks
                # S model.verticalExitLengthBlocks
                # F model.drawBentExitsFlag
                # F model.matchTitleFlag
                # F model.matchDescripFlag
                # S model.matchDescripCharCount
                # F model.matchExitFlag
                # F model.analyseDescripFlag
                # F model.matchSourceFlag
                # F model.matchVNumFlag
                # F model.updateTitleFlag
                # F model.updateDescripFlag
                # F model.updateExitFlag
                # F model.updateSourceFlag
                # F model.updateVNumFlag
                # F model.updateRoomCmdFlag
                # F model.updateOrnamentFlag
                # S model.autoCompareMode
                # F model.autoCompareAllFlag
                # S model.autoCompareMax
                # S model.autoSlideMode
                # S model.autoSlideMax
                # F model.autoRescueFlag
                # F model.autoRescueFirstFlag
                # F model.autoRescuePromptFlag
                # F model.autoRescueNoMoveFlag
                # F model.autoRescueVisitsFlag
                # F model.autoRescueForceFlag
                # F model.assistedMovesFlag
                # F model.assistedBreakFlag
                # F model.assistedPickFlag
                # F model.assistedUnlockFlag
                # F model.assistedOpenFlag
                # F model.assistedCloseFlag
                # F model.assistedLockFlag
                # F model.protectedMovesFlag
                # F model.superProtectedMovesFlag
                # F model.setTwinOrnamentFlag
                # F model.countVisitsFlag
                # F model.allowModelScriptFlag
                # F model.allowRoomScriptFlag
                # F model.intelligentExitsFlag
                # F model.followAnchorFlag
                # F model.capitalisedRoomTagFlag
                # F model.showTooltipsFlag
                # F model.explainGetLostFlag
                # F model.disableUpdateModeFlag
                # F model.updateExitTagFlag
                # F model.drawRoomEchoFlag
                # F model.trackPosnFlag
                # S model.trackingSensitivity
                # F model.avoidHazardsFlag
                # F model.postProcessingFlag
                # F model.quickPathFindFlag
                # F model.autocompleteExitsFlag
                # S model.mudlibPath
                # S model.mudlibExtension
                # F model.paintAllRoomsFlag
                # F model.locateRandomInRegionFlag
                # F model.locateRandomAnywhereFlag
                # S model.pathFindStepLimit
                %checkHash = (
                    'lightStatus'                   => undef,
                    'defaultGridWidthBlocks'        => undef,
                    'defaultGridHeightBlocks'       => undef,
                    'defaultBlockWidthPixels'       => undef,
                    'defaultBlockHeightPixels'      => undef,
                    'defaultRoomWidthPixels'        => undef,
                    'defaultRoomHeightPixels'       => undef,
                    'maxGridWidthBlocks'            => undef,
                    'maxGridHeightBlocks'           => undef,
                    'maxBlockWidthPixels'           => undef,
                    'maxBlockHeightPixels'          => undef,
                    'maxRoomWidthPixels'            => undef,
                    'maxRoomHeightPixels'           => undef,
                    'defaultMapWidthPixels'         => undef,
                    'defaultMapHeightPixels'        => undef,
                    'currentRoomMode'               => undef,
                    'roomInteriorMode'              => undef,
                    'drawExitMode'                  => undef,
                    'drawOrnamentsFlag'             => undef,
                    'horizontalExitLengthBlocks'    => undef,
                    'verticalExitLengthBlocks'      => undef,
                    'drawBentExitsFlag'             => undef,
                    'matchTitleFlag'                => undef,
                    'matchDescripFlag'              => undef,
                    'matchDescripCharCount'         => undef,
                    'matchExitFlag'                 => undef,
                    'analyseDescripFlag'            => undef,
                    'matchSourceFlag'               => undef,
                    'matchVNumFlag'                 => undef,
                    'updateTitleFlag'               => undef,
                    'updateDescripFlag'             => undef,
                    'updateExitFlag'                => undef,
                    'updateSourceFlag'              => undef,
                    'updateVNumFlag'                => undef,
                    'updateRoomCmdFlag'             => undef,
                    'updateOrnamentFlag'            => undef,
                    'autoCompareMode'               => undef,
                    'autoCompareAllFlag'            => undef,
                    'autoCompareMax'                => undef,
                    'autoSlideMode'                 => undef,
                    'autoSlideMax'                  => undef,
                    'autoRescueFlag'                => undef,
                    'autoRescueFirstFlag'           => undef,
                    'autoRescuePromptFlag'          => undef,
                    'autoRescueNoMoveFlag'          => undef,
                    'autoRescueVisitsFlag'          => undef,
                    'autoRescueForceFlag'           => undef,
                    'assistedMovesFlag'             => undef,
                    'assistedBreakFlag'             => undef,
                    'assistedPickFlag'              => undef,
                    'assistedUnlockFlag'            => undef,
                    'assistedOpenFlag'              => undef,
                    'assistedCloseFlag'             => undef,
                    'assistedLockFlag'              => undef,
                    'protectedMovesFlag'            => undef,
                    'superProtectedMovesFlag'       => undef,
                    'setTwinOrnamentFlag'           => undef,
                    'countVisitsFlag'               => undef,
                    'allowModelScriptFlag'          => undef,
                    'allowRoomScriptFlag'           => undef,
                    'intelligentExitsFlag'          => undef,
                    'followAnchorFlag'              => undef,
                    'capitalisedRoomTagFlag'        => undef,
                    'showTooltipsFlag'              => undef,
                    'explainGetLostFlag'            => undef,
                    'disableUpdateModeFlag'         => undef,
                    'updateExitTagFlag'             => undef,
                    'drawRoomEchoFlag'              => undef,
                    'trackPosnFlag'                 => undef,
                    'trackingSensitivity'           => undef,
                    'avoidHazardsFlag'              => undef,
                    'postProcessingFlag'            => undef,
                    'quickPathFindFlag'             => undef,
                    'autocompleteExitsFlag'         => undef,
                    'mudlibPath'                    => undef,
                    'mudlibExtension'               => undef,
                    'paintAllRoomsFlag'             => undef,
                    'locateRandomInRegionFlag'      => undef,
                    'locateRandomAnywhereFlag'      => undef,
                    'pathFindStepLimit'             => undef,
                );

                if ($size > 2) {

                    $error = $genError;

                } elsif (exists $checkHash{$second}) {

                    $blessed = $wmObj;
                    $var = $blessed->{$second};
                    $ivName = $second;
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'msdp') {

            my (
                $modThird,
                @list,
            );

            # L msdp.generic.list
            # S msdp.generic.VAR
            if ($second eq 'generic') {

                if ($size > 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)} ($self->ivKeys('msdpGenericValueHash'));
                    $var = \@list;
                    $ivName = 'msdpGenericValueHash';
                    $privFlag = TRUE;

                } else {

                    $blessed = undef;
                    $privFlag = TRUE;

                    # Variable names can contain spaces; user must replace them in $string with
                    #   underline characters
                    $modThird = $third;
                    $modThird =~ s/_/ /g;

                    if (
                        ! $self->ivExists('msdpGenericValueHash', $third)
                        && ! $self->ivExists('msdpGenericValueHash', $modThird)
                    ) {
                        $error = 'Generic MSDP variable \'' . $third . '\' not found';

                    } else {

                        if ($self->ivExists('msdpGenericValueHash', $third)) {
                            $var = $self->ivShow('msdpGenericValueHash', $third);
                        } else {
                            $var = $self->ivShow('msdpGenericValueHash', $modThird);
                        }

                        $ivName = 'msdpGenericValueHash';
                    }
                }

            # L msdp.custom.list
            # S msdp.custom.VAR
            } elsif ($second eq 'custom') {

                if ($size > 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)} ($self->ivKeys('msdpCustomValueHash'));
                    $var = \@list;
                    $ivName = 'msdpCustomValueHash';
                    $privFlag = TRUE;

                } else {

                    $blessed = undef;
                    $privFlag = TRUE;

                    $modThird = $third;
                    $modThird =~ s/_/ /g;

                    if (
                        ! $self->ivExists('msdpCustomValueHash', $third)
                        && ! $self->ivExists('msdpCustomValueHash', $modThird)
                    ) {
                        $error = 'Custom MSDP variable \'' . $third . '\' not found';

                    } else {

                        if ($self->ivExists('msdpCustomValueHash', $third)) {
                            $var = $self->ivShow('msdpCustomValueHash', $third);
                        } else {
                            $var = $self->ivShow('msdpCustomValueHash', $modThird);
                        }

                        $ivName = 'msdpCustomValueHash';
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'mssp') {

            my (
                $modThird,
                @list,
            );

            # L mssp.generic.list
            # S mssp.generic.VAR
            if ($second eq 'generic') {

                if ($size > 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)}
                                ($self->currentWorld->ivKeys('msspGenericValueHash'));
                    $var = \@list;
                    $ivName = 'msspGenericValueHash';
                    $privFlag = TRUE;

                } else {

                    $blessed = undef;
                    $privFlag = TRUE;

                    $modThird = $third;
                    $modThird =~ s/_/ /g;

                    if (
                        ! $self->currentWorld->ivExists('msspGenericValueHash', $third)
                        && ! $self->currentWorld->ivExists('msspGenericValueHash', $modThird)
                    ) {
                        $error = 'Generic MSSP variable \'' . $third . '\' not found';

                    } else {

                        if ($self->currentWorld->ivExists('msspGenericValueHash', $third)) {
                            $var = $self->currentWorld->ivShow('msspGenericValueHash', $third);
                        } else {
                            $var = $self->currentWorld->ivShow('msspGenericValueHash', $modThird);
                        }

                        $ivName = 'msspGenericValueHash';
                    }
                }

            # L mssp.custom.list
            # S mssp.custom.VAR
            } elsif ($second eq 'custom') {

                if ($size > 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)}
                                ($self->currentWorld->ivKeys('msspCustomValueHash'));
                    $var = \@list;
                    $ivName = 'msspCustomValueHash';
                    $privFlag = TRUE;

                } else {

                    $blessed = undef;
                    $privFlag = TRUE;

                    $modThird = $third;
                    $modThird =~ s/_/ /g;

                    if (
                        ! $self->currentWorld->ivExists('msspCustomValueHash', $third)
                        && ! $self->currentWorld->ivExists('msspCustomValueHash', $modThird)
                    ) {
                        $error = 'Custom MSSP variable \'' . $third . '\' not found';

                    } else {

                        if ($self->currentWorld->ivExists('msspCustomValueHash', $third)) {
                            $var = $self->currentWorld->ivShow('msspCustomValueHash', $third);
                        } else {
                            $var = $self->currentWorld->ivShow('msspCustomValueHash', $modThird);
                        }

                        $ivName = 'msspCustomValueHash';
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'mxp') {

            my @list;

            # L mxp.entity.list
            # S mxp.entity.VAR
            if ($second eq 'entity') {

                my $obj;

                if ($size != 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)} ($self->ivKeys('mxpEntityHash'));
                    $var = \@list;
                    $ivName = 'mxpEntityHash';
                    $privFlag = TRUE;

                } else {

                    if ($self->ivExists('mxpEntityHash', $third)) {

                        $obj = $self->ivShow('mxpEntityHash', $third);
                        if ($obj->privateFlag) {

                            $error = 'MXP entity \'' . $third . '\' is marked private';

                        } else {

                            $blessed = undef;
                            $var = $obj->value;
                            $ivName = 'mxpEntityHash';
                            $privFlag = TRUE;
                        }

                    } elsif ($axmud::CLIENT->ivExists('constMxpEntityHash', $third)) {

                        $blessed = undef;
                        $var = $axmud::CLIENT->ivShow('constMxpEntityHash', $third);
                        $ivName = 'constMxpEntityHash';
                        $privFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'plugin') {

            # L plugin.init
            if ($second eq 'init') {

                if ($size != 2) {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{initPluginList};
                    $ivName = 'initPluginList';
                    $privFlag = TRUE;
                }

            # L plugin.load
            } elsif ($second eq 'load') {

                my @list;

                if ($size != 2) {

                    $error = $genError;

                } else {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)} ($axmud::CLIENT->ivKeys('pluginHash'));
                    $var = \@list;
                    $ivName = 'pluginHash';
                    $privFlag = TRUE;
                }

            # O plugin.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('pluginHash', $second)) {

                    if ($size == 2) {
                        $error = 'Plugin object named \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Pluging object named \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('pluginHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'prof') {

            # L prof.priority.list
            if ($string eq 'prof.priority.list') {

                $blessed = $self;
                $var = $self->{profPriorityList};
                $ivName = 'profPriorityList';
                $privFlag = TRUE;

            # o prof.NAME
            } elsif ($size > 3) {

                $error = $genError;

            } else {

                if ($self->ivExists('profHash', $second)) {
                    $blessed = $self->ivShow('profHash', $second);
                } else {
                    $blessed = $axmud::CLIENT->ivShow('worldProfHash', $second);
                }

                $privFlag = $blessed->_privFlag;

                if (! $blessed) {

                    if ($size == 2) {
                        $error = 'Profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Profile \'' . $obj . '\' doesn\'t exist';
                    }

                } elsif (defined $third) {

                    $var = $blessed->{$last};
                    $ivName = $last;

                } else {

                    $objFlag = TRUE;
                }
            }

        } elsif ($first eq 'protocol') {

            my (%clientHash, %sessionHash);

            # Save a lot of lines of code by loading IV names/values into hashes

            # F protocol.echo
            # F protocol.sga
            # F protocol.ttype
            # F protocol.eor
            # F protocol.naws
            # F protocol.newenviron
            # F protocol.charset
            # S protocol.ttype.mode
            # S protocol.ttype.name
            # S protocol.ttype.version
            #
            # F protocol.msdp
            # F protocol.mssp
            # F protocol.mccp
            # F protocol.msp
            # F protocol.mxp
            # F protocol.pueblo
            # F protocol.zmp
            # F protocol.aard102
            # F protocol.atcp
            # F protocol.gmcp
            # F protocol.mtts
            # F protocol.mnes
            # F protocol.mcp
            #
            # F protocol.msp.multiple
            # F protocol.msp.load
            # F protocol.msp.flexible
            #
            # F protocol.mxp.font
            # F protocol.mxp.image.allow
            # F protocol.mxp.image.load
            # F protocol.mxp.image.filter
            # F protocol.mxp.sound.allow
            # F protocol.mxp.sound.load
            # F protocol.mxp.gauge
            # F protocol.mxp.frame
            # F protocol.mxp.interior
            # F protocol.mxp.crosslink
            # F protocol.mxp.room
            %clientHash = (
                'protocol.echo'         => 'useEchoFlag',
                'protocol.sga'          => 'useSgaFlag',
                'protocol.ttype'        => 'useTTypeFlag',
                'protocol.eor'          => 'useEorFlag',
                'protocol.naws'         => 'useNawsFlag',
                'protocol.newenviron'   => 'useNewEnvironFlag',
                'protocol.charset'      => 'useCharSetFlag',
                'protocol.ttype.mode'   => 'termTypeMode',
                'protocol.ttype.name'   => 'customClientName',
                'protocol.ttype.version'
                                        => 'customClientVersion',

                'protocol.msdp'         => 'useMsdpFlag',
                'protocol.mssp'         => 'useMsspFlag',
                'protocol.mccp'         => 'useMccpFlag',
                'protocol.msp'          => 'useMspFlag',
                'protocol.mxp'          => 'useMxpFlag',
                'protocol.pueblo'       => 'usePuebloFlag',
                'protocol.zmp'          => 'useZmpFlag',
                'protocol.aard102'      => 'useAard102Flag',
                'protocol.atcp'         => 'useAtcpFlag',
                'protocol.gmcp'         => 'useGmcpFlag',
                'protocol.mtts'         => 'useMttsFlag',
                'protocol.mnes'         => 'useMnesFlag',
                'protocol.mcp'          => 'useMcpFlag',

                'protocol.msp.multiple' => 'allowMspMultipleFlag',
                'protocol.msp.load'     => 'allowMspLoadSoundFlag',
                'protocol.msp.flexible' => 'allowMspFlexibleFlag',

                'protocol.mxp.font'     => 'allowMxpFontFlag',
                'protocol.mxp.image.allow'
                                        => 'allowMxpImageFlag',
                'protocol.mxp.image.load'
                                        => 'allowMxpLoadImageFlag',
                'protocol.mxp.image.filter'
                                        => 'allowMxpFilterImageFlag',
                'protocol.mxp.sound.allow'
                                        => 'allowMxpSoundFlag',
                'protocol.mxp.sound.load'
                                        => 'allowMxpLoadSoundFlag',
                'protocol.mxp.gauge'    => 'allowMxpGaugeFlag',
                'protocol.mxp.frame'    => 'allowMxpFrameFlag',
                'protocol.mxp.interior' => 'allowMxpInteriorFlag',
                'protocol.mxp.crosslink'
                                        => 'allowMxpCrosslinkFlag',
                'protocol.mxp.room'     => 'allowMxpRoomFlag',
                'protocol.mxp.flexible' => 'allowMxpFlexibleFlag',
            );

            # S protocol.ttype.sent
            #
            # S protocol.mode.echo
            # S protocol.mode.sga
            # S protocol.mode.eor
            # S protocol.mode.naws
            # S protocol.mode.msdp
            # S protocol.mode.mssp
            # S protocol.mode.mccp
            # S protocol.mode.msp
            # S protocol.mode.mxp
            # S protocol.mode.pueblo
            # S protocol.mode.zmp
            # S protocol.mode.aard102
            # S protocol.mode.atcp
            # S protocol.mode.gmcp
            # S protocol.mode.mcp
            %sessionHash = (
                'protocol.ttype.sent'   => 'specifiedTType',

                'protocol.mode.echo'    => 'echoMode',
                'protocol.mode.sga'     => 'sgaMode',
                'protocol.mode.eor'     => 'eorMode',
                'protocol.mode.naws'    => 'nawsMode',
                'protocol.mode.msdp'    => 'msdpMode',
                'protocol.mode.mssp'    => 'msspMode',
                'protocol.mode.mccp'    => 'mccpMode',
                'protocol.mode.msp'     => 'mspMode',
                'protocol.mode.mxp'     => 'mxpMode',
                'protocol.mode.pueblo'
                                        => 'puebloMode',
                'protocol.mode.zmp'     => 'zmpMode',
                'protocol.mode.aard102' => 'aard102Mode',
                'protocol.mode.atcp'    => 'atcpMode',
                'protocol.mode.gmcp'    => 'gmcpMode',
                'protocol.mode.mcp'     => 'mcpMode',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            } elsif (exists $sessionHash{$string}) {

                $blessed = $self;
                $ivName = $sessionHash{$string};
                $var = $self->{$ivName};
                $privFlag = TRUE;

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'race') {

            # o race.current
            if ($second eq 'current') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->currentRace) {

                    $error = 'No current race profile set';

                } else {

                    $blessed = $self->currentRace;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o race.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->ivExists('profHash', $second)) {

                    if ($size == 2) {
                        $error = 'Race profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Race profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('profHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if ($blessed->category ne 'race') {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a race profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a race profile';
                        }

                    } elsif (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'recording') {

            # F recording.active
            # F recording.paused
            # L recording.current
            # F recording.position
            if ($size != 2) {

                $error = $genError;

            } else {

                if ($second eq 'active') {
                    $ivName = 'recordingFlag';
                } elsif ($second eq 'paused') {
                    $ivName = 'recordingPausedFlag';
                } elsif ($second eq 'current') {
                    $ivName = 'recordingList';
                } elsif ($second eq 'position') {
                    $ivName = 'recordingPosn';
                }

                if ($ivName) {

                    $blessed = $self;
                    $var = $blessed->{$ivName};
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'regex') {

            # S regex.url.long
            # S regex.url.short
            # S regex.email
            if ($size < 2 || $size > 3) {

                $error = $genError;

            } else {

                if ($second eq 'url') {

                    if (! $third) {
                        $error = $genError;
                    } elsif ($third eq 'long') {
                        $ivName = 'constUrlRegex';
                    } elsif ($third eq 'short') {
                        $ivName = 'constShortUrlRegex';
                    } else {
                        $error = $genError;
                    }

               } elsif ($second eq 'email') {

                    if ($size > 2) {
                        $error = $genError;
                    } else {
                        $ivName = 'constEmailRegex';
                    }
                }

                if ($ivName) {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{$ivName};
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'room') {

            # o room.map
            if ($second eq 'map') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->mapObj->currentRoom) {

                    $error = 'No current room set in automapper';

                } else {

                    $blessed = $self->mapObj->currentRoom;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o room.last
            } elsif ($second eq 'last') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->mapObj->lastKnownRoom) {

                    $error = 'No last known room set in automapper';

                } else {

                    $blessed = $self->mapObj->lastKnownRoom;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o room.ghost
            } elsif ($second eq 'ghost') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->mapObj->ghostRoom) {

                    $error = 'No ghost room set in automapper';

                } else {

                    $blessed = $self->mapObj->ghostRoom;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # o room.locator
            } elsif ($second eq 'locator') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->locatorTask) {

                    $error = 'Locator task not running, current location unknown';


                } elsif (! $self->locatorTask->roomObj) {

                    $error = 'Locator task\'s current room not set';

                } else {

                    $blessed = $self->locatorTask->roomObj;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'script') {

            # L script.init.list
            # S script.init.NAME
            if ($size != 3) {

                $error = $genError;

            } else {

                if ($second eq 'init') {

                    if ($third eq 'list') {

                        $blessed = $axmud::CLIENT;
                        $var = $blessed->{initScriptOrderList};
                        $ivName = 'initScriptOrderList';
                        $privFlag = TRUE;

                    } elsif (! $axmud::CLIENT->ivExists('initScriptHash', $third)) {

                        $error = 'Initial script \'' . $third . '\' hasn\'t been added';

                    } else {

                        $blessed = undef;
                        $var = $axmud::CLIENT->ivShow('initScriptHash', $third);
                        $ivName = 'initScriptHash';
                        $privFlag = TRUE;
                    }

                } else {

                    $error = $genError;
                }
            }

        } elsif ($first eq 'session') {

            my (%sessionHash, %objHash);

            # Save a lot of lines of code by loading IV names/values into hashes

            # S session.number
            # S session.charset
            # F session.login
            # S session.queue
            # F session.quit
            # F session.freeze
            # S session.host
            # S session.port
            # S session.protocol
            # S session.status
            # S session.packets
            # S session.time.display
            # S session.time.instruct
            # S session.time.cmd
            # S session.redirect.mode
            # S session.redirect.string
            %sessionHash = (
                'session.number'        => 'number',
                'session.charset'       => 'sessionCharSet',
                'session.login'         => 'loginFlag',
                'session.queue'         => 'excessCmdList',
                'session.quit'          => 'delayedQuitTime',
                'session.freeze'        => 'freezeTaskLoopFlag',
                'session.host'          => 'host',
                'session.port'          => 'port',
                'session.protocol'      => 'protocol',
                'session.status'        => 'status',
                'session.packets'       => 'packetCount',
                'session.time.display'  => 'lastDisplayTime',
                'session.time.instruct' => 'lastInstructTime',
                'session.time.cmd'      => 'lastCmdTime',
                'session.time.outbounds'
                                        => 'lastOutBoundsTime',
                'session.redirect.mode' => 'redirectMode',
                'session.redirect.string'
                                        => 'redirectString',
            );

            # O session.mainwin
            #       - Current 'main' window object (GA::Session->mainWin)
            # O session.mapwin
            #       - Current session's automapper window (GA::Session->mapWin)
            # O session.map
            #       - Current session's automapper object (GA::Session->mapObj)
            # O session.model
            #       - Current session's world model (GA::Session->worldModelObj)
            # O session.history
            #       - Current session's connection history object (GA::Session->connectHistoryObj)
            # O session.mission
            #       - Current session's current mission (GA::Session->currentMission)
            %objHash = (
                'mainwin'               => 'mainWin',
                'mapwin'                => 'mapWin',
                'map'                   => 'mapObj',
                'model'                 => 'worldModelObj',
                'history'               => 'connectHistoryObj',
                'mission'               => 'currentMission',
            );

            if (exists $sessionHash{$string}) {

                $blessed = $self;
                $ivName = $sessionHash{$string};
                $var = $self->{$ivName};
                $privFlag = TRUE;

            } elsif (exists $objHash{$second}) {

                if ($size > 3) {

                    $error = $genError;

                } else {

                    $blessed = $self->{$objHash{$second}};
                    if (! $blessed) {

                        $error = 'Object \'' . $string . '\' not set';

                    } else {

                        $privFlag = $blessed->_privFlag;

                        if (defined $third) {

                            $var = $blessed->{$last};
                            $ivName = $last;

                        } else {

                            $objFlag = TRUE;
                        }
                    }
                }

            # S session.max
            } elsif ($string eq 'session.max') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{sessionMax};
                $ivName = 'sessionMax';
                $privFlag = TRUE;

            # O session.obj.NUMBER
            } elsif ($second eq 'obj') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('sessionHash', $third)) {

                    if ($size == 3) {
                        $error = 'Session \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Session \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('sessionHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O session.tab.default
            # O session.tab.default.width
            # O session.tab.default.height
            # O session.tab.current
            # O session.tab.current.width
            # O session.tab.current.height
            } elsif ($second eq 'tab') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } else {

                    if ($third eq 'default') {

                        if ($fourth eq 'width') {

                            $blessed = $self->defaultTabObj->textViewObj;
                            $ivName = 'textWidthChars';
                            $var = $blessed->{$ivName};
                            $privFlag = TRUE;

                        } elsif ($fourth eq 'height') {

                            $blessed = $self->defaultTabObj->textViewObj;
                            $ivName = 'textHeightChars';
                            $var = $blessed->{$ivName};
                            $privFlag = TRUE;

                        } else {

                            $blessed = $self->defaultTabObj;
                            $privFlag = $blessed->_privFlag;
                            if (defined $fourth) {

                                $var = $blessed->{$last};
                                $ivName = $last;

                            } else {

                                $objFlag = TRUE;
                            }
                        }

                    } elsif ($third eq 'current') {

                        if ($fourth eq 'width') {

                            $blessed = $self->currentTabObj->textViewObj;
                            $ivName = 'textWidthChars';
                            $var = $blessed->{$ivName};
                            $privFlag = TRUE;

                        } elsif ($fourth eq 'height') {

                            $blessed = $self->currentTabObj->textViewObj;
                            $ivName = 'textHeightChars';
                            $var = $blessed->{$ivName};
                            $privFlag = TRUE;

                        } else {

                            $blessed = $self->currentTabObj;
                            $privFlag = $blessed->_privFlag;
                            if (defined $fourth) {

                                $var = $blessed->{$last};
                                $ivName = $last;

                            } else {

                                $objFlag = TRUE;
                            }
                        }
                    }

                    if (! $blessed) {

                        $error = $genError;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'sound') {

            my @list;

            # L sound.format
            if ($string eq 'sound.format') {

                $blessed = undef;
                @list = sort {lc($a) cmp lc($b)} ($axmud::CLIENT->ivKeys('constSoundFormatHash'));
                $var = \@list;
                $ivName = 'constSoundFormatHash';
                $privFlag = TRUE;

            # F sound.allow
            } elsif ($string eq 'sound.allow') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{allowSoundFlag};
                $ivName = 'allowSoundFlag';
                $privFlag = TRUE;

            # F sound.ascii
            } elsif ($string eq 'sound.ascii') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{allowAsciiBellFlag};
                $ivName = 'allowAsciiBellFlag';
                $privFlag = TRUE;

            # L sound.effect.list
            # L sound.effect.NAME
            } elsif ($second eq 'effect') {

                if ($size != 3) {

                    $error = $genError;

                } elsif ($third eq 'list') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)}
                                ($axmud::CLIENT->ivKeys('customSoundHash'));
                    $var = \@list;
                    $ivName = 'customSoundHash';
                    $privFlag = TRUE;

                } elsif (! $axmud::CLIENT->ivExists('customSoundHash', $third)) {

                    $error = 'Sound effect \'' . $string . '\' doesn\'t exist';

                } else {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivShow('customSoundHash', $third);
                    $ivName = 'customSoundHash';
                    $privFlag = TRUE;
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'task') {

            # O task.name.NAME
            if ($second eq 'name') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('currentTaskHash', $third)) {

                    if ($size == 3) {
                        $error = 'Current task with unique name \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Current task with unique name \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('currentTaskHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O task.recent.NAME
            } elsif ($second eq 'recent') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $self->ivExists('currentTaskNameHash', $third)) {

                    if ($size == 3) {
                        $error = 'Current task with formal name \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Current task with formal name \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $self->ivShow('currentTaskNameHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O task.init.NAME
            } elsif ($second eq 'init') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('initTaskHash', $third)) {

                    if ($size == 3) {

                        $error = 'Global initial tasklist task with name \'' . $string
                                    . '\' doesn\'t exist';

                    } else {

                        $error = 'Global initial tasklist task with name \'' . $obj
                                    . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('initTaskHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O task.custom.NAME
            } elsif ($second eq 'custom') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('customTaskHash', $third)) {

                    if ($size == 3) {
                        $error = 'Custom task with name \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Custom task with name \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('customTaskHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # S task.package.NAME
            } elsif ($second eq 'package') {

                if ($size != 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('taskPackageHash', $third)) {

                    $error = 'Task package name \'' . $string . '\' doesn\'t exist';

                } else {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivShow('taskPackageHash', $third);
                    $ivName = 'taskPackageHash';
                    $privFlag = TRUE;
                }

            # S task.label.LABEL
            } elsif ($second eq 'label') {

                if ($size != 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('taskLabelHash', $third)) {

                    $error = 'Task label \'' . $string . '\' doesn\'t exist';

                } else {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivShow('taskLabelHash', $third);
                    $ivName = 'taskLabelHash';
                    $privFlag = TRUE;
                }

            # L task.runlist.first
            # L task.runlist.last
            } elsif ($second eq 'runlist') {

                if ($size != 3) {

                    $error = $genError;

                } elsif ($third eq 'first') {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{taskRunFirstList};
                    $ivName = 'taskRunFirstList';
                    $privFlag = TRUE;

                } elsif ($third eq 'last') {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{taskRunLastList};
                    $ivName = 'taskRunLastList';
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }

            # O task.current.advance
            # O task.current.attack
            # O task.current.channels
            # O task.current.chat
            # O task.current.compass
            # O task.current.condition
            # O task.current.divert
            # O task.current.inventory
            # O task.current.launch
            # O task.current.locator
            # O task.current.notepad
            # O task.current.rawtext
            # O task.current.rawtoken
            # O task.current.status
            # O task.current.system
            # O task.current.tasklist
            # O task.current.watch
            } elsif ($second eq 'current') {

                my $taskIV;

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } else {

                    if (
                        $third eq 'advance' || $third eq 'attack' || $third eq 'channels'
                        || $third eq 'chat' || $third eq 'compass' || $third eq 'condition'
                        || $third eq 'divert' || $third eq 'inventory' || $third eq 'launch'
                        || $third eq 'locator' || $third eq 'notepad' || $third eq 'rawtext'
                        || $third eq 'rawtoken' || $third eq 'status' || $third eq 'system'
                        || $third eq 'tasklist' || $third eq 'watch'
                    ) {
                        if ($third eq 'rawtext') {
                            $taskIV = 'rawText';
                        } elsif ($third eq 'rawtoken') {
                            $taskIV = 'rawToken';
                        } elsif ($third eq 'tasklist') {
                            $taskIV = 'taskListTask';
                        } else {
                            $taskIV = $third . 'Task';
                        }

                        if (! $self->$taskIV) {

                            $error = 'No current ' . ucfirst($third) . ' task';

                        } else {

                            $blessed = $self->$taskIV;
                            $privFlag = $blessed->_privFlag;
                            if (defined $fourth) {

                                $var = $blessed->{$last};
                                $ivName = $last;

                            } else {

                                $objFlag = TRUE;
                            }
                        }

                    } else {

                        $error = $genError;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'template') {

            # o template.NAME
            if ($size > 3) {

                $error = $genError;

            } elsif (! $self->ivExists('templateHash', $second)) {

                if ($size == 2) {
                    $error = 'Template \'' . $string . '\' doesn\'t exist';
                } else {
                    $error = 'Template \'' . $obj . '\' doesn\'t exist';
                }

            } else {

                $blessed = $self->ivShow('templateHash', $second);
                $privFlag = $blessed->_privFlag;
                if (defined $third) {

                    $var = $blessed->{$last};
                    $ivName = $last;

                } else {

                    $objFlag = TRUE;
                }
            }

        } elsif ($first eq 'toolbar') {

            # L toolbar.list
            if ($second eq 'list') {

                if ($size != 2 ) {

                    $error = $genError;

                } else {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{toolbarList};
                    $ivName = 'toolbarList';
                    $privFlag = TRUE;
                }

            # o toolbar.NAME
            } elsif ($size > 3) {

                $error = $genError;

            } elsif (! $axmud::CLIENT->ivExists('toolbarHash', $second)) {

                if ($size == 2) {
                    $error = 'Toolbar button \'' . $string . '\' doesn\'t exist';
                } else {
                    $error = 'Toolbar button \'' . $obj . '\' doesn\'t exist';
                }

            } else {

                $blessed = $axmud::CLIENT->ivShow('toolbarHash', $second);
                $privFlag = $blessed->_privFlag;
                if (defined $third) {

                    $var = $blessed->{$last};
                    $ivName = $last;

                } else {

                    $objFlag = TRUE;
                }
            }

        } elsif ($first eq 'tts') {

            my (
                @list,
                %clientHash,
            );

            # Save a lot of lines of code by loading IV names/values into hashes

            # F tts.flag.allow
            # F tts.flag.smooth
            # L tts.engine.list
            # F tts.enable.receive
            # F tts.flag.login
            # F tts.enable.system
            # F tts.enable.error
            # F tts.enable.cmd
            # F tts.enable.dialogue
            # F tts.enable.task
            # F tts.enable.verbose
            %clientHash = (
                'tts.flag.allow'        => 'customAllowTTSFlag',
                'tts.flag.smooth'       => 'ttsSmoothFlag',
                'tts.engine.list'       => 'constTTSList',
                'tts.enable.receive'    => 'ttsReceiveFlag',
                'tts.flag.login'        => 'ttsLoginFlag',
                'tts.enable.system'     => 'ttsSystemFlag',
                'tts.enable.error'      => 'ttsSystemErrorFlag',
                'tts.enable.cmd'        => 'ttsWorldCmdFlag',
                'tts.enable.dialogue'   => 'ttsDialogueFlag',
                'tts.enable.task'       => 'ttsTaskFlag',
                'tts.enable.verbose'    => 'ttsVerboseFlag',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # L tts.obj.list
            # o tts.obj.NAME
            } elsif ($second eq 'obj') {

                if ($third eq 'list') {

                    if ($size != 3) {

                        $error = $genError;

                    } else {

                        $blessed = undef;
                        @list = sort {lc($a) cmp lc($b)} ($axmud::CLIENT->ivKeys('ttsObjHash'));
                        $var = \@list;
                        $ivName = 'ttsObjHash',
                        $privFlag = TRUE;
                    }

                } elsif ($size < 3) {

                    $error = $genError;

                } elsif ($size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('ttsObjHash', $third)) {

                    if ($size == 3) {
                        $error = 'TTS configuration object \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'TTS configuration object \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('ttsObjHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'window') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # F window.main.share
            # S window.main.width
            # S window.main.height
            # S window.grid.width
            # S window.grid.height
            # S window.text.size
            # L window.charset.list
            # S window.mode.tab
            # F window.mode.xterm
            # F window.mode.long
            # F window.mode.simple
            # F window.mode.toolbar
            # F window.mode.irreversible
            # F window.mode.urgency
            # F window.mode.tooltip
            # F window.confirm.close
            # F window.confirm.tab
            # F window.keys.scroll
            # F window.keys.smooth
            # F window.keys.split
            # F window.keys.complete
            # F window.keys.switch
            %clientHash = (
                'window.main.share'     => 'shareMainWinFlag',
                'window.main.width'     => 'customMainWinWidth',
                'window.main.height'    => 'customMainWinHeight',
                'window.grid.width'     => 'customGridWinWidth',
                'window.grid.height'    => 'customGridWinHeight',
                'window.text.size'      => 'customTextBufferSize',
                'window.charset.current'
                                        => 'charSet',
                'window.charset.list'   => 'charSetList',
                'window.mode.tab'       => 'sessionTabMode',
                'window.mode.xterm'     => 'xTermTitleFlag',
                'window.mode.long'      => 'longTabLabelFlag',
                'window.mode.simple'    => 'simpleTabFlag',
                'window.mode.toolbar'   => 'toolbarLabelFlag',
                'window.mode.irreversible'
                                        => 'irreversibleIconFlag',
                'window.mode.urgency'   => 'mainWinUrgencyFlag',
                'window.mode.tooltip'   => 'mainWinTooltipFlag',
                'window.confirm.close'  => 'confirmCloseMainWinFlag',
                'window.confirm.tab'    => 'confirmCloseTabFlag',
                'window.keys.scroll'    => 'useScrollKeysFlag',
                'window.keys.smooth'    => 'smoothScrollKeysFlag',
                'window.keys.split'     => 'autoSplitKeysFlag',
                'window.keys.complete'  => 'useCompleteKeysFlag',
                'window.keys.switch'    => 'useSwitchKeysFlag',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # O window.grid.NUMBER
            } elsif ($second eq 'grid') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->desktopObj->ivExists('gridWinHash', $third)) {

                    if ($size == 3) {
                        $error = '\'Grid\' window \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = '\'Grid\' window \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->desktopObj->ivShow('gridWinHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O window.free.NUMBER
            } elsif ($second eq 'free') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->desktopObj->ivExists('freeWinHash', $third)) {

                    if ($size == 3) {
                        $error = '\'Free\' window \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = '\'Free\' window \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->desktopObj->ivShow('freeWinHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'winmap') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # S winmap.default.enabled
            # S winmap.default.disabled
            # S winmap.default.internal
            %clientHash = (
                'winmap.default.enabled'
                                        => 'defaultEnabledWinmap',
                'winmap.default.disabled'
                                        => 'defaultDisabledWinmap',
                'winmap.default.internal'
                                        => 'defaultInternalWinmap',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # O winmap.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('winmapHash', $second)) {

                    if ($size == 2) {
                        $error = 'Winmap \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Winmap \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('winmapHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'workspace') {

            my %clientHash;

            # Save a lot of lines of code by loading IV names/values into hashes

            # S workspace.dir
            # F workspace.grid.activate
            # F workspace.grid.permit
            # S workspace.grid.block
            # S workspace.grid.gap
            # F workspace.grid.adjust
            # F workspace.grid.correct
            # F workspace.grid.reshuffle
            # F workspace.grid.invisible
            %clientHash = (
                'workspace.dir'         => 'initWorkspaceDir',
                'workspace.grid.activate'
                                        => 'activateGridFlag',
                'workspace.grid.permit' => 'gridPermitFlag',
                'workspace.grid.block'  => 'gridBlockSize',
                'workspace.grid.gap'    => 'gridGapMaxSize',
                'workspace.grid.adjust' => 'gridAdjustmentFlag',
                'workspace.grid.correct'
                                        => 'gridEdgeCorrectionFlag',
                'workspace.grid.reshuffle'
                                        => 'gridReshuffleFlag',
                'workspace.grid.invisible'
                                        => 'gridInvisWinFlag',
            );

            if (exists $clientHash{$string}) {

                $blessed = $axmud::CLIENT;
                $ivName = $clientHash{$string};
                $var = $axmud::CLIENT->{$ivName};
                $privFlag = TRUE;

            # S workspace.init.count
            # S workspace.init.NUMBER
            } elsif ($second eq 'init') {

                if ($size != 3) {

                    $error = $genError;

                } elsif ($third eq 'count') {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivPairs('initWorkspaceHash');
                    $ivName = 'initWorkspaceHash';
                    $privFlag = TRUE;

                } elsif (! $axmud::CLIENT->ivExists('initWorkspaceHash', $third)) {

                    if ($size == 3) {
                        $error = 'Initial workspace \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Initial workspace \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = undef;
                    $var = $axmud::CLIENT->ivShow('initWorkspaceHash', $third);
                    $ivName = 'initWorkspaceHash';
                    $privFlag = TRUE;
                }

            # O workspace.obj.NUMBER
            } elsif ($second eq 'obj') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->desktopObj->ivExists('workspaceHash', $third)) {

                    if ($size == 3) {
                        $error = 'Workspace object \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Workspace object \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->desktopObj->ivShow('workspaceHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # O workspace.grid.NUMBER
            } elsif ($second eq 'grid') {

                if ($size < 3 || $size > 4) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->desktopObj->ivExists('gridHash', $third)) {

                    if ($size == 3) {
                        $error = 'Workspace grid \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'Workspace grid \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->desktopObj->ivShow('gridHash', $third);
                    $privFlag = $blessed->_privFlag;
                    if (defined $fourth) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'world') {

            # o world.current
            if ($second eq 'current') {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $self->currentWorld) {

                    $error = 'No current world profile set';

                } else {

                    $blessed = $self->currentWorld;
                    $privFlag = $blessed->_privFlag;
                    if (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }

            # L world.list.favourite
            # L world.list.favorite
            # L world.list.basic
            } elsif ($second eq 'list') {

                my @list;

                if ($size != 3) {

                    $error = $genError;

                } elsif ($third eq 'favourite' || $third eq 'favorite') {

                    $blessed = $axmud::CLIENT;
                    $var = $blessed->{favouriteWorldList};
                    $ivName = 'favouriteWorldList';
                    $privFlag = TRUE;

                } elsif ($third eq 'basic') {

                    $blessed = undef;
                    @list = sort {lc($a) cmp lc($b)}
                                ($axmud::CLIENT->ivKeys('constBasicWorldHash'));
                    $var = \@list;
                    $ivName = 'constBasicWorldHash';
                    $privFlag = TRUE;

                } else {

                    $error = $genError;
                }

            # F world.flag.history
            } elsif ($string eq 'world.flag.history') {

                $blessed = $axmud::CLIENT;
                $var = $blessed->{connectHistoryFlag};
                $ivName = 'connectHistoryFlag';
                $privFlag = TRUE;

            # o world.NAME
            } else {

                if ($size > 3) {

                    $error = $genError;

                } elsif (! $axmud::CLIENT->ivExists('worldProfHash', $second)) {

                    if ($size == 2) {
                        $error = 'World profile \'' . $string . '\' doesn\'t exist';
                    } else {
                        $error = 'World profile \'' . $obj . '\' doesn\'t exist';
                    }

                } else {

                    $blessed = $axmud::CLIENT->ivShow('worldProfHash', $second);
                    $privFlag = $blessed->_privFlag;
                    if ($blessed->category ne 'world') {

                        if ($size == 2) {
                            $error = 'Profile \'' . $string . '\' not a world profile';
                        } else {
                            $error = 'Profile \'' . $obj . '\' not a world profile';
                        }

                    } elsif (defined $third) {

                        $var = $blessed->{$last};
                        $ivName = $last;

                    } else {

                        $objFlag = TRUE;
                    }
                }
            }

        } elsif ($first eq 'zmp') {

            if ($second eq 'list') {

                my @list;

                # L zmp.list
                # L zmp.list.PACKAGE
                # L zmp.list.PACKAGE.SUBPACKAGE
                if ($size < 2) {

                    $error = $genError;

                } elsif ($size == 3) {

                    @list = sort {lc($a->name) cmp lc($b->name)}
                                ($axmud::CLIENT->ivValues('zmpPackageHash'));

                    $blessed = undef;
                    $var = \@list;
                    $ivName = 'zmpPackageHash';
                    $privFlag = TRUE;

                } else {

                    @list = @compList;
                    shift @list;
                    shift @list;

                    $blessed = $axmud::CLIENT->ivShow(
                        'zmpPackageHash',
                        join('.', @list) . '@' . $self->currentWorld->name,
                    );

                    if (! $blessed) {

                        $blessed = $axmud::CLIENT->ivShow(
                            'zmpPackageHash',
                            join('.', @list) . '@',
                        );
                    }

                    if (! $blessed) {

                        $error = 'ZMP package \'' . $string . '\' doesn\'t exist';

                    } else {

                        $var = $blessed->{cmdHash};
                        $ivName = 'cmdHash';
                        $privFlag = TRUE;
                    }
                }

            } else {

                $error = $genError;
            }

        } elsif ($first eq 'zonemap') {

            # O zonemap.NAME
            if ($size > 3) {

                $error = $genError;

            } elsif (! $axmud::CLIENT->ivExists('zonemapHash', $second)) {

                if ($size == 2) {
                    $error = 'Zonemap \'' . $string . '\' doesn\'t exist';
                } else {
                    $error = 'Zonemap \'' . $obj . '\' doesn\'t exist';
                }

            } else {

                $blessed = $axmud::CLIENT->ivShow('zonemapHash', $second);
                $privFlag = $blessed->_privFlag;
                if (defined $third) {

                    $var = $blessed->{$last};
                    $ivName = $last;

                } else {

                    $objFlag = TRUE;
                }
            }

        } else {

            $error = $genError;
        }

        # If $ivName is set, we need to check whether the instance variable actually exists
        if (! $error && defined $blessed && defined $ivName && ! ($blessed->ivMember($ivName)) ) {

            $error = 'Non-existent property (IV) \'' . $ivName . '\'';
        }

        # IVs in GA::Client->constIVHash, which exist in every Axmud object, can be peeked but never
        #   poked
        if (defined $ivName && $axmud::CLIENT->ivExists('constIVHash', $ivName)) {

            $privFlag = TRUE;
        }

        # If returning a value that's a list or hash reference, $var might contain the original
        #   reference, or not (depending on which $string was specified)
        # In either case, we don't want to return a reference that's stored in an IV, because the
        #   calling code might try to modify it. Instead, create a new, independent list/hash
        #   reference, and return that
        if (! $error && defined $var) {

            if ((ref $var) eq 'ARRAY') {

                @deRefList = @$var;
                $var = \@deRefList;

            } elsif ((ref $var) eq 'HASH') {

                %deRefHash = %$var;
                $var = \%deRefHash;
            }
        }

        if (defined $error) {
            return (undef, $error);
        } else {
            return (1, $blessed, $ivName, $var, $objFlag, $privFlag);
        }
    }

    sub peek {

        # Can be called by anything, but mostly called by $self->perlCmd
        # A quick way to do peeking/poking. A call to $self->parsePeekPoke returns a list of 6
        #   items, which is more than $self->perlCmd needs
        # This function returns a flat list, containing only a blessed reference, or the contents of
        #   an IV (which might be a scalar, list or hash). In the event of any error, it returns an
        #   empty list
        #
        # Expected arguments
        #   $string     - This function expects the same single argument that $self->parsePeekPoke
        #                   expects, a parseable string such as 'dict.current.weaponHash'
        #
        # Return values
        #   An empty list on improper arguments or on failure (for example, if $string cannot be
        #       parsed)
        #   Otherwise returns a flat list, containing a blessed reference to an object, or the
        #       contents of an IV (may be a scalar, list or hash, or an empty list)

        my ($self, $string, $check) = @_;

        # Local variables
        my (
            $successFlag, $blessed, $ivName, $var, $objFlag,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $string || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->peek', @_);
            return @emptyList;
        }

        # Parse the supplied $string; we don't need all of its return values
        ($successFlag, $blessed, $ivName, $var, $objFlag) = $self->parsePeekPoke($string);

        if (! $successFlag) {

            # Unrecognised argument, e.g. 'defB.deathmud.port'
            return @emptyList;

        } elsif ($objFlag) {

            # Perl object
            return $blessed;

        } else {

            if ( ref($var) eq 'ARRAY') {
                return @$var;
            } elsif ( ref($var) eq 'HASH') {
                return %$var;
            } else {
                return $var;
            }
        }
    }

    ##################
    # Accessors - set

    sub add_cage {

        my ($self, $cageObj, $session, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $cageObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_cage', @_);
        }

        # Update IVs in every session using the same current world profile (including this one)
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name);
        foreach my $session (@sessionList) {

            $session->ivAdd('cageHash', $cageObj->name, $cageObj);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->add_cage');

        return 1;
    }

    sub del_cage {

        # NB When called by $self->deleteCages, $cageObj will be 'undef' if it was created by a
        #   plugin which hasn't been loaded; so $cageObj is an optional argument

        my ($self, $cageName, $cageObj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $cageName || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_cage', @_);
        }

        # Update IVs in every session using the same current world profile (including this one)
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name);
        foreach my $session (@sessionList) {

            my %infHash;

            $session->ivDelete('cageHash', $cageName);

            if ($session->ivExists('currentCageHash', $cageName)) {

                $session->ivDelete('currentCageHash', $cageName);
            }

            # If the doomed cage appears anywhere in the session's ->inferiorCageHash, the hash must
            #   be rebuilt
            if ($cageObj) {

                %infHash = $session->inferiorCageHash;
                OUTER: foreach my $thisCageName (keys %infHash) {

                    if ($infHash{$thisCageName} && $infHash{$thisCageName} eq $cageObj) {

                        $session->setCageInferiors();
                    }
                }
            }
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->del_cage');

        return 1;
    }

    sub set_cageHash {

        my ($self, %hash) = @_;

        # Local variables
        my @sessionList;

        # (No improper arguments to check - %hash can be empty)

        # Update IVs in every session using the same current world profile (including this one)
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivPoke('cageHash', %hash);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->set_cageHash');

        return 1;
    }

    sub set_cmdBufferPosn {

        my ($self, $posn, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $self->writeImproper($self->_objClass . '->set_cmdBufferPosn', @_);
        }

        $self->ivPoke('cmdBufferPosn', $posn);

        return 1;
    }

    sub set_consoleWin {

        my ($self, $winObj, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_consoleWin', @_);
        }

        # Update IVs
        $self->ivPoke('consoleWin', $winObj);

        return 1;
    }

    sub set_currentDict {

        my ($self, $dictObj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $dictObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_currentDict', @_);
        }

        $self->ivPoke('currentDict', $dictObj);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivPoke('currentDict', $dictObj);
        }

        return 1;
    }

    sub set_currentMission {

        my ($self, $obj, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_currentMission', @_);
        }

        $self->ivPoke('currentMission', $obj);

        return 1;
    }

    sub reset_currentMission {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_currentMission', @_);
        }

        $self->ivUndef('currentMission');

        return 1;
    }

    sub add_currentProf {

        my ($self, $obj, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_currentProf', @_);
        }

        $self->ivAdd('currentProfHash', $obj->category, $obj);
        $self->ivPoke('currentProfChangeFlag', TRUE);

        if ($obj->category eq 'world') {
            $self->ivPoke('currentWorld', $obj);  # This func shouldn't be called for world profiles
        } elsif ($obj->category eq 'guild') {
            $self->ivPoke('currentGuild', $obj);
        } elsif ($obj->category eq 'race') {
            $self->ivPoke('currentRace', $obj);
        } elsif ($obj->category eq 'char') {
            $self->ivPoke('currentChar', $obj);
        }

        return 1;
    }

    sub del_currentProf {

        my ($self, $category, $check) = @_;

        # Check for improper arguments
        if (! defined $category || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_currentProf', @_);
        }

        $self->ivDelete('currentProfHash', $category);
        $self->ivPoke('currentProfChangeFlag', TRUE);

        if ($category eq 'world') {
            $self->ivUndef('currentWorld');  # This func should not be called for world profiles
        } elsif ($category eq 'guild') {
            $self->ivUndef('currentGuild');
        } elsif ($category eq 'race') {
            $self->ivUndef('currentRace');
        } elsif ($category eq 'char') {
            $self->ivUndef('currentChar');
        }

        return 1;
    }

    sub set_currentProfChangeFlag {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_currentProfChangeFlag',
                @_,
            );
        }

        # (Call to this function always sets the flag to TRUE)
        $self->ivPoke('currentProfChangeFlag', TRUE);

        return 1;
    }

    sub set_currentTabObj {

        # Called by GA::Table::Pane->convertSimpleTab and ->convertTab

        my ($self, $tabObj, $check) = @_;

        # Check for improper arguments
        if (! defined $tabObj || defined $check) {

            return $self->writeImproper($self->_objClass . '->set_currentTabObj', @_);
        }

        $self->ivPoke('currentTabObj', $tabObj);

        return 1;
    }

    sub set_defaultTabObj {

        # Called by GA::Table::Pane->convertSimpleTab and ->convertTab

        my ($self, $tabObj, $check) = @_;

        # Check for improper arguments
        if (! defined $tabObj || defined $check) {

            return $self->writeImproper($self->_objClass . '->set_defaultTabObj', @_);
        }

        $self->ivPoke('defaultTabObj', $tabObj);

        return 1;
    }

    sub set_delayedQuit {

        my ($self, $cmd, $time, $check) = @_;

        # Check for improper arguments
        if (! defined $cmd || ! defined $time || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_delayedQuit', @_);
        }

        # Convert $time (in minutes) to a time matching $self->sessionTime
        $self->ivPoke(
            'delayedQuitTime',
            # $time is in minutes; add a single second, otherwise the label at the bottom of the
            #   'main' window shows 0 for less than a second, which is disconcerting
            ($self->sessionTime + ($time * 60) + 1),
        );

        $self->ivPoke('delayedQuitCmd', $cmd);

        return 1;
    }

    sub reset_delayedQuit {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_delayedQuit', @_);
        }

        $self->ivUndef('delayedQuitTime');
        $self->ivUndef('delayedQuitCmd');

        return 1;
    }

    sub set_disableWorldCmdFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_disableWorldCmdFlag',
                @_,
            );
        }

        if ($flag) {
            $self->ivPoke('disableWorldCmdFlag', TRUE);
        } else {
            $self->ivPoke('disableWorldCmdFlag', FALSE);
        }

        return 1;
    }

    sub set_disconnectNoSaveFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_disconnectNoSaveFlag',
                @_,
            );
        }

        if ($flag) {
            $self->ivPoke('disconnectNoSaveFlag', TRUE);
        } else {
            $self->ivPoke('disconnectNoSaveFlag', FALSE);
        }

        return 1;
    }

#    sub add_fileObj {  # Not called. If called, must update other sessions using the same world
#
#        my ($self, $obj, $check) = @_;
#
#        # Check for improper arguments
#        if (! defined $obj || defined $check) {
#
#            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_fileObj', @_);
#        }
#
#        $self->ivAdd('sessionFileObjHash', $obj->name, $obj);
#
#        return 1;
#    }

#    sub del_fileObj {
#
#        my ($self, $obj, $check) = @_;
#
#        # Check for improper arguments
#        if (! defined $obj || defined $check) {
#
#            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_fileObj', @_);
#        }
#
#        $self->ivDelete('sessionFileObjHash', $obj->name);
#
#        return 1;
#    }

    sub set_freezeTaskLoopFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_freezeTaskLoopFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('freezeTaskLoopFlag', TRUE);
        } else {
            $self->ivPoke('freezeTaskLoopFlag', FALSE);
        }

        return 1;
    }

    sub set_viewerWin {

        # Called by GA::OtherWin::Viewer->winEnable and ->winDestroy

        my ($self, $winObj, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_viewerWin', @_);
        }

        # Update IVs
        $self->ivPoke('viewerWin', $winObj);

        return 1;
    }

    sub set_instructBufferPosn {

        my ($self, $posn, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $self->writeImproper($self->_objClass . '->set_instructBufferPosn', @_);
        }

        $self->ivPoke('instructBufferPosn', $posn);

        return 1;
    }

    sub move_interface {

        my ($self, $iv, $interface, $posn, $check) = @_;

        # Local variables
        my $currentPosn;

        # Check for improper arguments
        if (! defined $iv || ! defined $interface || ! defined $posn || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->move_interface', @_);
        }

        # $posn is in the range 1..n, whereas the IV index is in the range 0..(n-1). Convert it
        $posn--;

        # $iv should be one of ->triggerOrderList, ->aliasOrderList, ->macroOrderList,
        #   ->timerOrderList or ->hookOrderList. Find the current position in this registry of the
        #   interface
        $currentPosn = $self->ivFind($iv, $interface->number);
        if (! defined $currentPosn) {

            # Interface not found
            return undef;

        } elsif ($posn == $currentPosn) {

            # Interface already at its intended position
            return 1;

        } else {

            # Remove the interface from its current position...
            $self->ivSplice($iv, $currentPosn, 1);
            # ...and re-insert at the new position
            $self->ivSplice($iv, $posn, 0, $interface->number);

            return 1;
        }
    }

    sub reset_lastExcessCmdTime {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_lastExcessCmdTime',
                @_,
            );
        }

        # Update IVs
        $self->ivPoke('lastExcessCmdTime', $self->sessionTime);

        return 1;
    }

    sub set_logAsleepUntilLine {

        my ($self, $line, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_logAsleepUntilLine', @_);
        }

        $self->ivPoke('logAsleepUntilLine', $line);

        return 1;
    }

    sub set_logDeadUntilLine {

        my ($self, $line, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_logDeadUntilLine', @_);
        }

        $self->ivPoke('logDeadUntilLine', $line);

        return 1;
    }

    sub set_loginSpecialList {

        # (Called by GA::Generic::Cmd->autoQuit)

        my ($self, @list) = @_;

        # (No improper arguments to check)

        # Update IVs
        $self->ivPoke('loginSpecialList', @list);     # Can be an empty list

        return 1;
    }

    sub set_logPassedOutUntilLine {

        my ($self, $line, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_logPassedOutUntilLine',
                @_,
            );
        }

        $self->ivPoke('logPassedOutUntilLine', $line);

        return 1;
    }

    sub set_loopSpinFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $self->writeImproper($self->_objClass . '->set_loopSpinFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('sessionLoopSpinFlag', TRUE);
        } else {
            $self->ivPoke('sessionLoopSpinFlag', FALSE);
        }

        return 1;
    }

    sub set_mapWin {

        # Called by GA::Win::Map->winEnable and ->winDestroy

        my ($self, $winObj, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_mapWin', @_);
        }

        # Update IVs
        $self->ivPoke('mapWin', $winObj);

        return 1;
    }

    sub set_mccpMode {

        # Called by GA::Obj::Telnet->_fillbuf, in those situations where $self->mccpMode must be
        #   set directly

        my ($self, $mode, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $mode
            || (
                $mode ne 'no_invite' && $mode ne 'client_agree' && $mode ne 'client_refuse'
                && $mode ne 'compress_start' && $mode ne 'compress_error'
                && $mode ne 'compress_stop'
            )
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_mccpMode', @_);
        }

        # Update IVs
        $self->ivPoke('mccpMode', $mode);

        return 1;
    }

    sub add_mcpCordType {

        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions
        # If the MCP cord type is already recognised by some other part of the code, calling this
        #   function will replace the old entry with a new one

        my ($self, $type, $funcRef, $check) = @_;

        # Check for improper arguments
        if (! defined $type || ! defined $funcRef || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_mcpCordType', @_);
        }

        $self->ivAdd('mcpCordOpenHash', $type, $funcRef);

        return 1;
    }

    sub del_mcpCordType {

        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions
        # Calling this function stops Axmud from opening MCP cords of the specified type; it doesn't
        #   close any existing cords of that type

        my ($self, $type, $check) = @_;

        # Check for improper arguments
        if (! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_mcpCordType', @_);
        }

        $self->ivDelete('mcpCordOpenHash', $type);

        return 1;
    }

    sub accept_mcpCordID {

        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions

        my ($self, $id, $msgFuncRef, $closeFuncRef, $check) = @_;

        # Check for improper arguments
        if (! defined $id || ! defined $msgFuncRef || ! defined $closeFuncRef || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->accept_mcpCordID', @_);
        }

        # Axmud stores MCP cord IDs with the initial I/R character removed. If the calling function
        #   has specified one of those characters, remove it
        $id =~ s/^[IR]//;

        $self->ivAdd('mcpCordIDHash', $id, $msgFuncRef);
        $self->ivAdd('mcpCordCloseHash', $id, $closeFuncRef);

        return 1;
    }

    sub refuse_mcpCordID {

        # See comments in $self->mcpCordOpen for details of how to use the MCP cord functions

        my ($self, $id, $check) = @_;

        # Check for improper arguments
        if (! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refuse_mcpCordID', @_);
        }

        # Axmud stores MCP cord IDs with the initial I/R character removed. If the calling function
        #   has specified one of those characters, remove it
        $id =~ s/^[IR]//;

        $self->ivAdd('mcpCordIDHash', $id, undef);
        $self->ivDelete('mcpCordCloseHash', $id);

        return 1;
    }

    sub add_pausedTask {

        my ($self, $taskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $taskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_pausedTask', @_);
        }

        $self->ivPush('pausedTaskList', $taskObj);

        return 1;
    }

    sub del_pausedTask {

        my ($self, $taskObj, $check) = @_;

        # Local variables
        my $index;

        # Check for improper arguments
        if (! defined $taskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_pausedTask', @_);
        }

        if (! $self->pausedTaskList) {

            return undef;

        } else {

            $index = $self->ivFind('pausedTaskList', $taskObj);
            if (! defined $index) {

                return undef;

            } else {

                $self->ivSplice('pausedTaskList', $index, 1);
                return 1;
            }
        }
    }

    sub empty_pausedTask {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->empty_pausedTask', @_);
        }

        $self->ivEmpty('pausedTaskList');

        return 1;
    }

    sub set_prevPokeString {

        my ($self, $string, $check) = @_;

        # Check for improper arguments
        if (! defined $string || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_prevPokeString', @_);
        }

        # Update IVs
        $self->ivPoke('prevPokeString', $string);

        return 1;
    }

    sub add_prof {

        my ($self, $obj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_prof', @_);
        }

        $self->ivAdd('profHash', $obj->name, $obj);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivAdd('profHash', $obj->name, $obj);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->add_prof');

        return 1;
    }

    sub del_prof {

        my ($self, $obj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_prof', @_);
        }

        $self->ivDelete('profHash', $obj->name);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivDelete('profHash', $obj->name);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->del_prof');

        return 1;
    }

    sub set_profHash {

        my ($self, %hash) = @_;

        # Local variables
        my @sessionList;

        # (No improper arguments to check - %hash can be empty)

        $self->ivPoke('profHash', %hash);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivPoke('profHash', %hash);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->set_profHash');

        return 1;
    }

    sub set_profPriorityList {

        my ($self, @list) = @_;

        # Local variables
        my @sessionList;

        # (No improper arguments to check - @list can be empty)

        $self->ivPoke('profPriorityList', @list);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivPoke('profPriorityList', @list);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->set_profPriorityList');

        return 1;
    }

    sub reset_rawTextBufferList {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_rawTextBufferList',
                @_,
            );
        }

        $self->ivEmpty('rawTextBufferList');

        return 1;
    }

    sub reset_rawTokenBufferList {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_rawTokenBufferList',
                @_,
            );
        }

        $self->ivEmpty('rawTokenBufferList');

        return 1;
    }

    sub set_recordingFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_recordingFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('recordingFlag', TRUE);
        } else {
            $self->ivPoke('recordingFlag', FALSE);
        }

        return 1;
    }

    sub set_recordingList {

        my ($self, @list) = @_;

        # (No improper arguments to check)

        # Update IVs
        $self->ivPoke('recordingList', @list);     # Can be an empty list

        return 1;
    }

    sub set_recordingPausedFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_recordingPausedFlag',
                @_,
            );
        }

        if ($flag) {
            $self->ivPoke('recordingPausedFlag', TRUE);
        } else {
            $self->ivPoke('recordingPausedFlag', FALSE);
        }

        return 1;
    }

    sub set_recordingPosn {

        my ($self, $value, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_recordingPosn', @_);
        }

        # Update IVs
        $self->ivPoke('recordingPosn', $value);     # Can be 'undef'

        return 1;
    }

    sub set_redirectMode {

        my ($self, $mode, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $mode
            || ($mode ne 'primary_only' && $mode ne 'primary_secondary' && $mode ne 'all_exits')
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_redirectMode', @_);
        }

        # Update IVs
        $self->ivPoke('redirectMode', $mode);

        return 1;
    }

    sub set_redirectString {

        my ($self, $string, $check) = @_;

        # Check for improper arguments ($string can be 'undef')
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_redirectString', @_);
        }

        # Update IVs
        $self->ivPoke('redirectString', $string);

        return 1;
    }

    sub add_repeatObj {

        my ($self, $repeatObj, $check) = @_;

        # Check for improper arguments
        if (! defined $repeatObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_repeatObj', @_);
        }

        $self->ivPush('repeatObjList', $repeatObj);

        return 1;
    }

    sub reset_repeatObjList {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_repeatObjList', @_);
        }

        $self->ivEmpty('repeatObjList');

        return 1;
    }

    sub add_replayCmdBuffer {

        my ($self, $cmd, $time, $check) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (! defined $cmd || ! defined $time || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_replayCmdBuffer', @_);
        }

        $obj = Games::Axmud::Buffer::Cmd->new(
                $self,
                $self,                              # Parent is this session
                $self->replayCmdBufferCount,
                $cmd,
                $time,
        );

        if (! $obj) {

            return undef;

        } else {

            $self->ivAdd('replayCmdBufferHash', $obj->number, $obj);
            $self->ivIncrement('replayCmdBufferCount');
            $self->ivPoke('replayCmdBufferLast', $obj->number);
            if (! defined $self->replayCmdBufferFirst) {

                $self->ivPoke('replayCmdBufferFirst', $obj->number);
            }

            return $obj;
        }
    }

    sub reset_replayCmdBufferHash {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_replayCmdBufferHash',
                @_,
            );
        }

        # Update IVs
        $self->ivEmpty('replayCmdBufferHash');
        $self->ivPoke('replayCmdBufferCount', 0);
        $self->ivUndef('replayCmdBufferFirst');
        $self->ivUndef('replayCmdBufferLast');

        return 1;
    }

    sub add_replayDisplayBuffer {

        my ($self, $text, $time, $check) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (! defined $text || ! defined $time || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->add_replayDisplayBuffer',
                @_,
            );
        }

        $obj = Games::Axmud::Buffer::Display->new(
                $self,
                $self,                              # Parent is this session
                $self->replayDisplayBufferCount,
                $text,                              # ->line, ->stripLine and ->modLine all the same
                $text,
                $text,
                $time,
                TRUE,                               # Treat every line as ending with a newline char
        );

        if (! $obj) {

            return undef;

        } else {

            $self->ivAdd('replayDisplayBufferHash', $obj->number, $obj);
            $self->ivIncrement('replayDisplayBufferCount');
            $self->ivPoke('replayDisplayBufferLast', $obj->number);
            if (! defined $self->replayDisplayBufferFirst) {

                $self->ivPoke('replayDisplayBufferFirst', $obj->number);
            }

            return $obj;
        }
    }

    sub reset_replayDisplayBufferHash {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_replayDisplayBufferHash',
                @_,
            );
        }

        # Update IVs
        $self->ivEmpty('replayDisplayBufferHash');
        $self->ivPoke('replayDisplayBufferCount', 0);
        $self->ivUndef('replayDisplayBufferFirst');
        $self->ivUndef('replayDisplayBufferLast');

        return 1;
    }

    sub reset_showNewTextFlag {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_showNewTextFlag', @_);
        }

        $self->ivPoke('showNewTextFlag', FALSE);

        return 1;
    }

    sub add_soundHarness {

        my ($self, $path, $harness, $delFlag, $type, $v, $l, $p, $c, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $path || ! defined $harness || ! defined $delFlag || ! defined $type
            || ! defined $v || ! defined $l || ! defined $p || ! defined $c || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_soundHarness', @_);
        }

        $self->ivIncrement('soundHarnessCount');

        my $soundObj = Games::Axmud::Obj::Sound->new(
            $self,
            $self->soundHarnessCount,
            $path,
            $harness,
            $delFlag,
            $type,
            $v,
            $l,
            $p,
            $c,
        );

        if ($soundObj) {

            $self->ivAdd('soundHarnessHash', $soundObj->number, $soundObj);
        }

        return 1;
    }

    sub set_spelunkerMode {

        my ($self, $mode, $check) = @_;

        # Check for improper arguments
        if (! defined $mode || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_spelunkerMode', @_);
        }

        # Update IVs
        $self->ivPoke('spelunkerMode', $mode);

        return 1;
    }

    sub add_standardTask {

        my ($self, $obj, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_standardTask', @_);
        }

        # Update IVs. If the task is one of Axmud's built-in tasks, e.g. the TaskList task, this
        #   object will have its ->shortCutIV set
        if ($obj->shortCutIV && $self->ivMember($obj->shortCutIV)) {

            # This built-in task has started running
            $self->ivPoke($obj->shortCutIV, $obj);
            return 1;

        } else {

            # Not a built-in task
            return undef;
        }
    }

    sub del_standardTask {

        my ($self, $obj, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_standardTask', @_);
        }

        # Update IVs. If the task is one of Axmud's built-in tasks, e.g. the TaskList task, this
        #   object will have its ->shortCutIV set
        # NB When a task shuts down, other IVs are set by $self->taskLoop
        if ($obj->shortCutIV && $self->ivMember($obj->shortCutIV)) {

            # This built-in task is no longer running
            $self->ivUndef($obj->shortCutIV);
            return 1;

        } else {

            # Not a built-in task
            return undef;
        }
    }

    sub update_statusTask {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->update_statusTask', @_);
        }

        # Update the status task, if it's running
        if ($self->statusTask) {

            $self->statusTask->set_updateFlag(TRUE);
        }

        return 1;
    }

    sub add_systemMsg {

        my ($self, $type, $msg, $check) = @_;

        # Check for improper arguments
        if (! defined $type || ! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_systemMsg', @_);
        }

        # If the Session Console window is actually open, display it there immediately
        if ($self->consoleWin) {

            $self->consoleWin->update($type, $msg);

        } else {

            $self->ivPush('systemMsgList', $type, $msg);

            if ($type eq 'error' || $type eq 'warning' || $type eq 'improper') {

                $self->ivPoke('systemMsgMode', 'error');

            } elsif ($type eq 'debug' && $self->systemMsgMode ne 'error') {

                $self->ivPoke('systemMsgMode', 'debug');

            } elsif (
                $type eq 'system'
                && $self->systemMsgMode ne 'error'
                && $self->systemMsgMode ne 'debug'
            ) {
                $self->ivPoke('systemMsgMode', 'system');
            }

            # (The colour of the button, while flashing, might be different to the colour when it
            #   stops flashing)
            if ($type eq 'error' || $type eq 'warning' || $type eq 'improper') {
                $self->ivPoke('systemMsgTempMode', 'error');
            } else {
                $self->ivPoke('systemMsgTempMode', $type);
            }

            $self->ivPoke(
                'systemMsgCheckTime',
                ($self->sessionTime + $self->systemMsgWaitTime),
            );

            # Update strip objects for any 'internal' windows used by this session
            foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

                my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
                if ($stripObj) {

                    $stripObj->updateConsoleButton($self->systemMsgMode, $self->systemMsgTempMode);
                }
            }
        }

        return 1;
    }

    sub reset_systemMsg {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_systemMsg', @_);
        }

        $self->ivEmpty('systemMsgList');
        $self->ivPoke('systemMsgMode', 'empty');
        $self->ivPoke('systemMsgTempMode', 'empty');
        $self->ivUndef('systemMsgCheckTime');

        # Update strip objects for any 'internal' windows used by this session
        foreach my $winObj ($axmud::CLIENT->desktopObj->listSessionGridWins($self, TRUE)) {

            my $stripObj = $winObj->ivShow('firstStripHash', 'Games::Axmud::Strip::Entry');
            if ($stripObj) {

                $stripObj->updateConsoleButton('empty');
            }
        }

        return 1;
    }

    sub add_task {

        my ($self, $taskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $taskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_task', @_);
        }

        # Update IVs
        $self->ivAdd('currentTaskHash', $taskObj->uniqueName, $taskObj);
        $self->ivAdd('currentTaskNameHash', $taskObj->name, $taskObj);

        return 1;
    }

    sub add_template {

        my ($self, $obj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_template', @_);
        }

        $self->ivAdd('templateHash', $obj->category, $obj);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivAdd('templateHash', $obj->category, $obj);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->add_template');

        return 1;
    }

    sub del_template {

        my ($self, $obj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_template', @_);
        }

        $self->ivDelete('templateHash', $obj->category);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivDelete('templateHash', $obj->category);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->del_template');

        return 1;
    }

    sub set_templateHash {

        my ($self, %hash) = @_;

        # Local variables
        my @sessionList;

        # (No improper arguments to check - %hash can be empty)

        $self->ivPoke('templateHash', %hash);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $session (@sessionList) {

            $session->ivPoke('templateHash', %hash);
        }

        # The data stored in this IV is saved in the 'otherprof' file
        $self->setModifyFlag('otherprof', TRUE, $self->_objClass . '->set_templateHash');

        return 1;
    }

    sub add_textViewScrollUp {

        my ($self, $textView, $check) = @_;

        # Check for improper arguments
        if (! defined $textView || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_textViewScrollUp', @_);
        }

        $self->ivAdd('textViewScrollUpHash', $textView, $textView);

        return 1;
    }

    sub add_textViewScrollDown {

        my ($self, $textView, $check) = @_;

        # Check for improper arguments
        if (! defined $textView || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_textViewScrollDown', @_);
        }

        $self->ivAdd('textViewScrollDownHash', $textView, $textView);

        return 1;
    }

    sub set_transferWorldModelFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_transferWorldModelFlag',
                @_,
            );
        }

        if ($flag) {
            $self->ivPoke('transferWorldModelFlag', TRUE);
        } else {
            $self->ivPoke('transferWorldModelFlag', FALSE);
        }

        return 1;
    }

    sub set_ttsLastType {

        my ($self, $type, $check) = @_;

        # Check for improper arguments ($string can be 'undef')
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_ttsLastType', @_);
        }

        # Update IVs
        $self->ivPoke('ttsLastType', $type);

        return 1;
    }

    sub set_ttsTempDisableFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_ttsTempDisableFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('ttsTempDisableFlag', TRUE);
        } else {
            $self->ivPoke('ttsTempDisableFlag', FALSE);
        }

        return 1;
    }

    sub del_winObj {

        # Called by GA::Win::Generic->winDestroy

        my ($self, $winObj, $check) = @_;

        # Local variables
        my $stripObj;

        # Check for improper arguments
        if (! defined $winObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_winObj', @_);
        }

        # For pseudo-windows, if the parent table object is still open, close it
        if ($winObj->pseudoWinTableObj) {

            $stripObj = $winObj->pseudoWinTableObj->stripObj;
            if ($stripObj->ivExists('tableObjHash', $winObj->pseudoWinTableObj->number)) {

                $stripObj->removeTableObj($winObj->pseudoWinTableObj);
            }
        }

        if ($self->mainWin && $self->mainWin eq $winObj) {

            # Prevent anything in the session halting code from writing system messages to the
            #   'main' window that's just been closed (via a call to Games::Axmud->writeText, etc)
            $self->ivUndef('defaultTabObj');
            # ...then we can halt the session (unless $self->stop has already been called)
            if ($axmud::CLIENT->ivExists('sessionHash', $self->number)) {

                $axmud::CLIENT->stopSession($self);
            }
        }

        return 1;
    }

    sub set_wizWin {

        # Called by GA::Generic::WizWin->winEnable and ->winDestroy

        my ($self, $winObj, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_wizWin', @_);
        }

        # Update IVs
        $self->ivPoke('wizWin', $winObj);

        return 1;
    }

    sub set_worldModelObj {

        my ($self, $worldModelObj, $check) = @_;

        # Local variables
        my @sessionList;

        # Check for improper arguments
        if (! defined $worldModelObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_worldModelObj', @_);
        }

        $self->ivPoke('worldModelObj', $worldModelObj);
        # Also update the automapper object (which updates the Automapper window, if open)
        $self->mapObj->set_worldModelObj($worldModelObj);

        # If any other sessions are using the same current world profile, update their IVs, too
        @sessionList = $axmud::CLIENT->findSessions($self->currentWorld->name, $self);
        foreach my $otherSession (@sessionList) {

            $otherSession->ivPoke('worldModelObj', $worldModelObj);
            # Likewise, update the automapper object
            $otherSession->mapObj->set_worldModelObj($worldModelObj);
        }

        return 1;
    }

    ##################
    # Accessors - get

    sub session
        { $_[0]->{session} }
    sub number
        { $_[0]->{number} }

    sub mainWin
        { $_[0]->{mainWin} }
    sub defaultTabObj
        { $_[0]->{defaultTabObj} }
    sub currentTabObj
        { $_[0]->{currentTabObj} }

    sub mapWin
        { $_[0]->{mapWin} }
    sub mapObj
        { $_[0]->{mapObj} }
    sub worldModelObj
        { $_[0]->{worldModelObj} }

    sub consoleWin
        { $_[0]->{consoleWin} }
    sub viewerWin
        { $_[0]->{viewerWin} }
    sub wizWin
        { $_[0]->{wizWin} }

    sub startCompleteFlag
        { $_[0]->{startCompleteFlag} }
    sub initialTextBuffer
        { $_[0]->{initialTextBuffer} }
    sub connectHistoryObj
        { $_[0]->{connectHistoryObj} }
    sub historyCheckTIme
        { $_[0]->{historyCheckTIme} }
    sub sessionCharSet
        { $_[0]->{sessionCharSet} }

    sub systemMsgList
        { my $self = shift; return @{$self->{systemMsgList}}; }
    sub systemMsgMode
        { $_[0]->{systemMsgMode} }
    sub systemMsgTempMode
        { $_[0]->{systemMsgTempMode} }
    sub systemMsgWaitTime
        { $_[0]->{systemMsgWaitTime} }
    sub systemMsgCheckTime
        { $_[0]->{systemMsgCheckTime} }

    sub loginFlag
        { $_[0]->{loginFlag} }
    sub loginPromptsMode
        { $_[0]->{loginPromptsMode} }
    sub loginSuccessPatternList
        { my $self = shift; return @{$self->{loginSuccessPatternList}}; }
    sub loginPromptPatternList
        { my $self = shift; return @{$self->{loginPromptPatternList}}; }
    sub loginConnectPatternList
        { my $self = shift; return @{$self->{loginConnectPatternList}}; }
    sub loginConnectFoundFlag
        { $_[0]->{loginConnectFoundFlag} }
    sub loginSpecialList
        { my $self = shift; return @{$self->{loginSpecialList}}; }
    sub loginWarningTime
        { $_[0]->{loginWarningTime} }
    sub loginConfirmText
        { $_[0]->{loginConfirmText} }

    sub promptCount
        { $_[0]->{promptCount} }
    sub promptFlag
        { $_[0]->{promptFlag} }
    sub promptInsertFlag
        { $_[0]->{promptInsertFlag} }
    sub promptCheckTime
        { $_[0]->{promptCheckTime} }

    sub lastTokenType
        { $_[0]->{lastTokenType} }
    sub nlEchoFlag
        { $_[0]->{nlEchoFlag} }
    sub crlfMode
        { $_[0]->{crlfMode} }
    sub constRawHash
        { my $self = shift; return %{$self->{constRawHash}}; }
    sub currentTokenList
        { my $self = shift; return @{$self->{currentTokenList}}; }
    sub rawTextBufferList
        { my $self = shift; return @{$self->{rawTextBufferList}}; }
    sub rawTokenBufferList
        { my $self = shift; return @{$self->{rawTokenBufferList}}; }

    sub sessionFileObjHash
        { my $self = shift; return %{$self->{sessionFileObjHash}}; }
    sub disconnectNoSaveFlag
        { $_[0]->{disconnectNoSaveFlag} }

    sub autoSaveCheckTime
        { $_[0]->{autoSaveCheckTime} }
    sub autoSaveLastTime
        { $_[0]->{autoSaveLastTime} }

    sub profPriorityList
        { my $self = shift; return @{$self->{profPriorityList}}; }
    sub templateHash
        { my $self = shift; return %{$self->{templateHash}}; }

    sub profHash
        { my $self = shift; return %{$self->{profHash}}; }
    sub currentProfHash
        { my $self = shift; return %{$self->{currentProfHash}}; }
    sub currentWorld
        { $_[0]->{currentWorld} }
    sub currentGuild
        { $_[0]->{currentGuild} }
    sub currentRace
        { $_[0]->{currentRace} }
    sub currentChar
        { $_[0]->{currentChar} }
    sub currentProfChangeFlag
        { $_[0]->{currentProfChangeFlag} }

    sub initWorld
        { $_[0]->{initWorld} }
    sub initHost
        { $_[0]->{initHost} }
    sub initPort
        { $_[0]->{initPort} }
    sub initChar
        { $_[0]->{initChar} }
    sub initPass
        { $_[0]->{initPass} }
    sub initAccount
        { $_[0]->{initAccount} }
    sub initProtocol
        { $_[0]->{initProtocol} }
    sub initLoginMode
        { $_[0]->{initLoginMode} }
    sub initOfflineFlag
        { $_[0]->{initOfflineFlag} }
    sub initTempFlag
        { $_[0]->{initTempFlag} }

    sub cageHash
        { my $self = shift; return %{$self->{cageHash}}; }
    sub currentCageHash
        { my $self = shift; return %{$self->{currentCageHash}}; }
    sub inferiorCageHash
        { my $self = shift; return %{$self->{inferiorCageHash}}; }

    sub currentDict
        { $_[0]->{currentDict} }

    sub interfaceHash
        { my $self = shift; return %{$self->{interfaceHash}}; }
    sub interfaceNumHash
        { my $self = shift; return %{$self->{interfaceNumHash}}; }
    sub interfaceCount
        { $_[0]->{interfaceCount} }
    sub deleteInterfaceList
        { my $self = shift; return @{$self->{deleteInterfaceList}}; }

    sub triggerHash
        { my $self = shift; return %{$self->{triggerHash}}; }
    sub triggerCooldownHash
        { my $self = shift; return %{$self->{triggerCooldownHash}}; }
    sub triggerOrderList
        { my $self = shift; return @{$self->{triggerOrderList}}; }
    sub aliasHash
        { my $self = shift; return %{$self->{aliasHash}}; }
    sub aliasCooldownHash
        { my $self = shift; return %{$self->{aliasCooldownHash}}; }
    sub aliasOrderList
        { my $self = shift; return @{$self->{aliasOrderList}}; }
    sub macroHash
        { my $self = shift; return %{$self->{macroHash}}; }
    sub macroCooldownHash
        { my $self = shift; return %{$self->{macroCooldownHash}}; }
    sub macroOrderList
        { my $self = shift; return @{$self->{macroOrderList}}; }
    sub timerNumHash
        { my $self = shift; return %{$self->{timerNumHash}}; }
    sub timerClockHash
        { my $self = shift; return %{$self->{timerClockHash}}; }
    sub timerLastClock
        { $_[0]->{timerLastClock} }
    sub timerOrderList
        { my $self = shift; return @{$self->{timerOrderList}}; }
    sub hookHash
        { my $self = shift; return %{$self->{hookHash}}; }
    sub hookCooldownHash
        { my $self = shift; return %{$self->{hookCooldownHash}}; }
    sub hookOrderList
        { my $self = shift; return @{$self->{hookOrderList}}; }

    sub perlCmdDataHash
        { my $self = shift; return %{$self->{perlCmdDataHash}}; }
    sub perlCmdTagHash
        { my $self = shift; return %{$self->{perlCmdTagHash}}; }
    sub perlCmdGrpStringList
        { my $self = shift; return @{$self->{perlCmdGrpStringList}}; }

    sub currentTaskHash
        { my $self = shift; return %{$self->{currentTaskHash}}; }
    sub currentTaskNameHash
        { my $self = shift; return %{$self->{currentTaskNameHash}}; }
    sub pausedTaskList
        { my $self = shift; return @{$self->{pausedTaskList}}; }

    sub advanceTask
        { $_[0]->{advanceTask} }
    sub attackTask
        { $_[0]->{attackTask} }
    sub channelsTask
        { $_[0]->{channelsTask} }
    sub chatTask
        { $_[0]->{chatTask} }
    sub compassTask
        { $_[0]->{compassTask} }
    sub conditionTask
        { $_[0]->{conditionTask} }
    sub connectionsTask
        { $_[0]->{connectionsTask} }
    sub divertTask
        { $_[0]->{divertTask} }
    sub inventoryTask
        { $_[0]->{inventoryTask} }
    sub launchTask
        { $_[0]->{launchTask} }
    sub locatorTask
        { $_[0]->{locatorTask} }
    sub notepadTask
        { $_[0]->{notepadTask} }
    sub rawTextTask
        { $_[0]->{rawTextTask} }
    sub rawTokenTask
        { $_[0]->{rawTokenTask} }
    sub statusTask
        { $_[0]->{statusTask} }
    sub systemTask
        { $_[0]->{systemTask} }
    sub taskListTask
        { $_[0]->{taskListTask} }
    sub watchTask
        { $_[0]->{watchTask} }

    sub scriptResumeList
        { my $self = shift; return @{$self->{scriptResumeList}}; }

    sub recordingFlag
        { $_[0]->{recordingFlag} }
    sub recordingPausedFlag
        { $_[0]->{recordingPausedFlag} }
    sub recordingList
        { my $self = shift; return @{$self->{recordingList}}; }
    sub recordingPosn
        { $_[0]->{recordingPosn} }

    sub currentMission
        { $_[0]->{currentMission} }

    sub logAsleepUntilLine
        { $_[0]->{logAsleepUntilLine} }
    sub logPassedOutUntilLine
        { $_[0]->{logPassedOutUntilLine} }
    sub logDeadUntilLine
        { $_[0]->{logDeadUntilLine} }

    sub sessionTime
        { $_[0]->{sessionTime} }
    sub connectInfoCheckTime
        { $_[0]->{connectInfoCheckTime} }
    sub connectedTimeString
        { $_[0]->{connectedTimeString} }

    sub sessionLoopObj
        { $_[0]->{sessionLoopObj} }
    sub sessionLoopSpinFlag
        { $_[0]->{sessionLoopSpinFlag} }
    sub childLoopSpinFlag
        { $_[0]->{childLoopSpinFlag} }
    sub sessionLoopDelay
        { $_[0]->{sessionLoopDelay} }

    sub textViewScrollUpHash
        { my $self = shift; return %{$self->{textViewScrollUpHash}}; }
    sub textViewScrollDownHash
        { my $self = shift; return %{$self->{textViewScrollDownHash}}; }

    sub maintainLoopDelay
        { $_[0]->{maintainLoopDelay} }
    sub maintainLoopCheckTime
        { $_[0]->{maintainLoopCheckTime} }

    sub lastExcessCmdTime
        { $_[0]->{lastExcessCmdTime} }
    sub excessCmdCount
        { $_[0]->{excessCmdCount} }
    sub excessCmdList
        { my $self = shift; return @{$self->{excessCmdList}}; }

    sub crawlModeFlag
        { $_[0]->{crawlModeFlag} }
    sub crawlModeCmdLimit
        { $_[0]->{crawlModeCmdLimit} }
    sub crawlModeWaitTime
        { $_[0]->{crawlModeWaitTime} }
    sub crawlModeCheckTime
        { $_[0]->{crawlModeCheckTime} }

    sub emergencyCmdList
        { my $self = shift; return @{$self->{emergencyCmdList}}; }
    sub worldCmdProcessFlag
        { $_[0]->{worldCmdProcessFlag} }
    sub worldCmdGhostRoom
        { $_[0]->{worldCmdGhostRoom} }

    sub delayedQuitTime
        { $_[0]->{delayedQuitTime} }
    sub delayedQuitCmd
        { $_[0]->{delayedQuitCmd} }
    sub disconnectTime
        { $_[0]->{disconnectTime} }

    sub timerLoopDelay
        { $_[0]->{timerLoopDelay} }
    sub timerLoopCheckTime
        { $_[0]->{timerLoopCheckTime} }

    sub incomingLoopDelay
        { $_[0]->{incomingLoopDelay} }
    sub incomingLoopCheckTime
        { $_[0]->{incomingLoopCheckTime} }

    sub emergencyBuffer
        { $_[0]->{emergencyBuffer} }

    sub host
        { $_[0]->{host} }
    sub port
        { $_[0]->{port} }
    sub protocol
        { $_[0]->{protocol} }
    sub connectObj
        { $_[0]->{connectObj} }
    sub sshObj
        { $_[0]->{sshObj} }
    sub ptyObj
        { $_[0]->{ptyObj} }
    sub sslObj
        { $_[0]->{sslObj} }
    sub packetCount
        { $_[0]->{packetCount} }
    sub status
        { $_[0]->{status} }
    sub doDisconnectFlag
        { $_[0]->{doDisconnectFlag} }
    sub reactDisconnectFlag
        { $_[0]->{reactDisconnectFlag} }

    sub processOrigLine
        { $_[0]->{processOrigLine} }
    sub processStripLine
        { $_[0]->{processStripLine} }
    sub processTagHash
        { my $self = shift; return %{$self->{processTagHash}}; }
    sub processRetainFlag
        { $_[0]->{processRetainFlag} }
    sub processImageLine
        { $_[0]->{processImageLine} }

    sub explicitTextLength
        { $_[0]->{explicitTextLength} }
    sub ctrlCursorMode
        { $_[0]->{ctrlCursorMode} }
    sub ctrlKeypadMode
        { $_[0]->{ctrlKeypadMode} }

    sub taskLoopDelay
        { $_[0]->{taskLoopDelay} }
    sub taskLoopCheckTime
        { $_[0]->{taskLoopCheckTime} }

    sub freezeTaskLoopFlag
        { $_[0]->{freezeTaskLoopFlag} }
    sub firstTaskLoopCompleteFlag
        { $_[0]->{firstTaskLoopCompleteFlag} }
    sub taskCallHash
        { my $self = shift; return %{$self->{taskCallHash}}; }

    sub replayLoopDelay
        { $_[0]->{replayLoopDelay} }
    sub replayLoopCheckTime
        { $_[0]->{replayLoopCheckTime} }

    sub replayLoopStartTime
        { $_[0]->{replayLoopStartTime} }
    sub replayLoopTime
        { $_[0]->{replayLoopTime} }
    sub replayLoopStopTime
        { $_[0]->{replayLoopStopTime} }

    sub replayLoopTextFlag
        { $_[0]->{replayLoopTextFlag} }
    sub replayLoopCmdFlag
        { $_[0]->{replayLoopCmdFlag} }
    sub replayLoopNextText
        { $_[0]->{replayLoopNextText} }
    sub replayLoopNextCmd
        { $_[0]->{replayLoopNextCmd} }

    sub replayDisplayBufferHash
        { my $self = shift; return %{$self->{replayDisplayBufferHash}}; }
    sub replayDisplayBufferCount
        { $_[0]->{replayDisplayBufferCount} }
    sub replayDisplayBufferFirst
        { $_[0]->{replayDisplayBufferFirst} }
    sub replayDisplayBufferLast
        { $_[0]->{replayDisplayBufferLast} }

    sub replayCmdBufferHash
        { my $self = shift; return %{$self->{replayCmdBufferHash}}; }
    sub replayCmdBufferCount
        { $_[0]->{replayCmdBufferCount} }
    sub replayCmdBufferFirst
        { $_[0]->{replayCmdBufferFirst} }
    sub replayCmdBufferLast
        { $_[0]->{replayCmdBufferLast} }

    sub displayBufferHash
        { my $self = shift; return %{$self->{displayBufferHash}}; }
    sub displayBufferCount
        { $_[0]->{displayBufferCount} }
    sub displayBufferFirst
        { $_[0]->{displayBufferFirst} }
    sub displayBufferLast
        { $_[0]->{displayBufferLast} }

    sub instructBufferHash
        { my $self = shift; return %{$self->{instructBufferHash}}; }
    sub instructBufferCount
        { $_[0]->{instructBufferCount} }
    sub instructBufferFirst
        { $_[0]->{instructBufferFirst} }
    sub instructBufferLast
        { $_[0]->{instructBufferLast} }
    sub instructBufferPosn
        { $_[0]->{instructBufferPosn} }

    sub cmdBufferHash
        { my $self = shift; return %{$self->{cmdBufferHash}}; }
    sub cmdBufferCount
        { $_[0]->{cmdBufferCount} }
    sub cmdBufferFirst
        { $_[0]->{cmdBufferFirst} }
    sub cmdBufferLast
        { $_[0]->{cmdBufferLast} }
    sub cmdBufferPosn
        { $_[0]->{cmdBufferPosn} }

    sub moveMode
        { $_[0]->{moveMode} }
    sub overruleMoveFlag
        { $_[0]->{overruleMoveFlag} }

    sub lastDisplayTime
        { $_[0]->{lastDisplayTime} }
    sub lastInstructTime
        { $_[0]->{lastInstructTime} }
    sub lastCmdTime
        { $_[0]->{lastCmdTime} }
    sub lastOutBoundsTime
        { $_[0]->{lastOutBoundsTime} }
    sub constHookIdleTime
        { $_[0]->{constHookIdleTime} }
    sub disableUserIdleFlag
        { $_[0]->{disableUserIdleFlag} }
    sub disableWorldIdleFlag
        { $_[0]->{disableWorldIdleFlag} }

    sub ttsBuffer
        { $_[0]->{ttsBuffer} }
    sub ttsLastType
        { $_[0]->{ttsLastType} }
    sub ttsTempDisableFlag
        { $_[0]->{ttsTempDisableFlag} }

    sub showModFlag
        { $_[0]->{showModFlag} }
    sub showNewTextFlag
        { $_[0]->{showNewTextFlag} }
    sub showTabColourMode
        { $_[0]->{showTabColourMode} }
    sub xTermTitle
        { $_[0]->{xTermTitle} }
    sub showXTermTitleFlag
        { $_[0]->{showXTermTitleFlag} }
    sub oscColourHash
        { my $self = shift; return %{$self->{oscColourHash}}; }

    sub blinkerStateHash
        { my $self = shift; return %{$self->{blinkerStateHash}}; }

    sub echoMode
        { $_[0]->{echoMode} }
    sub specialEchoMode
        { $_[0]->{specialEchoMode} }

    sub sgaMode
        { $_[0]->{sgaMode} }
    sub sendTTypeList
        { my $self = shift; return @{$self->{sendTTypeList}}; }
    sub specifiedTType
        { $_[0]->{specifiedTType} }
    sub eorMode
        { $_[0]->{eorMode} }
    sub eorgaFlag
        { $_[0]->{eorgaFlag} }
    sub nawsMode
        { $_[0]->{nawsMode} }
    sub newEnvironMode
        { $_[0]->{newEnvironMode} }
    sub charSetMode
        { $_[0]->{charSetMode} }

    sub msdpMode
        { $_[0]->{msdpMode} }
    sub msdpGenericCmdHash
        { my $self = shift; return %{$self->{msdpGenericCmdHash}}; }
    sub msdpCustomCmdHash
        { my $self = shift; return %{$self->{msdpCustomCmdHash}}; }
    sub msdpGenericListHash
        { my $self = shift; return %{$self->{msdpGenericListHash}}; }
    sub msdpCustomListHash
        { my $self = shift; return %{$self->{msdpCustomListHash}}; }
    sub msdpInitMode
        { $_[0]->{msdpInitMode} }
    sub msdpRequestList
        { my $self = shift; return @{$self->{msdpRequestList}}; }
    sub msdpGenericConfigFlagHash
        { my $self = shift; return %{$self->{msdpGenericConfigFlagHash}}; }
    sub msdpCustomConfigFlagHash
        { my $self = shift; return %{$self->{msdpCustomConfigFlagHash}}; }
    sub msdpGenericConfigValHash
        { my $self = shift; return %{$self->{msdpGenericConfigValHash}}; }
    sub msdpCustomConfigValHash
        { my $self = shift; return %{$self->{msdpCustomConfigValHash}}; }
    sub msdpGenericReportableFlagHash
        { my $self = shift; return %{$self->{msdpGenericReportableFlagHash}}; }
    sub msdpCustomReportableFlagHash
        { my $self = shift; return %{$self->{msdpCustomReportableFlagHash}}; }
    sub msdpGenericReportedFlagHash
        { my $self = shift; return %{$self->{msdpGenericReportedFlagHash}}; }
    sub msdpCustomReportedFlagHash
        { my $self = shift; return %{$self->{msdpCustomReportedFlagHash}}; }
    sub msdpGenericSendableFlagHash
        { my $self = shift; return %{$self->{msdpGenericSendableFlagHash}}; }
    sub msdpCustomSendableFlagHash
        { my $self = shift; return %{$self->{msdpCustomSendableFlagHash}}; }
    sub msdpGenericValueHash
        { my $self = shift; return %{$self->{msdpGenericValueHash}}; }
    sub msdpCustomValueHash
        { my $self = shift; return %{$self->{msdpCustomValueHash}}; }
    sub msdpSanityNum
        { $_[0]->{msdpSanityNum} }

    sub msspMode
        { $_[0]->{msspMode} }

    sub mccpMode
        { $_[0]->{mccpMode} }

    sub mspMode
        { $_[0]->{mspMode} }
    sub soundHarnessHash
        { my $self = shift; return %{$self->{soundHarnessHash}}; }
    sub soundHarnessCount
        { $_[0]->{soundHarnessCount} }
    sub mspDefaultURL
        { $_[0]->{mspDefaultURL} }

    sub mxpMode
        { $_[0]->{mxpMode} }
    sub mxpLineMode
        { $_[0]->{mxpLineMode} }
    sub mxpDefaultMode
        { $_[0]->{mxpDefaultMode} }
    sub mxpTempMode
        { $_[0]->{mxpTempMode} }
    sub mxpTempLinkList
        { my $self = shift; return @{$self->{mxpTempLinkList}}; }
    sub mxpElementHash
        { my $self = shift; return %{$self->{mxpElementHash}}; }
    sub mxpEntityHash
        { my $self = shift; return %{$self->{mxpEntityHash}}; }
    sub mxpFilterHash
        { my $self = shift; return %{$self->{mxpFilterHash}}; }
    sub mxpFrameHash
        { my $self = shift; return %{$self->{mxpFrameHash}}; }
    sub mxpCurrentFrame
        { $_[0]->{mxpCurrentFrame} }
    sub mxpPrevFrame
        { $_[0]->{mxpPrevFrame} }
    sub mxpFrameWidth
        { $_[0]->{mxpFrameWidth} }
    sub mxpFrameHeight
        { $_[0]->{mxpFrameHeight} }
    sub mxpFrameXFactor
        { $_[0]->{mxpFrameXFactor} }
    sub mxpFrameYFactor
        { $_[0]->{mxpFrameYFactor} }
    sub mxpCurrentDest
        { $_[0]->{mxpCurrentDest} }
    sub mxpCurrentVar
        { $_[0]->{mxpCurrentVar} }
    sub mxpCurrentLink
        { $_[0]->{mxpCurrentLink} }
    sub mxpCurrentSend
        { $_[0]->{mxpCurrentSend} }
    sub mxpFlagTextHash
        { my $self = shift; return %{$self->{mxpFlagTextHash}}; }
    sub mxpFlagTextStoreHash
        { my $self = shift; return %{$self->{mxpFlagTextStoreHash}}; }
    sub mxpStyleSheetNum
        { $_[0]->{mxpStyleSheetNum} }
    sub mxpIgnoreNewLineFlag
        { $_[0]->{mxpIgnoreNewLineFlag} }
    sub mxpParagraphFlag
        { $_[0]->{mxpParagraphFlag} }
    sub mxpHeadingFlag
        { $_[0]->{mxpHeadingFlag} }
    sub mxpGaugeStripObj
        { $_[0]->{mxpGaugeStripObj} }
    sub mxpGaugeLevel
        { $_[0]->{mxpGaugeLevel} }
    sub mxpGaugeHash
        { my $self = shift; return %{$self->{mxpGaugeHash}}; }
    sub mxpGaugeUpdateHash
        { my $self = shift; return %{$self->{mxpGaugeUpdateHash}}; }
    sub mxpRelocateMode
        { $_[0]->{mxpRelocateMode} }
    sub mxpRelocateHost
        { $_[0]->{mxpRelocateHost} }
    sub mxpRelocatePort
        { $_[0]->{mxpRelocatePort} }
    sub mxpRelocateQuietFlag
        { $_[0]->{mxpRelocateQuietFlag} }
    sub mxpRelocateQuietLineFlag
        { $_[0]->{mxpRelocateQuietLineFlag} }
    sub mxpDisableFrameFlag
        { $_[0]->{mxpDisableFrameFlag} }
    sub mxpLoginMode
        { $_[0]->{mxpLoginMode} }

    sub puebloMode
        { $_[0]->{puebloMode} }
    sub puebloVersion
        { $_[0]->{puebloVersion} }
    sub puebloActiveFlag
        { $_[0]->{puebloActiveFlag} }
    sub puebloBaseFontSize
        { $_[0]->{puebloBaseFontSize} }
    sub puebloStackList
        { my $self = shift; return @{$self->{puebloStackList}}; }
    sub puebloColumnSize
        { $_[0]->{puebloColumnSize} }
    sub puebloParagraphFlag
        { $_[0]->{puebloParagraphFlag} }
    sub puebloLiteralFlag
        { $_[0]->{puebloLiteralFlag} }
    sub puebloLiteralSampFlag
        { $_[0]->{puebloLiteralSampFlag} }
    sub puebloJustifyMode
        { $_[0]->{puebloJustifyMode} }

    sub zmpMode
        { $_[0]->{zmpMode} }
    sub zmpTimeStamp
        { $_[0]->{zmpTimeStamp} }
    sub zmpSendIdentFlag
        { $_[0]->{zmpSendIdentFlag} }
    sub zmpServerName
        { $_[0]->{zmpServerName} }
    sub zmpServerVersion
        { $_[0]->{zmpServerVersion} }
    sub zmpServerDescrip
        { $_[0]->{zmpServerDescrip} }

    sub aard102Mode
        { $_[0]->{aard102Mode} }
    sub aard102Status
        { $_[0]->{aard102Status} }
    sub aard102TickTime
        { $_[0]->{aard102TickTime} }

    sub atcpMode
        { $_[0]->{atcpMode} }
    sub atcpDataHash
        { my $self = shift; return %{$self->{atcpDataHash}}; }

    sub gmcpMode
        { $_[0]->{gmcpMode} }
    sub gmcpDataHash
        { my $self = shift; return %{$self->{gmcpDataHash}}; }

    sub mcpMode
        { $_[0]->{mcpMode} }
    sub constMcpVersion
        { $_[0]->{constMcpVersion} }
    sub mcpAuthKey
        { $_[0]->{mcpAuthKey} }
    sub mcpWaitTime
        { $_[0]->{mcpWaitTime} }
    sub mcpCheckTime
        { $_[0]->{mcpCheckTime} }
    sub mcpMultiObjHash
        { my $self = shift; return %{$self->{mcpMultiObjHash}}; }
    sub mcpPackageHash
        { my $self = shift; return %{$self->{mcpPackageHash}}; }
    sub mcpWaitHash
        { my $self = shift; return %{$self->{mcpWaitHash}}; }
    sub mcpCordOpenHash
        { my $self = shift; return %{$self->{mcpCordOpenHash}}; }
    sub mcpCordIDHash
        { my $self = shift; return %{$self->{mcpCordIDHash}}; }
    sub mcpCordCloseHash
        { my $self = shift; return %{$self->{mcpCordCloseHash}}; }

    sub protocolDebugList
        { my $self = shift; return @{$self->{protocolDebugList}}; }

    sub disableWorldCmdFlag
        { $_[0]->{disableWorldCmdFlag} }

    sub spelunkerMode
        { $_[0]->{spelunkerMode} }

    sub redirectString
        { $_[0]->{redirectString} }
    sub redirectMode
        { $_[0]->{redirectMode} }

    sub connectTimeOut
        { $_[0]->{connectTimeOut} }
    sub repeatObjList
        { my $self = shift; return @{$self->{repeatObjList}}; }
    sub cmdMode
        { $_[0]->{cmdMode} }

    sub prevPokeString
        { $_[0]->{prevPokeString} }

    sub transferWorldModelFlag
        { $_[0]->{transferWorldModelFlag} }

    sub constRewriteMax
        { $_[0]->{constRewriteMax} }
}

# Package must return a true value
1
