	/* This is a login matrix script for Synchronet.
 * If the user's terminal supports ANSI, this will display an ANSI
 * graphic and a lightbar menu with logon options.  If the user's
 * terminal doesn't support ANSI, this will do a more traditional
 * login sequence.
 *
 * Author: Eric Oulashin (AKA Nightfox)
 * BBS: Digital Distortion
 * BBS address: digdist.bbsindex.com
 */

"use strict";

// TODO: Support Synchronet's new 'fast logons'
/*
So... if you're writing a login matrix or custom login module where you want
to use a *different* method of detecting/enabling fast logon support, you'll
just need to set the SS_FASTLOGON flag in the JS bbs.sys_status property:
    bbs.sys_status |= SS_FASTLOGON;

var gFastLogonSupported = (typeof(SS_FASTLOGON) != "undefined");
*/

const cp437_defs_js_exists = file_exists(backslash(system.exec_dir + "load") + "cp437_defs.js");
if (typeof(require) === "function")
{
	require("sbbsdefs.js", "K_NOCRLF");
	require("smbdefs.js", "NET_INTERNET");
	require("cterm_lib.js", "supports_sixel");
	require("text.js", "MsgSubj");
	require("key_defs.js", "CTRL_A");
	require("dd_lightbar_menu.js", "DDLightbarMenu");
	if (cp437_defs_js_exists)
		require("cp437_defs.js", "CP437_BOX_DRAWINGS_UPPER_LEFT_SINGLE");
	require("rip_lib.js", "RIPWindow");
}
else
{
	load("sbbsdefs.js");
	load("smbdefs.js");
	load("cterm_lib.js");
	load("text.js");
	load("key_defs.js");
	load("dd_lightbar_menu.js");
	if (cp437_defs_js_exists)
		load("cp437_defs.js");
	load("rip_lib.js");
}

//var VERSION = "1.37";
//var VER_DATE = "2026-06-30";

// Program arguments:
// 0: Greeting file to display for the traditional login sequence,
//    relative to Synchronet's text/menu directory.  Defaults to
//    "../answer", which is the normal greeting file.
var gTraditionalGreetingFile = "../answer";
if (typeof(argv[0]) != "undefined")
	gTraditionalGreetingFile = argv[0];

// Box-drawing/border characters - These are things that are defined in
// cp437_defs.js, which was added to Synchronet 3.21, but if the system
// doesn't have cp437_defs.js, then we need to define these here. These
// will be available in the global scope when declared with 'var'.
if (!cp437_defs_js_exists)
{
	var CP437_BOX_DRAWINGS_UPPER_LEFT_SINGLE = "\xDA";
	var CP437_BOX_DRAWINGS_HORIZONTAL_SINGLE = "\xC4";
	var CP437_BOX_DRAWINGS_UPPER_RIGHT_SINGLE = "\xBF";
	var CP437_BOX_DRAWINGS_LIGHT_VERTICAL = "\xB3";
	var CP437_BOX_DRAWINGS_LOWER_LEFT_SINGLE = "\xC0";
	var CP437_BOX_DRAWINGS_LOWER_RIGHT_SINGLE = "\xD9";
	// Box-drawing/border characters: Double-line
	var CP437_BOX_DRAWINGS_UPPER_LEFT_DOUBLE = "\xC9";
	var CP437_BOX_DRAWINGS_HORIZONTAL_DOUBLE = "\xCD";
	var CP437_BOX_DRAWINGS_UPPER_RIGHT_DOUBLE = "\xBB";
	var CP437_BOX_DRAWINGS_DOUBLE_VERTICAL = "\xBA";
	var CP437_BOX_DRAWINGS_LOWER_LEFT_DOUBLE = "\xC8";
	var CP437_BOX_DRAWINGS_LOWER_RIGHT_DOUBLE = "\xBC";
	var CP437_BOX_DRAWINGS_DOUBLE_T = "\xCB";
}

// Other special characters
// Keyboard keys
var CR = CTRL_M;
var KEY_ENTER = CTRL_M;
const KEY_ESC = "\x1b";
var BACKSPACE = CTRL_H;

var CLEAR_SCR_ATTRS = BG_BLACK|LIGHTGRAY;

var gGuestUserNum = system.matchuser("guest");

// Image file formats supported (for converting background images to sixels to be displayed)
var gImgFilenameExts = ["png", "jpg", "jpeg", "gif"];
// Sixel cache directory name (not fully-pathed) for themes that have just
// image files. Sixels for various terminal sizes will be stored in the cache
// directory for faster loading later.
var gSixelCacheDirNameForThemes = "sixel_cache";

// General filename for a theme configuration file
var gGeneralThemeCfgFilename = "DDMatrixTheme.cfg";

///////////////////////////
// Script execution code //
///////////////////////////

// Figure out the the script's execution directory.
// This code is a trick that was created by Deuce, suggested by Rob
// Swindell as a way to detect which directory the script was executed
// in.  I've shortened the code a little.
// Note: gStartupPath will include the trailing slash.
var gStartupPath = '.';
try { throw dig.dist(dist); } catch(e) { gStartupPath = e.fileName; }
gStartupPath = backslash(gStartupPath.replace(/[\/\\][^\/\\]*$/,''));

// Get the main configuration options from DDLoginMatrix.cfg
var gMainCfgObj = ReadConfigFile(gStartupPath);

// Load the language strings from the specified language file.
var gMatrixLangStrings = new MatrixLangStrings();
var gStdLangStrings = new StdLangStrings();
var gGenLangStrings = new GeneralLangStrings();
// Load the strings from the lanuage file
if (file_isdir(gStartupPath + "langFiles/"))
	loadLangStrings(gStartupPath + "langFiles/" + gMainCfgObj.Language + ".lng");
else if (file_isdir(gStartupPath + "DDLoginMatrixLangFiles/"))
	loadLangStrings(gStartupPath + "DDLoginMatrixLangFiles/" + gMainCfgObj.Language + ".lng");

// The following 2 lines are only required for "Re-login" capability
bbs.logout();
system.node_list[bbs.node_num-1].status = NODE_LOGON;

// If the UseMatrix option is true and the user's terminal supports ANSI,
// then do the matrix-style login.  Otherwise, do the standard login.
if (gMainCfgObj.UseMatrix && (console.term_supports(USER_ANSI) || console.term_supports(USER_PETSCII)))
   loginMatrix();
else
   loginStandard();

// If the user logged in, then play the login sound if configured
// to do so.
if (user.number > 0)
{
	if (gMainCfgObj.PlaySound && (gMainCfgObj.SoundFile != ""))
		sound(gMainCfgObj.SoundFile);
}

// End of script execution

//////////////////////////////////////////////////////////////////////////////////
// Functions

function loginMatrix()
{
	// Get a theme configuration and use it to configure the background, menu, etc.
	var themeCfgObj = getThemeCfg(gStartupPath, gMainCfgObj.MatrixTheme, gMainCfgObj.RandomOnlySixelThemesForSixelTerminal);
	// Convert the theme images to sixels, if needed
	var cvtImgsToSixelRetObj = convertThemeImgsToSixel(themeCfgObj);

	// Construct the matrix menu
	const LOGIN = 0;
	const NEWUSER = 1;
	const GUEST = 2;
	const RETRIEVE_PASSWORD = 3;
	const EMAIL_SYSOP = 4;
	const DISCONNECT = 5;
	const PAGE_SYSOP = 6;
	const REMOTE_SYSTEM = 7;

	// If not using the RIP menu, create the ANSI menu and add the menu items to it
	const useRIP = (themeCfgObj.RIP.UseRIP && console.term_supports(USER_RIP));
	var matrixMenu = null;
	if (!useRIP)
	{
		matrixMenu = createLightbarMatrixMenu(themeCfgObj);
		matrixMenu.timeoutMS = gMainCfgObj.MenuTimeoutMS;
		matrixMenu.Add(gMatrixLangStrings.LOGIN, LOGIN);
		if (gMainCfgObj.MenuDisplayNewUser && !Boolean(system.settings & SYS_CLOSED))
			matrixMenu.Add(gMatrixLangStrings.NEWUSER, NEWUSER);
		if (gGuestUserNum > 0 && gMainCfgObj.MenuDisplayGuestAccountIfExists)
			matrixMenu.Add(gMatrixLangStrings.GUEST, GUEST);
		if (gMainCfgObj.MenuDisplayRetrievePassword)
			matrixMenu.Add(gMatrixLangStrings.RETRIEVE_PASSWORD, RETRIEVE_PASSWORD);
		if (gMainCfgObj.MenuDisplayEmailSysop)
			matrixMenu.Add(gMatrixLangStrings.EMAIL_SYSOP, EMAIL_SYSOP);
		if (gMainCfgObj.MenuDisplayPageSysop)
			matrixMenu.Add(gMatrixLangStrings.PAGE_SYSOP, PAGE_SYSOP);
		matrixMenu.Add(gMatrixLangStrings.DISCONNECT, DISCONNECT);

		/*
		// Remote systems to connect to
		const remoteConnectionsStartIdx = gMainCfgObj.remoteConnections.length > 0 ? matrixMenu.NumItems() : -1;
		for (var i = 0; i < gMainCfgObj.remoteConnections.length; ++i)
			matrixMenu.Add(gMainCfgObj.remoteConnections[i].display_name, REMOTE_SYSTEM);
		*/


		// Figure out what the menu's width & height should be, based on the number of items and the item text length
		var numMenuItems = matrixMenu.NumItems();
		matrixMenu.size.height = numMenuItems + 2; // + 2 for the borders
		var maxItemTextLength = 0;
		for (var i = 0; i < numMenuItems; ++i)
		{
			var item = matrixMenu.GetItem(i);
			if (item == null)
				continue;
			var itemScreenLen = console.strlen(item.text);
			if (item.text.indexOf("&") > -1)
				--itemScreenLen;
			if (itemScreenLen > maxItemTextLength)
				maxItemTextLength = itemScreenLen;
		}
		matrixMenu.size.width = maxItemTextLength + 2; // + 2 for borders
	}

	// Logon loop
	var continueOn = true;
	for (var loopVar = 0; loopVar < 10 && continueOn && !js.terminated; ++loopVar)
	{
		// The "node sync" is required for sysop interruption/chat/etc.
		bbs.nodesync();

		// Clear the screen and display the initial background file
		console.print("\x01q\x01n");
		console.clear();
		var initialSixelFileExists = cvtImgsToSixelRetObj.InitialBkgSixelFilename.length > 0 && file_exists(cvtImgsToSixelRetObj.InitialBkgSixelFilename);
		if (initialSixelFileExists && supports_sixel() && themeCfgObj.userTermIsWideEnough())
			showSixel(cvtImgsToSixelRetObj.InitialBkgSixelFilename);
		else if (traditionalScreenFileExists(themeCfgObj.InitialBackgroundFilename) || termSupportsRIPAndRIPFileExists(themeCfgObj.InitialBackgroundFilename))
		{
			//bbs.menu(themeCfgObj.InitialBackgroundFilename);
			displayFileConsideringRIPConditions(themeCfgObj.InitialBackgroundFilename, useRIP);
		}

		// Display any custom initial login screen info lines
		for (var loginLineI = 0; loginLineI < themeCfgObj.InitialBackgroundInfoLines.length; ++loginLineI)
		{
			var infoObj = themeCfgObj.InitialBackgroundInfoLines[loginLineI];
			console.gotoxy(infoObj.X, infoObj.Y);
			console.putmsg("\x01n" + attrCodeStr(infoObj.attrChars) + infoObj.str);
		}
		if (useRIP)
		{
			console.pushxy();
			console.home();
			if (themeCfgObj.RIP.InitialBackgroundAdditionalRIPCmds.length > 0)
			{
				for (var loginLineI = 0; loginLineI < themeCfgObj.RIP.InitialBackgroundAdditionalRIPCmds.length; ++loginLineI)
					printf("\r\n%s", themeCfgObj.RIP.InitialBackgroundAdditionalRIPCmds[loginLineI]);
				console.crlf();
			}
			console.popxy();
		}

		// Show the matrix menu and respond to the user's choice.
		// If the user's terminal supports RIP & we're configured to show the RIP
		// menu, do so. If that fails, fall back on showing the ANSI lightbar menu.
		var doNewUser = false;
		var userChoice = null;
		var successfullyDisplayedRIPMenu = false;
		if (useRIP)
		{
			var RIPRetObj = displayConnectScreenRIPMenu(gMainCfgObj, themeCfgObj);
			if (RIPRetObj.successful)
			{
				successfullyDisplayedRIPMenu = true;
				// Get a keypress from the user
				console.attributes = "N";
				var userKey = console.getkeys(RIPRetObj.validUserInputKeys, -1, K_NOCRLF|K_NOSPIN|K_NOECHO|K_UPPER).toString();
				if (userKey == "L")
					userChoice = LOGIN;
				else if (userKey == "N")
					userChoice = NEWUSER;
				else if (userKey == "G")
					userChoice = GUEST;
				else if (userKey == "R")
					userChoice = RETRIEVE_PASSWORD;
				else if (userKey == "E")
					userChoice = EMAIL_SYSOP;
				else if (userKey == "P")
					userChoice = PAGE_SYSOP;
				else if (userKey == "D")
					userChoice = DISCONNECT;
				else // Shouldn't happen, but don't want to end up in an infinite loop
				{
					userChoice = DISCONNECT;
					console.home();
					console.print("\x01n\x01y\x01h* \x01wThere is a problem with user input with RIP\x01n\r\n");
				}

			}
			console.print("\x01n\r\n!" + RIPEraseTextWindow() + RIPEraseGraphicsWindow() + RIPHome() + "\r\n");
			console.home();
		}
		if (!successfullyDisplayedRIPMenu || !useRIP)
		{
			if (matrixMenu != null) // This shouldn't be the case, but check just in case
			{
				matrixMenu.selectedItemIndex = 0;
				// If the theme is configured to clear the space around the menu, then do so
				if (themeCfgObj.ClearSpaceAroundMenu)
					writeSpacesAroundRectangle(matrixMenu.pos.x, matrixMenu.pos.y, matrixMenu.size.width, matrixMenu.size.height);
				// If the theme is configured to display the menu title, then do so above the menu
				if (themeCfgObj.DisplayMenuTitle && matrixMenu.pos.y > 1)
				{
					console.attributes = "N";
					var titleText = randomDimBrightString(themeCfgObj.MenuTitle, "\x01w");
					console.gotoxy(matrixMenu.pos.x, matrixMenu.pos.y-1);
					console.print(themeCfgObj.MenuColor_ClearAroundMenu + titleText);
					console.attributes = "N";
					// Clear the rest of the area in the title line
					//printf("%*s", matrixMenu.size.width - console.strlen(titleText), "");
				}
				// Display the menu and get the user's selection
				userChoice = matrixMenu.GetVal(true);
			}
			else
			{
				// Oops, we're not using RIP and matrixMenu is null somehow
				userChoice == DISCONNECT;
			}
		}

		// Handle the user's choice'
		if (userChoice == null)
		{
			console.clear(CLEAR_SCR_ATTRS);
			console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
			continueOn = false; // For the outer loop
			bbs.hangup();
		}
		else if (userChoice == LOGIN)
		{
			var loginRetObj = doMatrixLogin(themeCfgObj, cvtImgsToSixelRetObj);
			continueOn = loginRetObj.continueLoginMatrixLoop;
			doNewUser = loginRetObj.doNewUser;
		}
		else if (userChoice == NEWUSER)
		{
			// Only allow new users if the system's "closed" setting is false.
			doNewUser = !Boolean(system.settings & SYS_CLOSED);
		}
		else if (userChoice == GUEST)
		{
			if (bbs.login("guest", themeCfgObj.PasswordPromptColor +
			              gMatrixLangStrings.PASSWORD_PROMPT + " " +
			              themeCfgObj.PasswordTextColor))
			{
				continueOn = false; // For the outer loop
				console.clear(CLEAR_SCR_ATTRS);
				bbs.logon();
				break;
			}
			else
			{
				console.gotoxy(themeCfgObj.MenuX, themeCfgObj.MenuY);
				console.print(gMatrixLangStrings.GUEST_ACCT_FAIL);
				mswait(1500);
			}
		}
		else if (userChoice == RETRIEVE_PASSWORD)
		{
			console.clear(CLEAR_SCR_ATTRS);
			// Prompt the user for their username or email address
			console.print(gStdLangStrings.USERNAME_OR_EMAIL_ADDR_PROMPT + "\x01n:");
			console.crlf();
			var userNameOrEmailAddr = console.getstr(console.screen_columns-1, K_TAB); // K_UPRLWR
			// Look up the user's account & email their password to them if found
			emailAcctInfo(userNameOrEmailAddr, false, true);
		}
		else if (userChoice == EMAIL_SYSOP)
		{
			console.clear(CLEAR_SCR_ATTRS);
			continueOn = false; // For the outer loop
			// Now, not using the guest account anymore because if multiple people do this,
			// the 'reply to' email address would be the one from the last person to do this.
			/*
			// Log the user on as the guest; during this process, it will prompt for an email address.
			// Then let the user send an email.
			if (bbs.login("guest", themeCfgObj.PasswordPromptColor +
			              gMatrixLangStrings.PASSWORD_PROMPT + " " +
			              themeCfgObj.PasswordTextColor))
			{
				// Ask the user to enter an email address (for any replies). And in order to save
				// the email address, it seems we need to load the guest user account and save it.
				var emailAddr = "";
				if (gGuestUserNum > 0)
				{
					var guestUser = new User(gGuestUserNum);
					emailAddr = promptUserForTheirEmailAddress();
					guestUser.netmail = emailAddr;
					guestUser.close();
				}
				// Allow the user to select a message editor, and then email the sysop
				bbs.select_editor();
				bbs.email(1);
				// Log the user off
				console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
				bbs.hangup();
				break;
			}
			else
			{
				// The guest account isn't available; Still let the user send an email to the sysop
				// Allow the user to select a message editor, and then email the sysop
				bbs.select_editor();
				bbs.email(1);
				if (console.yesno(gMatrixLangStrings.LOGOFF_CONFIRM_TEXT))
				{
					console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
					bbs.hangup();
					break;
				}
			}
			*/
			// Calling bbs.email(1) would let the user email the sysop, but
			// then, if the sysop resplies to the email, Synchronet would have
			// the sysop reply to the guest account, and it would use the last
			// email saved in the guest account.  If multiple people do this,
			// then that wouldn't be the correct email address for everyone's*
			// emails.
			//bbs.email(1);
			letUserEmailSysopByEditingAFile();
			if (console.yesno(gMatrixLangStrings.LOGOFF_CONFIRM_TEXT))
			{
				continueOn = false;
				console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
				bbs.hangup();
				break;
			}
			else
				continueOn = true;
		}
		else if (userChoice == PAGE_SYSOP)
		{
			console.clear("\x01n\x01c");
			var sysopPaged = bbs.page_sysop();
			console.crlf();
			if (sysopPaged)
				console.print(gMatrixLangStrings.SYSOP_HAS_BEEN_PAGED + "\r\n");
			else
				console.print(gMatrixLangStrings.UNABLE_TO_PAGE_SYSOP + "\r\n");
			console.pause();
		}
		else if (userChoice == DISCONNECT)
		{
			console.clear(CLEAR_SCR_ATTRS);
			console.print(gMatrixLangStrings.DISCONNECT_MESSAGE + "\x01n");
			continueOn = false; // For the outer loop
			bbs.hangup();
		}
		else if (userChoice == REMOTE_SYSTEM)
		{
			var chosenSystemIdx = matrixMenu.selectedItemIdx - remoteConnectionsStartIdx;

			// gMainCfgObj.remoteConnections
			// display_name
			// address
			// port (number)
			// protocol
			var sysAddrAndPort = gMainCfgObj.remoteConnections[chosenSystemIdx].address + ":" + gMainCfgObj.remoteConnections[chosenSystemIdx].port;
			var protLower = gMainCfgObj.remoteConnections[chosenSystemIdx].protocol.toLowerCase();
			var telgateFlags = TG_NONE;
			if (protLower == "telnet")
			{
			}
			else if (protLower == "rlogin")
				telgateFlags = TG_RLOGIN;

			//bbs.exec("?telgate " + sysAddrAndPort);
			console.print("\x01n\r\n\x01h\x01hPress \x01yCtrl-]\x01w for a control menu anytime.\r\n\r\n");
			console.pause();
			writeln("\x01h\x01yConnecting to: \x01w" + sysAddrAndPort + "\x01n");
			bbs.telnet_gate(sysAddrAndPort, telgateFlags);
			console.clear();
		}
		else
		{
			// If the user's input is blank, that probably means the
			// input timeout was hit, so disconnect the user.
			var userInputBlank = false;
			if (successfullyDisplayedRIPMenu || matrixMenu == null)
				userInputBlank = (userChoice == undefined || userChoice == null || typeof(userChoice) !== "string");
			else
				userInputBlank = (matrixMenu.lastUserInput == null || (typeof(matrixMenu.lastUserInput) === "string" && matrixMenu.lastUserInput.length == 0));
			if (userInputBlank)
			{
				// We probably hit tine input timeout, so disconnect the user.
				console.clear(CLEAR_SCR_ATTRS);
				console.print("\x01n\x01h\x01w" + system.name + ": " +
							  gMatrixLangStrings.INPUT_TIMEOUT_REACHED + "\x01n");
				continueOn = false; // For the outer loop
				bbs.hangup();
			}
		}

		// If the user wants to create a new user account, then do it.
		if (doNewUser)
		{
			console.clear(CLEAR_SCR_ATTRS);
			if (bbs.newuser())
			{
				bbs.logon();
				continueOn = false; // For the outer loop
			}
			else
				continue;
		}
	}
}

// Helper for loginMatrix(): Performs the matrix login procedure (prompting for
// username & password, etc.)
//
// Parameters:
//  pThemeCfgObj: An object with theme configuration settings
//  pCvtImgsToSixelRetObj: The return object from checking sixel images/converting images to sixels
//
// Return value: An object with the following properties:
//               continueLoginMatrixLoop: Whether or not to continue the matrix loop in the calling code
//               doNewUser: Whether or not to start the new user process
function doMatrixLogin(pThemeCfgObj, pCvtImgsToSixelRetObj)
{
	var retObj = {
		continueLoginMatrixLoop: true,
		doNewUser: false
	};

	const useRIP = (pThemeCfgObj.RIP.UseRIP && console.term_supports(USER_RIP));


	// Clear the screen and display the login background file, if
	// one is specified.
	console.clear(CLEAR_SCR_ATTRS);
	if (pThemeCfgObj.LoginBackgroundFilename.length > 0)
	{
		var loginSixelFileExists = pCvtImgsToSixelRetObj.LoginBkgSixelFilename.length > 0 && file_exists(pCvtImgsToSixelRetObj.LoginBkgSixelFilename);
		if (loginSixelFileExists && supports_sixel() && pThemeCfgObj.userTermIsWideEnough())
			showSixel(pCvtImgsToSixelRetObj.LoginBkgSixelFilename);
		else if (traditionalScreenFileExists(pThemeCfgObj.LoginBackgroundFilename) || termSupportsRIPAndRIPFileExists(pThemeCfgObj.LoginBackgroundFilename))
		{
			//bbs.menu(pThemeCfgObj.LoginBackgroundFilename);
			displayFileConsideringRIPConditions(pThemeCfgObj.LoginBackgroundFilename, useRIP);
		}
	}

	// Display any custom login screen info lines
	for (var loginLineI = 0; loginLineI < pThemeCfgObj.LoginInfoLines.length; ++loginLineI)
	{
		var infoObj = pThemeCfgObj.LoginInfoLines[loginLineI];
		console.gotoxy(infoObj.X, infoObj.Y);
		console.putmsg("\x01n" + attrCodeStr(infoObj.attrChars) + infoObj.str);
	}
	if (useRIP)
	{
		console.pushxy();
		console.home();
		if (pThemeCfgObj.RIP.LoginAdditionalRIPCmds.length > 0)
		{
			for (var loginLineI = 0; loginLineI < pThemeCfgObj.RIP.LoginAdditionalRIPCmds.length; ++loginLineI)
				printf("\r\n%s", pThemeCfgObj.RIP.LoginAdditionalRIPCmds[loginLineI]);
			console.crlf();
		}
		console.popxy();
	}

	// Draw boxes for the username, password, and status.
	// First, calculate the box sizes.
	var usernamePrompt = gMatrixLangStrings.USERNAME_PROMPT;
	if (gMainCfgObj.AllowUserNumber)
		usernamePrompt = gMatrixLangStrings.USERNAME_OR_NUM_PROMPT;
	var usernamePromptLen = console.strlen(usernamePrompt);
	var passwordPrompt = gMatrixLangStrings.PASSWORD_PROMPT;
	var passwordPromptLen = console.strlen(passwordPrompt);
	var usernameBoxInnerWidth = pThemeCfgObj.UsernameLength + usernamePromptLen + 1;
	var statusBoxWidth = pThemeCfgObj.StatusBoxInnerWidth + 2;

	var passwordLength = 16; // Default password length
	// If the theme configuration has a PasswordBoxInnerWidth, then
	// use it for the absolute inner width of the password box.  Otherwise,
	// adjust the size of the password box to match the system-configured
	// maximum password length.
	if (pThemeCfgObj.hasOwnProperty("PasswordBoxInnerWidth"))
		passwordLength = pThemeCfgObj.PasswordBoxInnerWidth - passwordPromptLen - 1; // - 1
	else
	{
		// If system.min_password_length or system.max_password_length are defined
		// (minimum & maximum password length; added in Synchronet v3.18), then
		// make sure the configured password length fits these settings.
		if (typeof(system.min_password_length) === "number")
		{
			if (passwordLength < system.min_password_length)
			{
				// Fix the password box inner width
				var widthDiff = system.min_password_length - passwordLength;
				passwordLength += widthDiff;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
		}
		if (typeof(system.max_password_length) === "number")
		{
			if (passwordLength > system.max_password_length)
			{
				passwordLength = system.max_password_length;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
			else if (passwordLength < system.max_password_length)
			{
				// Fix the password box inner width
				var widthDiff = system.max_password_length - passwordLength;
				passwordLength += widthDiff;
				// Make sure the password box location will let it fit on the screen
				// with the new width, up to the width of the user's terminal
				// - 2 for the borders and also subtract the prompt length
				var maxPassWidth = console.screen_columns - 2 - passwordPromptLen;
				if (passwordLength > maxPassWidth)
					passwordLength = maxPassWidth;
			}
		}
	}

	var passwordBoxInnerWidth = passwordLength + passwordPromptLen + 1;
	var usernameBoxWidth = usernameBoxInnerWidth;
	if (pThemeCfgObj.DrawLoginNameBoxBorder)
		usernameBoxWidth += 2;
	var passwordBoxWidth = passwordBoxInnerWidth;
	if (pThemeCfgObj.DrawLoginPasswordBoxBorder)
		passwordBoxWidth += 2;

	// In case the password box inner width was changed (due to minimum
	// & maximum password length in the Synchronet configuration), make sure
	// the password box horizontal location is valid.
	//passwordPrompt is the password prompt text
	var currentEndX = pThemeCfgObj.PasswordX + passwordBoxWidth - 1;
	if (currentEndX > console.screen_columns)
	{
		// If the password box would now be too far to the right, then
		// move it left.
		var diff = currentEndX - console.screen_columns;
		pThemeCfgObj.PasswordX -= diff;
		// If it's too far left, then we'll have to have the password box
		// start on the left edge and make it the width of the console.
		// Unfortunately, this will mean that the BBS's configured maximum
		// password length won't be supported because it's too long.
		if (pThemeCfgObj.PasswordX < 1)
		{
			pThemeCfgObj.PasswordX = 1;
			passwordBoxInnerWidth = console.screen_columns - 2;
			passwordBoxWidth = console.screen_columns;
		}
	}

	if (pThemeCfgObj.DrawLoginNameBoxBorder)
	{
		// Draw the username box
		drawOneLineInputBox(pThemeCfgObj.UsernameX, pThemeCfgObj.UsernameY,
		                    usernameBoxWidth, pThemeCfgObj.LoginNameBoxBorder, pThemeCfgObj.UsernameBoxBorderColor,
		                    pThemeCfgObj.UsernameBoxBkgColor,
		                    pThemeCfgObj.UsernamePromptColor + usernamePrompt);
	}
	else
	{
		console.gotoxy(pThemeCfgObj.UsernameX, pThemeCfgObj.UsernameY);
		console.print("\x01n" + pThemeCfgObj.UsernamePromptColor + usernamePrompt + "\x01n");
	}

	if (pThemeCfgObj.DrawLoginPasswordBoxBorder)
	{
		// Draw the password box
		drawOneLineInputBox(pThemeCfgObj.PasswordX, pThemeCfgObj.PasswordY,
		                    passwordBoxWidth, pThemeCfgObj.LoginPasswordBoxBorder,
		                    pThemeCfgObj.PasswordBoxBorderColor,
		                    pThemeCfgObj.PasswordBoxBkgColor,
		                    pThemeCfgObj.PasswordPromptColor + passwordPrompt);
	}
	else
	{
		console.gotoxy(pThemeCfgObj.PasswordX, pThemeCfgObj.PasswordY);
		console.print("\x01n" +  pThemeCfgObj.PasswordPromptColor + passwordPrompt + "\x01n");
	}

	if (pThemeCfgObj.DrawLoginStatusBoxBorder)
	{
		// Status box
		drawOneLineInputBox(pThemeCfgObj.StatusX, pThemeCfgObj.StatusY,
		                    statusBoxWidth, pThemeCfgObj.LoginStatusBoxBorder,
		                    pThemeCfgObj.StatusBoxBorderColor,
		                    pThemeCfgObj.StatusBoxBkgColor);
	}

	// Figure out the screen positions for the username & password inputs,
	// and the status text line
	var usernameX = pThemeCfgObj.UsernameX + passwordPromptLen + 1;
	var usernameY = pThemeCfgObj.UsernameY;
	if (pThemeCfgObj.DrawLoginNameBoxBorder)
	{
		++usernameX;
		++usernameY;
	}
	var passwordX = pThemeCfgObj.PasswordX + passwordPromptLen + 1;
	var passwordY = pThemeCfgObj.PasswordY;
	if (pThemeCfgObj.DrawLoginPasswordBoxBorder)
	{
		++passwordX;
		++passwordY;
	}
	var statusX = pThemeCfgObj.StatusX;
	var statusY = pThemeCfgObj.StatusY;
	if (pThemeCfgObj.DrawLoginStatusBoxBorder)
	{
		++statusX;
		++statusY;
	}

	// Set the text for prompting the user to enter their username/number.
	var enterUsernameAndPasswordStr = "";
	if (gMainCfgObj.AllowUserNumber && !Boolean(bbs.node_settings & NM_NO_NUM))
		enterUsernameAndPasswordStr = randomDimBrightString(format("%-" + usernameBoxWidth + "s", gMatrixLangStrings.USERNAME_NUM_PASS_PROMPT), "\x01w");
	else
		enterUsernameAndPasswordStr = randomDimBrightString(format("%-" + usernameBoxWidth + "s", gMatrixLangStrings.USERNAME_PASS_PROMPT), "\x01w");

	// If the username/password text is too long for where it should be located, then truncate it
	var promptUsernamePasswordPromptTextX = (pThemeCfgObj.DrawLoginNameBoxBorder ? usernameX-11 : usernameX-10);
	var maxUsernamePasswordPromptLen = console.screen_columns - promptUsernamePasswordPromptTextX + 1;
	if (console.strlen(enterUsernameAndPasswordStr) > maxUsernamePasswordPromptLen)
		enterUsernameAndPasswordStr = substrWithAttrCodes(enterUsernameAndPasswordStr, 0, maxUsernamePasswordPromptLen);

	// Prompt for username & password.  Give the user 10 chances.
	var loggedOn = false;    // Whether or not the user successfully logged on
	var returnToLogonMenu = false; // Whether or not to display the logon menu again
	//var loginAttempts = 0;
	for (var loginAttempts = 0; loginAttempts < gMainCfgObj.MaxLoginAttempts; ++loginAttempts)
	{
		// Prepare to prompt for the username
		var promptUsernamePasswordPromptTextY = (pThemeCfgObj.DrawLoginNameBoxBorder ? usernameY-2 : usernameY-1);
		console.gotoxy(pThemeCfgObj.UsernameX, promptUsernamePasswordPromptTextY); // Used to use promptUsernamePasswordPromptTextX here
		console.print(enterUsernameAndPasswordStr);
		console.gotoxy(usernameX, usernameY);
		// If this is the not the first time through the loop,
		// then clear the username input.
		if (loginAttempts > 0)
		{
			flashMessage(usernameX, usernameY, "", 0, usernameBoxInnerWidth-10,
						 true, pThemeCfgObj.UsernameBoxBkgColor);
						 console.gotoxy(usernameX, usernameY);
		}
		console.print(pThemeCfgObj.UsernameTextColor);
		// Prompt for the username
		//var username = console.getstr(pThemeCfgObj.UsernameLength, K_UPRLWR | K_TAB);
		//var username = console.getstr("", 128, K_UPRLWR|K_TAB|K_NOCRLF|K_NOSPIN);
		var username = getStrWithTimeout(128, K_UPRLWR|K_TAB|K_NOCRLF|K_NOSPIN, null, gMainCfgObj.MenuTimeoutMS);
		// If the username is blank, then we want to return to the logon menu
		// (set returnToLogonMenu to true and break out of this
		// username/password loop).
		if (username.length == 0)
		{
			returnToLogonMenu = true;
			break;
		}

		truncsp(username);

		// If the user entered a username that's blocked, then output an
		// error and hang up.
		if (system.trashcan("name", username))
		{
			console.clear(CLEAR_SCR_ATTRS);
			alert(log(LOG_NOTICE, "!Failed login with blocked user name: " + username));
			console.print("\x01n");
			bbs.hangup();
		}

		// If the user typed "new", then let them create a new
		// user account.  Set doNewUser to true and break out
		// of the username/password loop.
		if (username.toUpperCase() == gGenLangStrings.NEW.toUpperCase())
		{
			retObj.doNewUser = true;
			break;
		}

		// If we get here, then the user didn't enter "new".
		// If user numbers are allowed and the username contains
		// all digits, then set userNum to what the user entered.
		// Otherwise, look for the user number that matches the
		// username.
		var userNum = 0;
		if (gMainCfgObj.AllowUserNumber && !Boolean(bbs.node_settings & NM_NO_NUM) && username.match(/^[0-9]+$/))
			userNum = +username;
		else
		{
			// Try matching the user via username.  If that fails, and logins by real name
			// is allowed (in node settings), try matching against the user's real name.
			// It's possible there may be multiple users with the same name (though probably
			// not likely); this will match the first user with that name.
			userNum = system.matchuser(username);
			if (userNum < 1 && Boolean(bbs.node_settings & NM_LOGON_R)) // NM_LOGON_R: Allow logins using real name
				userNum = system.matchuserdata(U_NAME, username);
		}

		// If the user number is valid, then we can continue and
		// prompt for a password.
		if ((userNum > 0) && (system.username(userNum).length > 0))
		{
			// If the user didn't enter "guest", go to the password
			// prompt location and get ready to prompt for the password.
			if (username != "guest")
			{
				console.gotoxy(passwordX, passwordY);
				// If this is the not the first time through the loop,
				// then clear the password.
				if (loginAttempts > 0)
				{
					flashMessage(passwordX, passwordY, "", 0, passwordBoxInnerWidth-10,
								 true, pThemeCfgObj.PasswordTextColor);
					console.gotoxy(passwordX, passwordY);
				}
			}

			// Temporarily blank the "Unknown user" and "Invalid Logon" text strings
			bbs.replace_text(UnknownUser, "");
			bbs.replace_text(InvalidLogon, "");

			// Prompt for the password
			// BBS.login() update for 2017-08-10:
			// Allow more JavaScript control over password prompting:
			// bbs.login() now accepts 2 additional optional arguments: user_pw and sys_pw
			// if these passwords are supplied, they won't be prompted for by the underlying C
			// functions. If the password_prompt argument (2nd arg) is not supplied, no prompt
			// will be displayed, but a password must still be entered.
			// The default behavior is the same as before.
			// Original: bbs.login(user_name, password_prompt)
			var loginSuccess = false;
			if (system.version_num >= 31700)
			{
				// Maximum password length: Inner box width - prompt length
				// - 1 for the space after the prompt
				var maxPassLen = passwordBoxInnerWidth - passwordPromptLen;// - 1;
				if (typeof(system.max_password_length) === "number")
				{
					if (maxPassLen > system.max_password_length)
						maxPassLen = system.max_password_length;
				}
				if (pThemeCfgObj.DrawLoginPasswordBoxBorder && pThemeCfgObj.hasOwnProperty("PasswordBoxInnerWidth"))
					--maxPassLen;
				// Note: getStrWithTimeout() allows inputting more characters than are available
				// with the password box width, to allow inputting any length of password.
				console.print("\x01n" + pThemeCfgObj.PasswordTextColor); // Ensure the password text color is set & active
				var userPassword = getStrWithTimeout(maxPassLen, K_UPPER|K_NOSPIN|K_NOCRLF, "*", gMainCfgObj.MenuTimeoutMS);
				// If the inptuted password is blank, then we want to return to the logon menu
				// (set returnToLogonMenu to true and break out of this
				// username/password loop).
				if (userPassword.length == 0)
				{
					returnToLogonMenu = true;
					break;
				}
				// If the user is the sysop
				var theUser = new User(userNum);
				if (theUser.compare_ars("SYSOP"))
				{
					console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
					// Blank out the password area
					//printf("\x01n%" + +(maxPassLen) + "s", "");
					printf("\x01n%" + +(passwordBoxInnerWidth) + "s", "");
					// Get the system password from the user
					console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
					console.print("\x01c\x01hSY: \x01n");
					var systemPassword = getStrWithTimeout(0, K_UPPER|K_NOSPIN|K_NOCRLF|K_NOECHO, null, gMainCfgObj.MenuTimeoutMS);
					loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor, userPassword, systemPassword);
				}
				else
					loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor, userPassword);
				//delete theUser;
				theUser = undefined; // Destructs the object now, rather than with 'delete'
			}
			else
				loginSuccess = bbs.login(username, pThemeCfgObj.PasswordTextColor);
			if (loginSuccess)
			{
				console.clear();
				bbs.logon();
				loggedOn = true;
				retObj.continueLoginMatrixLoop = false; // For login matrix loop
				break;
			}
			else
			{
				// Go to the status box and tell the user the login
				// was invalid.
				flashMessage(statusX, statusY, pThemeCfgObj.StatusTextColor +
							 gMatrixLangStrings.INVALID_LOGIN, 1500,
							 pThemeCfgObj.StatusBoxInnerWidth,
							 (loginAttempts > 0));
				// Clear the password from the password box
				flashMessage(passwordX, passwordY, "", 0, passwordBoxInnerWidth-10,
							 true, pThemeCfgObj.PasswordBoxBkgColor);
				// Clear the status from the status box
				flashMessage(statusX, statusY, "", 0, pThemeCfgObj.StatusBoxInnerWidth,
							 true, pThemeCfgObj.StatusBoxBkgColor);
				// If the Synchronet version is at least 3.17 and the user is a
				// sysop, then re-write the password prompt text
				if (system.version_num >= 31700)
				{
					var theUser = new User(userNum);
					if (theUser.compare_ars("SYSOP"))
					{
						console.gotoxy(passwordX-passwordPromptLen-1, passwordY);
						console.print("\x01n" + pThemeCfgObj.PasswordPromptColor + passwordPrompt);
					}
					//delete theUser;
					theUser = undefined; // Destructs the object now, rather than with 'delete'
				}
			}

			// Revert the "Unknown user" and "Invalid Logon" text strings
			// back to their defaults.
			bbs.revert_text(390);
			bbs.revert_text(391);
		}
		else
		{
			var errorMsg = "";
			if (gMainCfgObj.AllowUserNumber)
				errorMsg = gMatrixLangStrings.UNKNOWN_USERNAME_OR_NUM;
			else
				errorMsg = gMatrixLangStrings.UNKNOWN_USERNAME;

			// Go to the status box and tell the user that the
			// username/number is unknown.
			flashMessage(statusX, statusY, pThemeCfgObj.StatusTextColor +
						 errorMsg, 1500, pThemeCfgObj.StatusBoxInnerWidth,
						 (loginAttempts > 0));
			// Clear the status text from the status box
			flashMessage(statusX, statusY, "", 0, pThemeCfgObj.StatusBoxInnerWidth,
						 true, pThemeCfgObj.StatusBoxBkgColor);
		}
	}

	// If we shouldn't return to the menu or do the new user login,
	// then quit the main menu loop.
	if (!returnToLogonMenu && !retObj.doNewUser)
	{
		retObj.continueLoginMatrixLoop = false; // For the login matrix loop
		// If the user didn't log on, then hang up.
		if (!loggedOn)
		{
			console.clear(CLEAR_SCR_ATTRS);
			console.gotoxy(1, 1);
			console.print(gMatrixLangStrings.LOGIN_ATTEMPTS_FAIL_MSG.replace("#", gMainCfgObj.MaxLoginAttempts));
			console.print("\x01n");
			bbs.hangup();
		}
	}

	return retObj;
}

/////////////////////////////////////////////////
// Other functions

// Performs the loop for the standard (non-matrix) login.
function loginStandard()
{
	var returnVal = true;

	if (gTraditionalGreetingFile.length > 0)
		bbs.menu(gTraditionalGreetingFile);
	for (var loopVar = 0; loopVar < 10; ++loopVar)
	{
		// The "node sync" is required for sysop interruption/chat/etc.
		bbs.nodesync();

		returnVal = doLogin(loopVar == 0);
		if (returnVal)
			break;

		// Password failure counts as 2 attempts
		++loopVar;
	}

	return returnVal;
}

// Helper for loginStandard() - Performs the username & password input.
function doLogin(pFirstTime)
{
	// Display login prompt
	console.print("\r\n" + gStdLangStrings.USERNAME_PROMPT);
	if (!Boolean(bbs.node_settings & NM_NO_NUM) && gMainCfgObj.AllowUserNumber)
		console.print(gStdLangStrings.OR_NUMER_PROMPT);
	if (!Boolean(system.settings & SYS_CLOSED))
		console.print("\r\n" + gStdLangStrings.NEW_USER_INFO);
	if (gGuestUserNum > 0 && gMainCfgObj.MenuDisplayGuestAccountIfExists)
		console.print("\r\n" + gStdLangStrings.GUEST_INFO);
	console.print("\r\n" + gStdLangStrings.LOGIN_PROMPT);

	// Get login string
	var str = console.getstr(25, // maximum user name length
	                         K_UPRLWR | K_TAB); // getkey/str mode flags
	truncsp(str);
	if (str.length == 0) // blank
		return false;

	// Set the color to high white on black background, and output
	// a couple blank lines for spacing after the login ANSI.
	console.print("\x01n\x01h\x01w\r\n\r\n");

	// New user application?
	if (str.toUpperCase() == gGenLangStrings.NEW.toUpperCase())
	{
		if (bbs.newuser())
		{
			bbs.logon();
			exit();
		}
		return true;
	}

	// Continue normal login (prompting for password)
	var retval = true;
	if (bbs.login(str, gStdLangStrings.PASSWORD_PROMPT))
		bbs.logon();
	else
	{
		if (gMainCfgObj.MenuDisplayRetrievePassword)
			retval = emailAcctInfo(str, true, false);
	}

	return retval;
}

// This function handles emailing the user's account information to the user.
//
// Parameters:
//  pUsernameOrEmailAddr: The user's username or email address
//  pAskIfForgotPass: Boolean - Whether or not to prompt the user whether or
//                    not they forgot their password before doing the lookup.
//  pPauseAfterMessages: Boolean - Whether or not to pause after displaying
//                       messages.  Optional.
function emailAcctInfo(pUsernameOrEmailAddr, pAskIfForgotPass, pPauseAfterMessages)
{
	if (pUsernameOrEmailAddr.length == 0)
		return false;

	var pauseAfterMsgs = false;
	if (pPauseAfterMessages != null)
		pauseAfterMsgs = pPauseAfterMessages;

	var retval = true;

	var usernum = 0;
	// If pUsernameOrEmailAddr has an @ in it, look up the user number based on email address. Otherwise,
	// look up the user number based on email (netmail) address.
	if (pUsernameOrEmailAddr.indexOf("@") > -1)
		usernum = system.matchuserdata(U_NETMAIL, pUsernameOrEmailAddr, 1); // Skip the sysop when searching
	else // Assume username
		usernum = system.matchuser(pUsernameOrEmailAddr);
	if (usernum > 0)
	{
		var theUser = new User(usernum);
		// Make sure the user is a valid and active user, is not a sysop,
		// and  has an internet email address.
		var continueOn = (!(theUser.settings&(USER_DELETED|USER_INACTIVE))
		                  && theUser.security.level < 90
		                  && netaddr_type(theUser.netmail) == NET_INTERNET);
		// If the user can retrieve their password and if pAskIfForgotPass is
		// true, then ask the user if they forgot their password.
		if (continueOn && pAskIfForgotPass)
			continueOn = !console.noyes(gGenLangStrings.DID_YOU_FORGET_PASSWORD_CONFIRM);
		// If we can send the user their account info, then go ahead and do it.
		if (continueOn)
		{
			console.print(gGenLangStrings.EMAIL_ADDR_CONFIRM_PROMPT);
			var email_addr = console.getstr(50);
			if (email_addr.toLowerCase() == theUser.netmail.toLowerCase())
			{
				var msgbase = new MsgBase("mail");
				if (msgbase.open() == false)
				{
					console.print("\r\n" + msgbase.last_error + "\r\n");
					if (pauseAfterMsgs)
						console.pause();
					alert(log(LOG_ERR,"!ERROR " + msgbase.last_error));
				}
				else
				{
					var hdr =
					{
						to: theUser.alias,
						to_net_addr: theUser.netmail, 
						to_net_type: NET_INTERNET,
						from: system.name,
						from_net_addr: "noreply@" + system.inet_addr,
						from_ext: 1, 
						subject: system.name + " user account information"
					};

					var msgtxt = gGenLangStrings.ACCT_INFO_REQUESTED_ON_TIME + " "
					           + system.timestr() + "\r\n";
					msgtxt += gGenLangStrings.BY + " " + client.host_name + " [" +
					client.ip_address +"] " + gGenLangStrings.VIA + " " +
					client.protocol + " (TCP " + gGenLangStrings.PORT + " " +
					client.port + "):\r\n\r\n";
					msgtxt += gGenLangStrings.INFO_ACCT_NUM + " " + theUser.number + "\r\n";
					msgtxt += gGenLangStrings.INFO_CREATED + " " + system.timestr(theUser.stats.firston_date) + "\r\n";
					msgtxt += gGenLangStrings.INFO_LAST_ON + " " + system.timestr(theUser.stats.laston_date) + "\r\n";
					msgtxt += gGenLangStrings.INFO_CONNECT + " " + theUser.host_name + " [" + theUser.ip_address + "] " +
					          gGenLangStrings.VIA + " " + theUser.connection + "\r\n";
					msgtxt += gGenLangStrings.INFO_PASSWORD + " " + theUser.security.password + "\r\n";

					if (msgbase.save_msg(hdr, msgtxt))
					{
						console.crlf();
						console.print(gGenLangStrings.ACCT_INFO_EMAILED_TO + theUser.netmail);
						console.crlf();
						if (pauseAfterMsgs)
							console.pause();
					}
					else
					{
						console.crlf();
						console.print(gGenLangStrings.ERROR_SAVING_BULKMAIL_MESSAGE + " " + msgbase.last_error);
						console.crlf();
						if (pauseAfterMsgs)
							console.pause();
						alert(log(LOG_ERR,"!ERROR " + msgbase.last_error));
					}

					msgbase.close();
				}
				retval = true;
			}
			else
			{
				alert(log(LOG_WARNING, gStdLangStrings.INFO_INCORRECT_EMAIL_ADDR + " " + email_addr));
				console.crlf();
				console.print(gGenLangStrings.INFO_INCORRECT_EMAIL_ADDR + " " + email_addr);
				console.crlf();
				if (pauseAfterMsgs)
					console.pause();
				retval = false;
			}
		}
		else
		{
			console.crlf();
			console.print(gGenLangStrings.UNABLE_TO_RETRIEVE_ACCT_INFO);
			console.crlf();
			if (pauseAfterMsgs)
				console.pause();
			retval = false;
		}
		//delete theUser;
		theUser = undefined; // Destructs the object now, rather than with 'delete'
	}
	else
	{
		console.crlf();
		console.print(gMatrixLangStrings.UNKNOWN_USERNAME_OR_NUM);
		console.crlf();
		if (pauseAfterMsgs)
			console.pause();
	}

	return retval;
}

// Returns the name of a random theme directory within the DDLoginMatrixThemes
// directory.
//
// Parameters:
//  pScriptDir: The script execution directory (with trailing slash)
//  pOnlySixelThemesForSixelTerminal: Boolean - Whether or not to only use themes
//                                    with sixels for sixel-capable terminals.
//                                    Defaults to false.
//
// Return value: The name of a random theme directory within the
//               DDLoginMatrixThemes directory.
function randomMatrixThemeDir(pScriptDir, pOnlySixelThemesForSixelTerminal)
{
	var onlySixelThemesForSixelTerm = (typeof(pOnlySixelThemesForSixelTerminal) === "boolean" ? pOnlySixelThemesForSixelTerminal : false);

	// Build an array of the directories in the DDLoginMatrixThemes
	// directory.
	var dirs = []; // An array of the directory names
	// For themes, first look for a 'themes' dir, then a 'DDLoginMatrixThemes' dir
	var files = [];
	var themesDirName = pScriptDir + "themes/";
	if (file_isdir(themesDirName))
		files = directory(themesDirName + "*");
	else
	{
		themesDirName = pScriptDir + "DDLoginMatrixThemes/";
		if (file_isdir(themesDirName))
			files = directory(themesDirName + "*");
	}
	if (files.length > 0)
	{
		var pos = 0;  // For finding text positions in the filenames
		var filename = null;
		var seen = {}; // For storing directory names we've already seen
		var userTermSupportsSixel = supports_sixel();
		for (var i in files)
		{
			if (file_isdir(files[i]))
			{
				// Look for "digdist" in the path and copy only the path
				// from that point.
				pos = files[i].indexOf("digdist");
				if (pos > 0)
					filename = files[i].substr(pos);
				else
					filename = files[i];

				// If we haven't seen the filename, then add it to the
				// dirs array.
				if (typeof(seen[filename]) == "undefined")
				{
					var includeThisTheme = true;

					// -- RIP stuff --
					// If not only including sixel themes, then if the theme has RIP images
					// but no .ans, .asc, .msg, or other images, then don't include this theme.
					if (!onlySixelThemesForSixelTerm && !console.term_supports(USER_RIP))
					{
						var ripFilenames = directory(backslash(themeDirName) + "*.rip");
						var traditionalScreenFilenames = directory(backslash(themeDirName) + "*.ans")
						      .concat(directory(backslash(themeDirName) + "*.asc"))
						      .concat(directory(backslash(themeDirName) + "*.msg"));
						var graphicFilesForSixel = directory(backslash(themeDirName) + "*.jpg")
						      .concat(directory(backslash(themeDirName) + "*.jpeg"))
						      .concat(directory(backslash(themeDirName) + "*.png"))
						      .concat(directory(backslash(themeDirName) + "*.gif"))
						      .concat(directory(backslash(themeDirName) + "*.sixel"));
						if (ripFilenames.length > 0 && traditionalScreenFilenames.length == 0 && graphicFilesForSixel.length == 0)
							includeThisTheme = false;
					}

					if (includeThisTheme)
					{
						// -- Sixel stuff --
						// Check to see if the theme has sixel images.
						// The filename path must be fixed to be a relative path.
						// Look for ".." in the path and remove everything before that
						var themeDirName = filename;
						var dotsIdx = themeDirName.indexOf("..");
						if (dotsIdx > -1)
							themeDirName = themeDirName.substr(dotsIdx);
						// Check for graphic files (we can display sixels & convert some graphic files to sixels)
						var imageFilenames = [];
						for (var imgExtI = 0; imgExtI < gImgFilenameExts.length; ++imgExtI)
						{
							var filenames = directory(backslash(themeDirName) + "*." + gImgFilenameExts[imgExtI]);
							imageFilenames = imageFilenames.concat(filenames);
						}
						// Sixel files
						imageFilenames = imageFilenames.concat(directory(backslash(themeDirName) + "*.sixel"));
						if (imageFilenames.length > 0)
						{
							// If the user's terminal doesn't support sixels, then if there are only
							// .sixel or other image files for the background images (no .asc/.ans/.msg
							// versions), then don't include this theme.
							if (!userTermSupportsSixel)
							{
								includeThisTheme = false; // In case there are only sixel backgrounds
								for (var filenameI in imageFilenames)
								{
									// Remove the filename extension and see if there is a .asc,
									// .ans, or .msg version of the file. If so, then include this
									// theme
									var filenameWithoutExt = imageFilenames[filenameI];
									var extIdx = filenameWithoutExt.lastIndexOf(".");
									if (extIdx > -1)
										filenameWithoutExt = filenameWithoutExt.substr(0, extIdx);
									includeThisTheme = (directory(filenameWithoutExt + ".ans").length > 0 ||
									                    directory(filenameWithoutExt + ".asc").length > 0 ||
									                    directory(filenameWithoutExt + ".msg").length > 0);
									if (includeThisTheme)
										break;
								}
							}
						}
						else
						{
							// No image/sixel files.  If the user's terminal supports sixels and we
							// only want to include sixel themes, then don't include this theme.
							if (userTermSupportsSixel && onlySixelThemesForSixelTerm)
								includeThisTheme = false;
						}
					}

					// If we should include this theme, then do so
					if (includeThisTheme)
					{
						dirs.push(filename);
						seen[filename] = true;
					}
				}
			}
		}
	}

	// Return one of the directory names at random
	//return(dirs.length > 0 ? filenames[random(filenames.length)] : "");
	var dirName = "";
	// If the filenames array has some filenames in it, then get
	// one at random.  Also, fix the filename to have the correct
	// full path.
	if (dirs.length > 0)
	{
		dirName = dirs[random(dirs.length)];
		var pos = dirName.indexOf("DDLoginMatrixThemes");
		if (pos > -1)
			dirName = pScriptDir + dirName.substr(pos);
	}

	return dirName;
}

// Reads DDMatrixTheme.cfg in a random theme configuration
// directory and returns the configuration settings in an
// object.
//
// Parameters:
//  pScriptDir: The full path where the script is located (with trailing slash)
//  pWhichTheme: String - Specifies which theme configuration to get, or "Random"
//               to choose a random theme.
//  pOnlySixelThemesForSixelTerminal: Boolean - When choosing a random theme, whether
//                                    or not to only use themes with sixels for
//                                    sixel-capable terminals.
//
// Return value: An object containing the theme settings.
function getThemeCfg(pScriptDir, pWhichTheme, pRandomOnlySixelThemesForSixelTerminal)
{
	var themeDir = "";
	if (pWhichTheme.toLowerCase() == "random")
		themeDir = randomMatrixThemeDir(pScriptDir, pRandomOnlySixelThemesForSixelTerminal);
	else
	{
		// For themes, first try using a "themes" directory, and if that doesn't exist,
		// use "DDLoginMatrixThemes" (which was the old name)
		themeDir = backslash(pScriptDir) + "themes/" + pWhichTheme + "/";
		if (!file_isdir(themeDir))
				themeDir = backslash(pScriptDir) + "DDLoginMatrixThemes/" + pWhichTheme + "/";

	}

	// Create cfgObject, the configuration object that we will
	// be returning.
	var cfgObj = {
		ThemeDir: themeDir,
		SixelCacheDir: backslash(backslash(themeDir) + gSixelCacheDirNameForThemes),
		MinTerminalWidth: 0, // No minimum width
		InitialBackgroundFilename: themeDir + "InitialBackground",
		LoginBackgroundFilename: themeDir + "LoginBackground",
		ScaleInitialBkgImgWithHeight: false,
		ScaleLoginImgWithHeight: false,
		MenuX: 43,
		MenuY: 8,
		//MenuBorders: "double", // Deprecated in favor of InitialMenuBorder
		DrawMenuBorder: true,
		InitialMenuBorder: "double",
		DrawLoginNameBoxBorder: true,
		LoginNameBoxBorder: "double",
		DrawLoginPasswordBoxBorder: true,
		LoginPasswordBoxBorder: "double",
		DrawLoginStatusBoxBorder: true,
		LoginStatusBoxBorder: "double",
		ClearSpaceAroundMenu: true,
		MenuTitle: "Login menu",
		DisplayMenuTitle: true,
		MenuColor_Border: "\x01h\x01b",
		MenuColor_Unselected: "\x01n\x01h\x01w",
		MenuColor_Selected: "\x01n\x01" + "4\x01h\x01c",
		MenuColor_Hotkey: "\x01h\x01y",
		MenuColor_ClearAroundMenu: "\x01n",
		UsernameX: 22,
		UsernameY: 7,
		UsernameLength: 25,
		UsernameBoxBorderColor: "\x01n\x01h\x01g",
		UsernameBoxBkgColor: "\x01n",
		UsernamePromptColor: "\x01n\x01c",
		UsernameTextColor: "\x01h\x01c",
		PasswordX: 22,
		PasswordY: 11,
		PasswordBoxBorderColor: "\x01n\x01h\x01g",
		PasswordBoxBkgColor: "\x01n",
		PasswordPromptColor: "\x01n\x01c",
		PasswordTextColor: "\x01h\x01c",
		StatusX: 22,
		StatusY: 15,
		StatusBoxInnerWidth: 35,
		StatusBoxBorderColor: "\x01n\x01h\x01b",
		StatusBoxBkgColor: "\x01n",
		StatusTextColor: "\x01n\x01h\x01y",
		InitialBackgroundInfoLines: [],
		LoginInfoLines: [],
		userTermIsWideEnough: function()
		{
			return (this.MinTerminalWidth > 0 ? console.screen_columns >= this.MinTerminalWidth : true);
		},
		RIP: {
			UseRIP: false,
			RIPMenuVertical: true,
			RIPMenuX: 0,
			RIPMenuY: 0,
			RIPFont: RIP_FONT_DEFAULT,
			RIPFontSize: 1,
			RIPBtnTextColor: RIP_COLOR_YELLOW,
			RIPBtnBorderColor: RIP_COLOR_WHITE,
			RIPBtnDarkVal: RIP_COLOR_DK_GRAY,
			RIPBtnSurfaceColor: RIP_COLOR_LT_BLUE,
			InitialBackgroundAdditionalRIPCmds: [],
			LoginAdditionalRIPCmds: []
		}
	};

	// For option processing: Takes a value, either a number or a string
	// with a numeric percent (of the screen's horizontal/X or vertical/Y
	// size) and returns a numeric value.
	//
	// Parameters:
	//  pVal: A number or a string with a number and % sign
	//  pIsX: Boolean: If a percent, whether it's for console.screen_columns; if false,
	//        it will be used with console.screen_rows
	//
	// Return value: A number with the value
	function numOrScreenPercentStr(pVal, pIsX)
	{
		var retVal = 0;
		var optType = typeof(pVal);
		if (optType === "number")
			retVal = pVal;
		else if (optType === "string")
			retVal = strToIntOrPercentOfValue(pVal, pIsX ? console.screen_columns : console.screen_rows, 1);
		return retVal;
	}
	// For option processing: Booleans - Just use the val if it's a boolean, or
	// if it's a string, allow "yes"/"no" or "true"/"false"
	function boolOrStrToBool(pVal)
	{
		var retVal = false;
		var optType = typeof(pVal);
		// Boolean
		if (optType === "boolean")
			retVal = pVal;
		else if (optType === "string")
		{
			var optionValLower = pVal.toLowerCase();
			retVal = (optionValLower == "yes" || optionValLower == "true");
		}
		return retVal;
	}

	// If themeDir is a valid directory, then open DDMatrixTheme.cfg
	// in that directory.
	if (file_isdir(themeDir))
	{
		var cfgFile = new File(themeDir + gGeneralThemeCfgFilename);
		if (cfgFile.open("r"))
		{
			var oldMenuBordersVal = ""; // Deprecated in favor of InitialMenuBorder
			var sawInitialMenuBorder = false;
			var sawLoginNameBoxBorder = false;
			var sawLoginPasswordBoxBorder = false;
			var sawLoginStatusBoxBorder = false;

			// Get the general & RIP configuration objects
			var genCfg = cfgFile.iniGetObject();
			if (genCfg == null || (genCfg != null && typeof(genCfg) === "object" && Object.keys(genCfg).length == 0))
				genCfg = cfgFile.iniGetObject("GENERAL");
			var ripCfg = cfgFile.iniGetObject("RIP");
			cfgFile.close();

			// Set the general config values in cfgObj, which we'll return
			if (genCfg != null)
			{
				// Iterating through the config object key names because
				// we do the same thing for several of the values
				var genKeys = Object.keys(genCfg);
				for (var i = 0; i < genKeys.length; ++i)
				{
					var optionName = genKeys[i];
					// Initial or logon background filename
					if (optionName == "InitialBackgroundFilename" || optionName == "LoginBackgroundFilename")
					{
						// If the value is non-blank, then set it in cfgObj.
						// Otherwise, set the value in cfgObj to a blank string.
						if (typeof(genCfg[optionName]) === "string" && genCfg[optionName].length > 0)
						{
							cfgObj[optionName] = themeDir + genCfg[optionName];
							// The filename path must be fixed to be a relative path.
							// Look for ".." in the path and remove everything before
							// that, but add another "../" before it.
							//var dotsIdx = cfgObj[optionName].indexOf("..");
							//if (dotsIdx > -1)
							//	cfgObj[optionName] = "../" + cfgObj[optionName].substr(dotsIdx);
						}
						else
							cfgObj[optionName] = "";
					}
					// Numeric options
					else if (optionName == "MenuX" || optionName == "UsernameX" || optionName == "PasswordX" || optionName == "StatusX")
						cfgObj[optionName] = numOrScreenPercentStr(genCfg[optionName], true);
					else if (optionName == "MenuY" || optionName == "UsernameY" || optionName == "PasswordY" || optionName == "StatusY")
						cfgObj[optionName] = numOrScreenPercentStr(genCfg[optionName], false);
					else if (optionName == "UsernameLength" || optionName == "StatusBoxInnerWidth" || optionName == "PasswordBoxInnerWidth")
					{
						if (typeof(genCfg[optionName]) === "number")
							cfgObj[optionName] = genCfg[optionName];
					}
					else if (optionName == "MenuBorders")
					{
						// This option is deprecated in favor of InitialMenuBorder (handled next)
						if (typeof(genCfg[optionName]) === "string")
							oldMenuBordersVal = genCfg[optionName].toLowerCase(); // Ensure lowercase
					}
					else if (optionName == "InitialMenuBorder")
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							cfgObj.InitialMenuBorder = genCfg[optionName].toLowerCase(); // Ensure lowercase
							sawInitialMenuBorder = true;
						}
					}
					else if (optionName == "LoginNameBoxBorder")
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							cfgObj.LoginNameBoxBorder = genCfg[optionName].toLowerCase(); // Ensure lowercase
							sawLoginNameBoxBorder = true;
						}
					}
					else if (optionName == "LoginPasswordBoxBorder")
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							cfgObj.LoginPasswordBoxBorder = genCfg[optionName].toLowerCase(); // Ensure lowercase
							sawLoginPasswordBoxBorder = true;
						}
					}
					else if (optionName == "LoginStatusBoxBorder")
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							cfgObj.LoginStatusBoxBorder = genCfg[optionName].toLowerCase(); // Ensure lowercase
							sawLoginStatusBoxBorder = true;
						}
					}
					// Boolean options
					else if (optionName == "ClearSpaceAroundMenu" || optionName == "DisplayMenuTitle" ||
					         optionName == "ScaleInitialBkgImgWithHeight" || optionName == "ScaleLoginImgWithHeight")
					{
						cfgObj[optionName] = boolOrStrToBool(genCfg[optionName]);
					}
					// Menu title
					else if (optionName == "MenuTitle")
					{
						if (typeof(genCfg[optionName]) === "string")
							cfgObj[optionName] = genCfg[optionName]; // Set it as-is
					}
					// Minimum terminal width
					else if (optionName == "MinTerminalWidth")
					{
						if (typeof(genCfg[optionName]) === "number")
							cfgObj.MinTerminalWidth = genCfg[optionName];
					}
					// Initial background info line
					else if (optionName.indexOf("InitialBackgroundInfoLine") == 0)
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							// A text string to display on the initial background after the initial background has been displayed
							// The value needs to be in the format colors,X,Y,string
							// For example: nch,1,1,@BBS@
							var valArray = genCfg[optionName].split(",");
							if (valArray.length == 4)
							{
								var screenX = strToIntOrPercentOfValue(valArray[1], console.screen_columns, 1);
								var screenY = strToIntOrPercentOfValue(valArray[2], console.screen_rows, 1);
								if (screenX >= 1 && screenY >= 1)
								{
									cfgObj.InitialBackgroundInfoLines.push({
										X: screenX,
										Y: screenY,
										attrChars: valArray[0].toUpperCase(),
										str: valArray[3]
									});
								}
							}
						}
					}
					// Login screen info line
					else if (optionName.indexOf("LoginInfoLine") == 0)
					{
						if (typeof(genCfg[optionName]) === "string")
						{
							// A text string to display on the initial background after the initial background has been displayed
							// The value needs to be in the format colors,X,Y,string
							// For example: nch,1,1,@BBS@
							var valArray = genCfg[optionName].split(",");
							if (valArray.length == 4)
							{
								var screenX = strToIntOrPercentOfValue(valArray[1], console.screen_columns, 1);
								var screenY = strToIntOrPercentOfValue(valArray[2], console.screen_rows, 1);
								if (screenX >= 1 && screenY >= 1)
								{
									cfgObj.LoginInfoLines.push({
										X: screenX,
										Y: screenY,
										attrChars: valArray[0].toUpperCase(),
										str: valArray[3]
									});
								}
							}
						}
					}
					// Colors
					else if (optionName == "MenuColor_Border" || optionName == "MenuColor_Unselected" ||
						optionName == "MenuColor_Selected" || optionName == "MenuColor_Hotkey" ||
						optionName == "MenuColor_ClearAroundMenu" || optionName == "UsernameBoxBorderColor" ||
						optionName == "UsernameBoxBkgColor" || optionName == "PasswordBoxBorderColor" ||
						optionName == "PasswordBoxBkgColor" || optionName == "StatusBoxBorderColor" ||
						optionName == "StatusBoxBkgColor" || optionName == "UsernamePromptColor" ||
						optionName == "UsernameTextColor" || optionName == "PasswordPromptColor" ||
						optionName == "PasswordTextColor" || optionName == "StatusTextColor")
					{
						if (typeof(genCfg[optionName]) === "string" && genCfg[optionName].length > 0)
							cfgObj[optionName] = attrCodeStr(genCfg[optionName]);
					}
				}
			}
			// Set the config values from the RIP config object
			if (ripCfg != null)
			{
				// Iterating through the config object key names because
				// we want to search for setting names starting with
				// "InitialBackgroundAdditionalRIPCmds" or "LoginAdditionalRIPCmds",
				// etc.
				var ripKeys = Object.keys(ripCfg);
				for (var i = 0; i < ripKeys.length; ++i)
				{
					var optionName = ripKeys[i];
					if (optionName == "RIPMenuX")
						cfgObj.RIP.RIPMenuX = numOrScreenPercentStr(ripCfg[optionName], true);
					else if (optionName == "RIPMenuY")
						cfgObj.RIP.RIPMenuY = numOrScreenPercentStr(ripCfg[optionName], false);
					// Boolean options
					else if (optionName == "UseRIP" || optionName == "RIPMenuVertical")
						cfgObj.RIP[optionName] = boolOrStrToBool(ripCfg[optionName]);
					else if (optionName == "RIPFont")
					{
						var valType = typeof(ripCfg.RIPFont);
						if (valType === "number")
						{
							if (cfgObj.RIP.RIPFont >= RIP_FONT_DEFAULT && cfgObj.RIP.RIPFont <= RIP_FONT_BOLD)
								cfgObj.RIP.RIPFont = ripCfg.RIPFont;
						}
						else if (valType === "string")
							cfgObj.RIP.RIPFont = RIPFontStrToNumericVal(ripCfg.RIPFont);
					}
					else if (optionName == "RIPFontSize")
					{
						if (typeof(ripCfg.RIPFontSize) === "number" && ripCfg.RIPFontSize > 0)
							cfgObj.RIP.RIPFontSize = ripCfg.RIPFontSize;
					}
					else if (optionName == "RIPBtnTextColor" || optionName == "RIPBtnBorderColor" ||
					         optionName == "RIPBtnDarkVal" || optionName == "RIPBtnSurfaceColor")
					{
						cfgObj.RIP[optionName] = RIPColorSetting(ripCfg, optionName);
					}
					else if (optionName.indexOf("InitialBackgroundAdditionalRIPCmds") == 0)
					{
						if (typeof(ripCfg[optionName]) === "string" && ripCfg[optionName].length > 0)
							cfgObj.RIP.InitialBackgroundAdditionalRIPCmds.push(ripCfg[optionName]);
					}
					else if (optionName.indexOf("LoginAdditionalRIPCmds") == 0)
					{
						if (typeof(ripCfg[optionName]) === "string" && ripCfg[optionName].length > 0)
							cfgObj.RIP.LoginAdditionalRIPCmds.push(ripCfg[optionName]);
					}
				}
			}

			// For the menu/box border style, if the theme configuration object
			// has only the deprecated MenuBorders option, then set all the border
			// options to that
			if (oldMenuBordersVal.length > 0)
			{
				if (!sawInitialMenuBorder)
					cfgObj.InitialMenuBorder = oldMenuBordersVal;
				if (!sawLoginNameBoxBorder)
					cfgObj.LoginNameBoxBorder = oldMenuBordersVal;
				if (!sawLoginPasswordBoxBorder)
					cfgObj.LoginPasswordBoxBorder = oldMenuBordersVal;
				if (!sawLoginStatusBoxBorder)
					cfgObj.LoginStatusBoxBorder = oldMenuBordersVal;
			}
		}
	}

	cfgObj.DrawMenuBorder = (cfgObj.InitialMenuBorder != "none");
	cfgObj.DrawLoginNameBoxBorder = (cfgObj.LoginNameBoxBorder != "none");
	cfgObj.DrawLoginPasswordBoxBorder = (cfgObj.LoginPasswordBoxBorder != "none");
	cfgObj.DrawLoginStatusBoxBorder = (cfgObj.LoginStatusBoxBorder != "none");

	return cfgObj;
}

// Reads the main configuration file, DDLoginMatrix.cfg, and returns
// an object containing the script configuration options.
//
// Parameters:
//  pScriptDir: The full path where the script is located (with trailing slash)
//
// Return object properties:
//  UseMatrix: Whether or not to use the matrix-style login
//  MenuTimeoutMS: The menu input timeout, in ms.
//  MenuDisplayNewUser: Boolean - Whether or not to display the new user menu option
//  MenuDisplayGuestAccountIfExists: Boolean - Whether or not to display the guest
//                                   account menu option, if the guest account exists
//  MenuDisplayRetrievePassword: Boolean - Whether or not to display the menu option
//                               for retrieving a user's password.
//  MenuDisplayEmailSysop: Boolean - Whether or not to display the "email sysop" menu option
//  MenuDisplayPageSysop: Boolean - Whether or not to display the "page sysop" menu option
//  MaxLoginAttempts: The maximum number of user login attempts
//  AllowUserNumber: Boolean - Whether or not to allow logging in via user number
//  MatrixTheme: A string specifying the theme to use.  "Random" specifies to choose a
//               random theme.
//  Language: The name of the language to use.  This will be the name of the file in the
//            DDLoginMatrixLangFiles directory without the extension.
function ReadConfigFile(pScriptDir)
{
	// Set up a config object with default values
	var cfgObj = {
		UseMatrix: true,
		MenuTimeoutMS: 60000,
		MenuDisplayNewUser: true,
		MenuDisplayGuestAccountIfExists: true,
		MenuDisplayRetrievePassword: true,
		MenuDisplayEmailSysop: true,
		MenuDisplayPageSysop: true,
		MaxLoginAttempts: 3,
		AllowUserNumber: true,
		MatrixTheme: "Random",
		RandomOnlySixelThemesForSixelTerminal: false,
		Language: "English",
		SoundFile: "",
		PlaySound: true,
		PathToImgToSixelConv: "",
		remoteConnections: []
	};

	var cfgFile = new File(pScriptDir + "DDLoginMatrix.cfg");
	if (cfgFile.open("r"))
	{
		var settingsObj = cfgFile.iniGetObject();
		cfgFile.close();
		for (var settingName in cfgObj)
		{
			if (settingsObj.hasOwnProperty(settingName))
			{
				var loadedSettingType = typeof(settingsObj[settingName]);
				var cfgObjSettingType = typeof(cfgObj[settingName]);
				if (loadedSettingType == cfgObjSettingType)
					cfgObj[settingName] = settingsObj[settingName];
				else
				{
					// For backward compatibility, for the boolean settings, allow "yes"/"no" in addition to true/false
					if (cfgObjSettingType == "boolean" && loadedSettingType == "string")
					{
						var settingLower = settingsObj[settingName].toLowerCase();
						cfgObj[settingName] = (settingLower == "yes" || settingLower == "true");
					}
				}
			}
		}

		// If TestIPAddresses or TestHostnames is specified and there is a TestMatrixTheme specified,
		// then if the user's IP address is included in the list if test IP addresses, then use the
		// test theme
		var testIPAddrsValid = typeof(settingsObj.TestIPAddresses) === "string" && settingsObj.TestIPAddresses != "";
		var testHostnamesValid = typeof(settingsObj.TestHostnames) === "string" && settingsObj.TestHostnames != "";
		var testMatrixThemeValid = typeof(settingsObj.TestMatrixTheme) === "string" && settingsObj.TestMatrixTheme != "";
		if (testMatrixThemeValid && (testIPAddrsValid || testHostnamesValid))
		{
			var clientMatch = false;
			if (testIPAddrsValid)
			{
				var testIPAddrs = settingsObj.TestIPAddresses.split(",");
				for (var i = 0; i < testIPAddrs.length && !clientMatch; ++i)
					clientMatch = clientMatch || (skipsp(truncsp(testIPAddrs[i])) == client.ip_address);
			}
			if (!clientMatch && testHostnamesValid)
			{
				var testHostnames = settingsObj.TestHostnames.split(",");
				for (var i = 0; i < testHostnames.length && !clientMatch; ++i)
					clientMatch = clientMatch || (skipsp(truncsp(testHostnames[i])) == client.host_name);
			}
			if (clientMatch)
				cfgObj.MatrixTheme = settingsObj.TestMatrixTheme;
		}

		// Do some bounds checking on the numeric settings.
		if (cfgObj.MenuTimeoutMS < 1)
			cfgObj.MenuTimeoutMS = 60000;
		if (cfgObj.MaxLoginAttempts < 1)
			cfgObj.MaxLoginAttempts = 3;

		// Verify that the file specified by PathToImgToSixelConv exists; if not, then blank it
		if (!file_exists(cfgObj.PathToImgToSixelConv))
			cfgObj.PathToImgToSixelConv = "";
	}

	var remoteConnectionsFile = new File(pScriptDir + "remoteConnections.ini");
	if (remoteConnectionsFile.open("r"))
	{
		var iniObjects = remoteConnectionsFile.iniGetAllObjects();
		remoteConnectionsFile.close();
		for (var i = 0; i < iniObjects.length; ++i)
		{
			// name (section name)
			// display_name
			// address
			// port (number)
			// protocol
			cfgObj.remoteConnections.push({
				display_name: iniObjects[i].display_name,
				address: iniObjects[i].address,
				port: iniObjects[i].port,
				protocol: iniObjects[i].protocol
			});
		}
	}

	return cfgObj;
}

// Loads the language strings in gMatrixLangStrings, gStdLangStrings,
// and gGenLangStrings from a specified file.
//
// Paramaters:
//  pLangFile: The full path & filename of the file from which to load
//             the language strings
function loadLangStrings(pLangFile)
{
	// Try to find the correct filename case.  If unable to find it,
	// then just return.
	var langFilename = file_getcase(pLangFile);
	if (langFilename == undefined)
		return;

	// Open the language file ad start reading it.
	var langFile = new File(langFilename);
	if (langFile.open("r"))
	{
		// String categories
		const CAT_MATRIX = 0;  // Strings for the login matrix
		const CAT_STD = 1;     // Strings for standard login
		const CAT_GEN = 2;     // General strings

		var strCategory = -1; // Will store the current string category

		// Read each line from the config file and set the
		// strings in the proper language string object.
		while (!langFile.eof)
		{
			// Read the line from the config file, look for a =, and
			// if found, read the option & value and set them
			// in cfgObj.
			var fileLine = langFile.readln(512);
			// fileLine should be a string, but I've seen some cases
			// where it isn't, so check its type.
			if (typeof(fileLine) != "string")
				continue;
			// If the line is blank or starts with with a semicolon
			// (the comment character), then skip it.
			if ((fileLine.length == 0) || (fileLine.substr(0, 1) == ";"))
				continue;

			// Check for and set the string category
			if (fileLine.toUpperCase() == "[MATRIX]")
			{
				strCategory = CAT_MATRIX;
				continue;
			}
			else if (fileLine.toUpperCase() == "[STANDARD]")
			{
				strCategory = CAT_STD;
				continue;
			}
			else if (fileLine.toUpperCase() == "[GENERAL]")
			{
				strCategory = CAT_GEN;
				continue;
			}

			// Replace any instances of "\x01" or "\1" with the Synchronet attribute control character.
			fileLine = fileLine.replace(/\\[xX]01/g, "\x01").replace(/\\1/g, "\x01");

			// Look for an = in the line, and if found, split into
			// option & value.
			var equalsPos = fileLine.indexOf("=");
			if (equalsPos > -1)
			{
				// Extract the option & value, trimming leading & trailing spaces.
				var option = trimSpaces(fileLine.substr(0, equalsPos), true, false, true).toUpperCase();
				var optionValue = trimSpaces(fileLine.substr(equalsPos+1), true, false, true);
				// Set the option in the proper language object.
				switch (strCategory)
				{
					case CAT_MATRIX:
						gMatrixLangStrings[option] = optionValue;
						break;
					case CAT_STD:
						gStdLangStrings[option] = optionValue;
						break;
					case CAT_GEN:
						gGenLangStrings[option] = optionValue;
						break;
				}
			}
		}
		langFile.close();
	}
}

// Draws a one-line text box, with (optionally) some text inside it.
//
// Parameters:
//  pX: The upper-left horizontal coordinate of the box
//  pY: The upper-left vertical coordinate of the box
//  pStyle: "single" for single-line border, or "double" for double-line border
//  pBorderColor: A Synchronet color code to use for the border
//  pInnerColor: A Synchronet color code to use when blanking out the inside of the box
//  pInnerText: Optional - Text to be displayed inside the box (color codes allowed)
function drawOneLineInputBox(pX, pY, pWidth, pStyle, pBorderColor, pInnerColor, pInnerText)
{
	// Determine which border characters to use, based on pStyle
	const UPPER_LEFT = (pStyle == "double" ? CP437_BOX_DRAWINGS_UPPER_LEFT_DOUBLE : CP437_BOX_DRAWINGS_UPPER_LEFT_SINGLE);
	const HORIZONTAL = (pStyle == "double" ? CP437_BOX_DRAWINGS_HORIZONTAL_DOUBLE : CP437_BOX_DRAWINGS_HORIZONTAL_SINGLE);
	const UPPER_RIGHT = (pStyle == "double" ? CP437_BOX_DRAWINGS_UPPER_RIGHT_DOUBLE : CP437_BOX_DRAWINGS_UPPER_RIGHT_SINGLE);
	const LOWER_LEFT = (pStyle == "double" ? CP437_BOX_DRAWINGS_LOWER_LEFT_DOUBLE : CP437_BOX_DRAWINGS_LOWER_LEFT_SINGLE);
	const VERTICAL = (pStyle == "double" ? CP437_BOX_DRAWINGS_DOUBLE_VERTICAL : CP437_BOX_DRAWINGS_LIGHT_VERTICAL);
	const LOWER_RIGHT = (pStyle == "double" ? CP437_BOX_DRAWINGS_LOWER_RIGHT_DOUBLE : CP437_BOX_DRAWINGS_LOWER_RIGHT_SINGLE);

	var innerWidth = pWidth - 2;

	// Top border
	console.gotoxy(pX, pY);
	console.print(pBorderColor + UPPER_LEFT);
	for (var i = 0; i < innerWidth; ++i)
		console.print(HORIZONTAL);
	console.print(UPPER_RIGHT);
	// Middle row
	console.gotoxy(pX, pY+1);
	console.print(VERTICAL);
	if (pInnerText != null)
		console.print(pInnerText);
	console.print(pInnerColor);
	for (var i = (pInnerText != null ? strip_ctrl(pInnerText).length : 0); i < innerWidth; ++i)
		console.print(" ");
	console.print(pBorderColor + VERTICAL);
	// Bottom border
	console.gotoxy(pX, pY+2);
	console.print(LOWER_LEFT);
	for (var i = 0; i < innerWidth; ++i)
		console.print(HORIZONTAL);
	console.print(LOWER_RIGHT);
}

// Flashes a message on the screen at a given location for a given amount of time.
//
// Parameters:
//  pX: The horizontal location on the screen of where to write the message
//  pY: The vertical location on the screen of where to write the message
//  pMessage: The message to write on the screen
//  pPauseMS: The amount of time (in milliseconds) to pause before erasing the message
//  pFieldWidth: The width of the line on the screen where the text is to be drawn.
//               This is used for clearing the field.  If 0 or null, this will not be used.
//  pClearFieldFirst: Boolean - Whether or not to clear the field first
//  pClearAttr: If not null, this specifies the attribute to use to clear the field.
//              If this is left off (or is null), this function will use Synchronet's
//              normal attribute.
function flashMessage(pX, pY, pMessage, pPauseMS, pFieldWidth, pClearFieldFirst, pClearAttr)
{
	console.gotoxy(pX, pY);
	if (pClearFieldFirst && (pFieldWidth != null) && (pFieldWidth > 0))
	{
		// Clear the box
		if ((typeof(pClearAttr) != "undefined") && (pClearAttr != null))
			console.print(pClearAttr);
		else
			console.print("\x01n");

		for (var x = 0; x < pFieldWidth; ++x)
			console.print(" ");

		console.gotoxy(pX, pY);
	}
	console.print(pMessage);
	// Pause, and then clear the box
	if ((pPauseMS != null) && (pPauseMS > 0))
		mswait(pPauseMS);
	console.gotoxy(pX, pY);
	console.print("\x01n");
	var messageLen = strip_ctrl(pMessage).length;
	for (var x = 0; x < messageLen; ++x)
		console.print(" ");
}

// This function takes a string and returns a copy of the string
// with randomly-alternating dim & bright versions of a color.
//
// Parameters:
//  pString: The string to convert
//  pColor: The name of the color to use
function randomDimBrightString(pString, pColor)
{
	// Return if an invalid string is passed in.
	if (typeof(pString) == "undefined")
		return "";
	if (pString == null)
		return "";

	// Set the color.  Default to green.
	var color = "\x01g";
	if ((typeof(pColor) != "undefined") && (pColor != null))
      color = pColor;

	// Create a copy of the string without any control characters,
	// and then add our coloring to it.
	pString = strip_ctrl(pString);
	var returnString = "\x01n" + color;
	var bright = false;     // Whether or not to use the bright version of the color
	var oldBright = bright; // The value of bright from the last pass
	for (var i = 0; i < pString.length; ++i)
	{
		// Determine if this character should be bright
		bright = (Math.floor(Math.random()*2) == 1);
		if (bright != oldBright)
		{
			if (bright)
				returnString += "\x01h";
			else
				returnString += "\x01n" + color;
		}

		// Append the character from pString.
		returnString += pString.charAt(i);

		oldBright = bright;
	}

	return returnString;
}

// Removes multiple, leading, and/or trailing spaces
// The search & replace regular expressions used in this
// function came from the following URL:
//  http://qodo.co.uk/blog/javascript-trim-leading-and-trailing-spaces
//
// Parameters:
//  pString: The string to trim
//  pLeading: Whether or not to trim leading spaces (optional, defaults to true)
//  pMultiple: Whether or not to trim multiple spaces (optional, defaults to true)
//  pTrailing: Whether or not to trim trailing spaces (optional, defaults to true)
function trimSpaces(pString, pLeading, pMultiple, pTrailing)
{
	var leading = true;
	var multiple = true;
	var trailing = true;
	if (typeof(pLeading) != "undefined")
		leading = pLeading;
	if (typeof(pMultiple) != "undefined")
		multiple = pMultiple;
	if (typeof(pTrailing) != "undefined")
		trailing = pTrailing;
		
	// To remove both leading & trailing spaces:
	//pString = pString.replace(/(^\s*)|(\s*$)/gi,"");

	if (leading)
		pString = pString.replace(/(^\s*)/gi,"");
	if (multiple)
		pString = pString.replace(/[ ]{2,}/gi," ");
	if (trailing)
		pString = pString.replace(/(\s*$)/gi,"");

	return pString;
}

//////////////////////////////////////////////////////////////////////////////////////////
// Language object functions

// Constructor for the MatrixLangStrings object, which contains strings
// for the matrix-style login.  Defaults to English strings.
function MatrixLangStrings()
{
   this.LOGIN = "&Log in";
   this.NEWUSER = "&New user";
   this.GUEST = "&Guest account";
   this.RETRIEVE_PASSWORD = "&Retrieve password";
   this.EMAIL_SYSOP = "&Email the sysop";
   this.PAGE_SYSOP = "&Page the sysop";
   this.DISCONNECT = "&Disconnect";
   this.USERNAME_NUM_PASS_PROMPT = "Enter your username/# and password";
   this.USERNAME_PASS_PROMPT = "Enter your username and password";
   this.USERNAME_OR_NUM_PROMPT = "Name / #:";
   this.USERNAME_PROMPT = "Username:";
   this.PASSWORD_PROMPT = "Password:";
   this.UNKNOWN_USERNAME_OR_NUM = "Unknown username/number";
   this.UNKNOWN_USERNAME = "Unknown username";
   this.LOGIN_ATTEMPTS_FAIL_MSG = "\x01w\x01hUnable to log in after \x01y#\x01wattempts.";
   this.GUEST_ACCT_FAIL = "\x01n\x01h\x01yError: \x01wUnable to log into the guest account.";
   this.SYSOP_HAS_BEEN_PAGED = "\x01h\x01cThe sysop has been paged.";
   this.UNABLE_TO_PAGE_SYSOP = "\x01h\x01yUnable to page the sysop at this time.";
   this.LOGOFF_CONFIRM_TEXT = "Logoff";
   this.DISCONNECT_MESSAGE = "\x01n\x01h\x01wHave a nice day!";
   this.INPUT_TIMEOUT_REACHED = "Input timeout reached.";
   this.INVALID_LOGIN = "Invalid login";
   this.FAILED_LOGIN_BLOCKED_USERNAME = "!Failed login with blocked user name: ";
}

// Constructor for the StdLangStrings object, which contains strings for
// the standard-style login.  Defaults to English strings.
function StdLangStrings()
{
   this.USERNAME_PROMPT = "\r\n\x01n\x01h\x01gEnter \x01yUser Name";
   this.OR_NUMER_PROMPT = "\x01g or \x01yNumber\x01g.";
   this.NEW_USER_INFO = "\r\n\x01gIf you are a new user, enter \x01w'\x01yNew\x01w'\x01g.";
   this.GUEST_INFO = "\r\n\x01gFor the guest account, enter \x01w'\x01yGuest\x01w'\x01g.";
   this.LOGIN_PROMPT = "\r\nNN:\b\b\bLogin: \x01w";
   this.PASSWORD_PROMPT = "\x01n\x01c\x01hPW:\b\b\bPassword: \x01w";
   this.USERNAME_OR_EMAIL_ADDR_PROMPT = "\x01n\x01h\x01gEnter your \x01yUser Name \x01gor \x01yEmail Address";
}

// Constructor for the GeneralLangStrings object, which contains strings
// for other things.  Defaults to English strings.
function GeneralLangStrings()
{
   this.EMAIL_ADDR_CONFIRM_PROMPT = "\x01n\x01c\x01hPlease confirm your Internet e-mail address: \x01y";
   this.DID_YOU_FORGET_PASSWORD_CONFIRM = "Did you forget your password";
   this.ACCT_INFO_REQUESTED_ON_TIME = "Your user account information was requested on";
   this.BY = "by";
   this.VIA = "via";
   this.PORT = "port";
   this.NEW = "new";
   this.INFO_ACCT_NUM = "Account Number:";
   this.INFO_CREATED = "Created:";
   this.INFO_LAST_ON = "Last on:";
   this.INFO_CONNECT = "Connect:";
   this.INFO_PASSWORD = "Password:";
   this.INFO_INCORRECT_EMAIL_ADDR = "Incorrect e-mail address:";
   this.ACCT_INFO_EMAILED_TO = "\x01n\x01h\x01yAccount information e-mailed to: \x01w";
   this.ERROR_SAVING_BULKMAIL_MESSAGE = "Error saving bulkmail message:";
   this.UNKNOWN_USERNAME = "Unknown username";
   this.UNABLE_TO_RETRIEVE_ACCT_INFO = "\x01n\x01h\x01yUnable to send you your password (no email address/invalid user/sysop).";
}

////////////////////////////////////////////////
// Misc. functions

// Inputs a string from the user. Optionally hides their input with a mask character. Uses an input
// timeout.
//
// Parameters:
//  pMaxNumChars: The maximum number of characters to display; this is independent of text input
//                length.  Defaults to 0 (no maximum).
//  pMode: The mode bits to use.  See K_* in sbbsdefs.js.
//  pMaskChar: A mask character to use when printing the inputted characters.  Can be null for no mask.
//  pTimeoutMS: The input timeout (in milliseconds)
//
// Return value: The user's inputted string. This may be blank on timeout, or null, depending
//               on whether K_NUL was specified in the mode bits
function getStrWithTimeout(pMaxNumChars, pMode, pMaskChar, pTimeoutMS)
{
	var maxNumChars = 0;
	if ((typeof(pMaxNumChars) === "number") && (pMaxNumChars > 0))
		maxNumChars = pMaxNumChars;
	var modeBits = typeof(pMode) === "number" ? pMode : K_NONE;
	var timeoutMS = typeof(pTimeoutMS) === "number" && pTimeoutMS > 0 ? pTimeoutMS : 30000;

	var userInput = "";
	var continueOn = true;
	var numCharsPrinted = 0; // For backspace tracking (userInput could still be longer than the # printed)
	while (continueOn)
	{
		//var userChar = console.getkey(K_NOECHO|modeBits);
		var userChar = console.inkey(K_NOECHO|modeBits, timeoutMS);
		if (isPrintableChar(userChar))
		{
			if (Boolean(modeBits & K_UPRLWR))
			{
				if (userInput.length == 0 || userInput.charAt(userInput.length-1) == " ")
					userChar = userChar.toUpperCase();
			}
			userInput += userChar;
			if (!Boolean(modeBits & K_NOECHO) && numCharsPrinted < maxNumChars)
			{
				if (typeof(pMaskChar) === "string" && pMaskChar.length == 1)
					console.print(pMaskChar);
				else
					console.print(userChar);
				++numCharsPrinted;
			}
			// If limiting the length of the text inputted by the user:
			/*
			if (maxNumChars == 0 || userInput.length < maxNumChars)
			{
				if (Boolean(modeBits & K_UPRLWR))
				{
					if (userInput.length == 0 || userInput.charAt(userInput.length-1) == " ")
						userChar = userChar.toUpperCase();
				}
				userInput += userChar;
				if (!Boolean(modeBits & K_NOECHO))
				{
					if (typeof(pMaskChar) === "string" && pMaskChar.length == 1)
						console.print(pMaskChar);
					else
						console.print(userChar);
					++numCharsPrinted;
				}
			}
			*/
		}
		else if (userChar == BACKSPACE || userChar == KEY_DEL)
		{
			if (userInput.length > 0)
			{
				if (!Boolean(modeBits & K_NOECHO) && numCharsPrinted > 0)
				{
					console.print(BACKSPACE);
					console.print(" ");
					console.print(BACKSPACE);
					--numCharsPrinted;
				}
				userInput = userInput.substr(0, userInput.length-1);
			}
		}
		else if (userChar == KEY_ENTER)
		{
			continueOn = false;
			if (!Boolean(pMode & K_NOCRLF))
				console.crlf();
		}
		else if (userChar == null || userChar.length == 0)
			continueOn = false;
	}
	return userInput;
}

// Returns whether or not a character is printable.
//
// Parameters:
//  pChar: A character to test
//
// Return value: Boolean - Whether or not the character is printable
function isPrintableChar(pChar)
{
	// Make sure pChar is valid and is a string.
	if (typeof(pChar) != "string")
		return false;
	if (pChar.length == 0)
		return false;

	// Make sure the character is a printable ASCII character in the range of 32 to 254,
	// except for 127 (delete).
	var charCode = pChar.charCodeAt(0);
	return ((charCode > 31) && (charCode < 255) && (charCode != 127));
}

// Returns a substring of a string, accounting for Synchronet attribute
// codes (not including the attribute codes in the start index or length)
// This (and printedToRealIdxInStr()) is implemented in dd_lightbar_menu.js,
// but this login matrix script doesn't (yet) use that.
//
// Parameters:
//  pStr: The string to perform the substring on
//  pLen: The length of the substring
//
// Return value: A substring of the string according to the parameters
function substrWithAttrCodes(pStr, pStartIdx, pLen)
{
	if (typeof(pStr) != "string")
		return "";
	if (typeof(pStartIdx) != "number")
		return "";
	if (typeof(pLen) != "number")
		return "";
	if ((pStartIdx <= 0) && (pLen >= console.strlen(pStr)))
		return pStr;

	// Find the real start index.  If there are Synchronet attribute 
	var startIdx = printedToRealIdxInStr(pStr, pStartIdx);
	if (startIdx < 0)
		return "";
	// Find the actual length of the string to get
	var len = pLen;
	var printableCharCount = 0;
	var syncAttrCount = 0;
	var syncAttrRegexWholeWord = /^\x01[krgybmcw01234567hinpq,;\.dtl<>\[\]asz]$/i;
	var i = startIdx;
	while ((printableCharCount < pLen) && (i < pStr.length))
	{
		if (syncAttrRegexWholeWord.test(pStr.substr(i, 2)))
		{
			++syncAttrCount;
			i += 2;
		}
		else
		{
			++printableCharCount;
			++i;
		}
	}
	len += (syncAttrCount * 2);
	var shortenedStr = pStr.substr(startIdx, len);
	// Include any attribute codes that might appear before the start index
	// in the string
	var attrIdx = pStr.lastIndexOf("\x01", startIdx);
	if (attrIdx >= 0)
	{
		var attrStartIdx = -1;
		// Generate a string of all Synchronet attributes at the found location
		for (var i = attrIdx; i >= 0; i -= 2)
		{
			if (syncAttrRegexWholeWord.test(pStr.substr(i, 2)))
				attrStartIdx = i;
			else
				break;
		}
		if (attrStartIdx > -1)
		{
			var attrStr = pStr.substring(attrStartIdx, attrIdx+2);
			shortenedStr = attrStr + shortenedStr;
		}
	}
	return shortenedStr;
}

// Converts a 'printed' index in a string to its real index in the string
//
// Parameters:
//  pStr: The string to search in
//  pIdx: The printed index in the string
//
// Return value: The actual index in the string object, or -1 on error
function printedToRealIdxInStr(pStr, pIdx)
{
	if (typeof(pStr) != "string")
		return -1;
	if ((pIdx < 0) || (pIdx >= pStr.length))
		return -1;

	// Store the character at the given index if the string didn't have attribute codes.
	// Also, to help ensure this returns the correct index, get a substring with several
	// characters starting at the given index to match a word within the string
	var strWithoutAttrCodes = strip_ctrl(pStr);
	var substr_len = 5;
	var substrWithoutAttrCodes = strWithoutAttrCodes.substr(pIdx, substr_len);
	var printableCharAtIdx = strWithoutAttrCodes.charAt(pIdx);
	// Iterate through pStr until we find that character and return that index.
	var realIdx = 0;
	for (var i = 0; i < pStr.length; ++i)
	{
		// tempStr is the string to compare with substrWithoutAttrCodes
		var tempStr = strip_ctrl(pStr.substr(i)).substr(0, substr_len);
		if ((pStr.charAt(i) == printableCharAtIdx) && (tempStr == substrWithoutAttrCodes))
		{
			realIdx = i;
			break;
		}
	}
	return realIdx;
}

// Given a string of attribute characters, this function inserts the control code
// in front of each attribute character and returns the new string.
//
// Parameters:
//  pAttrCodeCharStr: A string of attribute characters (i.e., "YH" for yellow high)
//
// Return value: A string with the control character inserted in front of the attribute characters
function attrCodeStr(pAttrCodeCharStr)
{
	if (typeof(pAttrCodeCharStr) !== "string")
		return "";

	var str = "";
	var attrCodeStr = strip_ctrl(pAttrCodeCharStr); // Just in case
	// See this page for Synchronet color attribute codes:
	// http://wiki.synchro.net/custom:ctrl-a_codes
	for (var i = 0; i < attrCodeStr.length; ++i)
	{
		var currentChar = attrCodeStr.charAt(i);
		if (/[krgybmcwKRGYBMCWHhIiEeFfNn01234567]/.test(currentChar))
			str += "\x01" + currentChar;
	}
	return str;
}

function createLightbarMatrixMenu(pThemeCfgObj)
{
	var menu = new DDLightbarMenu(pThemeCfgObj.MenuX, pThemeCfgObj.MenuY, 19, 3);

	menu.borderEnabled = pThemeCfgObj.DrawMenuBorder;
	menu.scrollbarEnabled = false;
	menu.inputTimeoutMS = 60000;

	menu.colors.borderColor = pThemeCfgObj.MenuColor_Border;
	menu.colors.itemColor = pThemeCfgObj.MenuColor_Unselected;
	menu.colors.selectedItemColor = pThemeCfgObj.MenuColor_Selected;
	menu.colors.itemTextCharHighlightColor = pThemeCfgObj.MenuColor_Hotkey;
	// Border characters
	var borderChars = getBoxBorderChars(pThemeCfgObj.InitialMenuBorder);
	menu.borderChars.upperLeft = borderChars.upperLeft;
	menu.borderChars.upperRight = borderChars.upperRight;
	menu.borderChars.lowerLeft = borderChars.lowerLeft;
	menu.borderChars.lowerRight = borderChars.lowerRight;
	menu.borderChars.top = borderChars.top;
	menu.borderChars.bottom = borderChars.bottom;
	menu.borderChars.left = borderChars.left;
	menu.borderChars.right = borderChars.right;

	return menu;
}

// Writes spaces around a recgangular area
function writeSpacesAroundRectangle(pStartX, pStartY, pWidth, pHeight)
{
	console.attributes = "N";
	// Top border
	if (pStartX > 1 && pStartY > 1)
	{
		console.gotoxy(pStartX-1, pStartY-1);
		printf("%*s", pWidth+2, "");
	}
	// Side chars
	var leftX = pStartX - 1;
	var rightX = pStartX + pWidth;
	var lastRow = pStartY + pHeight;
	for (var row = pStartY; row < lastRow; ++row)
	{
		if (leftX >= 1)
		{
			console.gotoxy(leftX, row);
			console.print(" ");
		}
		if (rightX <= console.screen_columns)
		{
			console.gotoxy(rightX, row);
			console.print(" ");
		}
	}
	// Bottom border
	if (pStartX > 1)
	{
		console.gotoxy(pStartX-1, lastRow);
		printf("%*s", pWidth+2, "");
	}
}

// Converts an image file to sixel format.  From sixelgallery by Codefenix.
function convertAndShowImage(imgPath, scale)
{
	log(LOG_INFO, "converting: '" + imgPath + "' to sixel");
	if (file_exists(imgPath))
	{
		//console.clear(false);
		print("Preparing \x01b\x01h" + file_getname(imgPath) + " \x01w\x01hplease wait.\x01n.\x01k\x01h..\x01n.\x01k\x01h.\x01w\x01h!");
		//if (!scale)
		//	print("\r\n\x01n\x01k(scaling is disabled, so this may take a while)");
		var tmpSixel = backslash(temp_sixel_path) + "temp" + bbs.node_num + "-%05d.sixel";
		var cmd = path_to_im_conv + " -coalesce " + imgPath + " " + (scale ? ("-resize " + scale_max_width + "x" + scale_max_height + " ") : "") + tmpSixel;
		var rslt = system.exec(cmd);
		//console.clear(false);
		var frames = directory(backslash(temp_sixel_path) + "temp" + bbs.node_num + "*.sixel");
		if (frames.length > 0)
		{
			for (var f = 0; f < frames.length; f++)
			{
				console.home();
				showSixel(frames[f]);
				file_remove(frames[f]);
			}
		}
		else
		{
			//print("failed.");
			log(LOG_WARNING, "No output found for '" + imgPath + "'.");
		}
		if (rslt !== 0)
			log(LOG_WARNING, "Convert for '" + imgPath + "' rslt: " + rslt);
	}
}

// Shows a sixel image to the user.  Checks whether the user's terminal is capable of
// displaying a sixel image, and if not, this function does nothing.
//
// Paramaters:
//  pFilename: The filename (on the BBS machine) of the sixel file to load and show
function showSixel(pFilename)
{
	if (!supports_sixel())
		return false;

	var showSuccess = false;
	var imgFile = new File(pFilename);
	if (imgFile.exists)
	{
		if (imgFile.open("rb", true))
		{
			var readlen = console.output_buffer_level + console.output_buffer_space;
			readlen /= 2;
			console.clear();
			while (!imgFile.eof)
			{
				var imagedata = imgFile.read(readlen);
				while (console.output_buffer_space < imagedata.length)
					mswait(1);
				console.write(imagedata);
			}
			imgFile.close();
			showSuccess = true;
		}
	}
	return showSuccess;
}

// Converts the theme images to sixels, if they aren't already
//
// Return value: An object with the following properties:
//               Success: Boolean - Whether or not this function succeeded
//               InitialBkgSixelFilename: The name of the sixel for initial connection
//                                        (might have the user's terminal size in the filename)
//               LoginBkgSixelFilename: The name of the sixel for login
//                                      (might have the user's terminal size in the filename)
function convertThemeImgsToSixel(pThemeCfgObj)
{
	console.print("\x01nOne moment please...");

	var retObj = {
		Success: false,
		InitialBkgSixelFilename: "",
		LoginBkgSixelFilename: ""
	};

	const initialBkgAlreadySixel = file_exists(pThemeCfgObj.InitialBackgroundFilename + ".sixel");
	const loginBkgAlreadySixel = file_exists(pThemeCfgObj.LoginBackgroundFilename + ".sixel");

	// If either of the sixels don't exist yet, then if the cache directory for the theme
	// doesn't exist, create it; do the same for system.temp_dir.
	if (!initialBkgAlreadySixel || !loginBkgAlreadySixel)
	{
		if (!file_isdir(pThemeCfgObj.SixelCacheDir))
		{
			if (!mkdir(pThemeCfgObj.SixelCacheDir))
			{
				log(LOG_ERR, "!DDLoginMatrix: Failed to create sixel cache directory: " + pThemeCfgObj.SixelCacheDir);
				return retObj;
			}
		}
		if (!file_isdir(system.temp_dir))
		{
			if (!mkdir(system.temp_dir))
			{
				log(LOG_ERR, "!DDLoginMatrix: Failed to create system temp directory: " + system.temp_dir);
				return retObj;
			}
		}
	}


	if (initialBkgAlreadySixel)
		retObj.InitialBkgSixelFilename = pThemeCfgObj.InitialBackgroundFilename + ".sixel";
	else
	{
		// See if the cached sixel file already exists. If not, then generate it.
		var justSixelFilename = format("%s_%dx%d.sixel",
		                               file_getname(pThemeCfgObj.InitialBackgroundFilename),
		                               console.screen_columns, console.screen_rows);
		var sixelFilename = pThemeCfgObj.SixelCacheDir + justSixelFilename;
		if (file_exists(sixelFilename))
			retObj.InitialBkgSixelFilename = sixelFilename;
		else
		{
			for (var i = 0; i < gImgFilenameExts.length; ++i)
			{
				var srcFilename = pThemeCfgObj.InitialBackgroundFilename + "." + gImgFilenameExts[i];
				if (file_exists(srcFilename))
				{
					var sixelFilenameInTmpDir = system.temp_dir + justSixelFilename;
					if (convertImgToSixel(srcFilename, sixelFilenameInTmpDir, true, pThemeCfgObj.ScaleInitialBkgImgWithHeight))
					{
						if (file_rename(sixelFilenameInTmpDir, sixelFilename))
							retObj.InitialBkgSixelFilename = sixelFilename;
						else
						{
							log(LOG_ERR, "!DDLoginMatrix: Failed to move %s to %s: ", sixelFilenameInTmpDir, sixelFilename);
							return retObj;
						}
					}
					break;
				}
			}
		}
	}

	if (loginBkgAlreadySixel)
		retObj.LoginBkgSixelFilename = pThemeCfgObj.LoginBackgroundFilename + ".sixel";
	else
	{
		// See if the cached sixel file already exists. If not, then generate it.
		var justSixelFilename = format("%s_%dx%d.sixel",
		                               file_getname(pThemeCfgObj.LoginBackgroundFilename),
		                               console.screen_columns, console.screen_rows);
		var sixelFilename = pThemeCfgObj.SixelCacheDir + justSixelFilename;
		if (file_exists(sixelFilename))
			retObj.LoginBkgSixelFilename = sixelFilename;
		else
		{
			for (var i = 0; i < gImgFilenameExts.length; ++i)
			{
				var srcFilename = pThemeCfgObj.LoginBackgroundFilename + "." + gImgFilenameExts[i];
				if (file_exists(srcFilename))
				{
					var sixelFilenameInTmpDir = system.temp_dir + justSixelFilename;
					if (convertImgToSixel(srcFilename, sixelFilenameInTmpDir, true, pThemeCfgObj.ScaleLoginImgWithHeight))
					{
						if (file_rename(sixelFilenameInTmpDir, sixelFilename))
							retObj.LoginBkgSixelFilename = sixelFilename;
						else
						{
							log(LOG_ERR, "!DDLoginMatrix: Failed to move %s to %s: ", sixelFilenameInTmpDir, sixelFilename);
							return retObj;
						}
					}
					break;
				}
			}
		}
	}

	retObj.Success = true;
	return retObj;
}

// Converts an image to sixel format
//
// Parameters:
//  pSrcImgFilename: The source image filename
//  pDestImgFilename: The destination sixel filename
//  pScale: Whether or not to scale the image to the user's terminal width
//  pScaleWithHeight: Whether or not to take the user's terminal height into account when scaling the image
//
// Return value: Boolean - Whether or not the system command succeeded and the destination file exists
function convertImgToSixel(pSrcImgFilename, pDestImgFilename, pScale, pScaleWithHeight)
{
	var maxWidth = 640 * (console.screen_columns / 80);
	var maxHeight = 999999; // Large number; don't take terminal height into account
	if (pScaleWithHeight)
		maxHeight = 370 * (console.screen_rows / 24);
	var cmd = "";
	// By default, this assumes use of the 'convert' program from ImageMagick.
	// See if the conversion command uses magick. If so, don't use the
	// -coalesce option.
	if (gMainCfgObj.PathToImgToSixelConv.toUpperCase().indexOf("MAGICK") > -1)
		cmd = format("\"%s\" \"%s\" %s \"%s\"", gMainCfgObj.PathToImgToSixelConv, pSrcImgFilename, pScale ? ("-resize " + maxWidth + "x" + maxHeight + " ") : "", pDestImgFilename);
	else
		cmd = format("\"%s\" -coalesce \"%s\" %s \"%s\"", gMainCfgObj.PathToImgToSixelConv, pSrcImgFilename, pScale ? ("-resize " + maxWidth + "x" + maxHeight + " ") : "", pDestImgFilename);
	// If running on Windows, it seems we need an additional set of double-quotes around the whole command line
	if (/^WIN/.test(system.platform.toUpperCase()))
		cmd = format("\"%s\"", cmd);
	log(LOG_INFO, format("Sixel convert command: %s", cmd));
	var rslt = system.exec(cmd);
	var sixelFileExists = file_exists(pDestImgFilename);
	if (!sixelFileExists)
	{
		//log(LOG_ERR, "!DDLoginMatrix: Failed to run image to sixel conversion program (" + gMainCfgObj.PathToImgToSixelConv + "). Either the path is wrong or ImageMagick is not installed.");
		log(LOG_ERR, format("!DDLoginMatrix: Failed to run image to sixel conversion program (%s). Exit code: %d. Either the path is wrong or ImageMagick is not installed.",
		                    gMainCfgObj.PathToImgToSixelConv, rslt));
	}
	return (rslt == 0 && sixelFileExists);
}

// Returns whether a file exists as an .asc, .ans, or .msg file
//
// Parameters:
//  pBaseFilename: The base filename (without extension)
//
// Return value: Boolean - Whether or not the file exists as an .asc or .ans file
function traditionalScreenFileExists(pBaseFilename)
{
		var fileExists = false;
		if (typeof(bbs.menu_exists) === "function")
			fileExists = bbs.menu_exists(pBaseFilename);
		else 
		{    
			fileExists = file_exists(pBaseFilename + ".ans") ||
			             file_exists(pBaseFilename + ".asc") ||
			             file_exists(pBaseFilename + ".msg");
		}
		return fileExists;
}

function strToIntOrPercentOfValue(pIntStr, pValueForPercentOf, pInitialDefaultVal)
{
	var value = (typeof(pInitialDefaultVal) === "number" ? Math.floor(pInitialDefaultVal) : 0);
	if (pIntStr.length > 0)
	{
		// If the value ends with a %, then set the value as a percentage
		// of screen width. If the value starts with a ., parse it as a
		// floating-point value and multiply that with the value.
		// Otherwise, parse it as an integer.
		if (pIntStr[pIntStr.length-1] == "%")
		{
			// parseInt() could be used, but parseFloat() here allows a fractional percentage
			var valueFloat = parseFloat(pIntStr.substr(0, pIntStr.length-1));
			if (!isNaN(valueFloat))
				value = Math.floor(pValueForPercentOf * (valueFloat / 100.0));
		}
		else if (pIntStr[0] == "." || pIntStr.indexOf("0.") == 0)
		{
			var valueFloat = parseFloat(pIntStr);
			if (!isNaN(valueFloat))
				value = Math.floor(pValueForPercentOf * valueFloat);
		}
		else
		{
			// If the string ends in "%+<number>" or "%-<number>", then treat it
			// as that percentage + or - a number. For instance, 23%+3 or 45%-4
			var matches = pIntStr.match(/^([0-9]+[.]?[0-9]*)%([+-])([0-9]+)$/);
			if (Array.isArray(matches) && matches.length == 4)
			{
				var percentage = parseFloat(matches[1]);
				var adjustValueFloat = parseFloat(matches[3]);
				if (!isNaN(percentage) && !isNaN(adjustValueFloat))
				{
					var percentageOfValue = Math.floor(pValueForPercentOf * (percentage / 100.0));
					if (matches[2] == "+")
						value = percentageOfValue + adjustValueFloat;
					else // The sign must be -
						value = percentageOfValue - adjustValueFloat;
				}
			}
			else
			{
				var valueInt = parseInt(pIntStr);
				if (!isNaN(valueInt))
					value = valueInt;
			}
		}
	}
	return value;
}

// Prompts the user to enter their email address, which is to be used for email
// replies when emailing the sysop
//
// Return value: The email address entered by the user
function promptUserForTheirEmailAddress()
{
	console.print("\x01n\x01cPlease enter an \x01hemail address\x01n\x01c which can be used for replies\x01g\x01h:\x01n\r\n");
	var emailAddr = console.getstr(console.screen_columns-1, K_LINE|K_NOSPIN);
	console.attributes = "N";
	// TODO: Email address validation
	return emailAddr;
}

// Lets the user email the sysop by editing a file, then posting the
// message to the sysop in the email msgbase. This function prompts
// the user for their email address & subject.
//
// Return value: An object containing the following properties:
//               emailSysopSucceeded: Boolean - Whether or not emailing the sysop was successful
//               delTempMsgFileSucceeded: Boolean - Whether or not deleting the temporary message file succeeded
function letUserEmailSysopByEditingAFile()
{
	var retObj = {
		emailSysopSucceeded: false,
		delTempMsgFileSucceeded: true
	};

	// Make sure the temp dir exists
	var tempDirExists = file_isdir(system.temp_dir);
	if (!tempDirExists)
	{
		tempDirExists = mkdir(system.temp_dir);
		if (!tempDirExists)
		{
			log(LOG_ERR, "Failed to create temp dir: " + system.temp_dir);
			return retObj;
		}
	}

	// Look up the sysop's handle/name
	var sysopHandleOrName = "";
	var sysopUser = new User(1);
	if (sysopUser != null)
	{
		if (sysopUser.alias.handle > 0)
			sysopHandleOrName = sysopUser.handle;
		else if (sysopUser.alias.length > 0)
			sysopHandleOrName = sysopUser.alias;
		else if (sysopUser.alias.name > 0)
			sysopHandleOrName = sysopUser.name;
		else
			sysopHandleOrName = "Sysop";
	}
	else
		sysopHandleOrName = "Sysop";

	// Prompt the user for their name
	console.print("\x01n\x01cPlease enter your name (you can also leave this blank)\x01g\x01h:\x01n\r\n");
	var fromName = console.getstr(console.screen_columns-1, K_LINE|K_NOSPIN);
	console.attributes = "N";
	if (fromName.length == 0 || /^\s+$/.test(fromName))
		fromName = "DDLoginMatrix User";

	// Prompt the user for the message subject
	var fromEmailAddr = promptUserForTheirEmailAddress();
	
	// Allow the user to select a message editor, and then email the sysop
	bbs.select_editor();

	// Prompt the user for the message subject
	console.print("\x01n\x01cPlease enter the email subject\x01g\x01h:\x01n\r\n");
	var msgSubj = console.getstr(console.screen_columns-1, K_LINE|K_NOSPIN);
	if (msgSubj.length == 0)
		msgSubj = "No Subject";
	console.attributes = "N";

	// If we get here, then the temp directory exists.
	var msgFilename = system.temp_dir + "ddlm_sysop_msg.txt";
	// Let the user edit the temp file. The parameters after the filename for console.editfile()
	// were added in Synchronet 3.20.
	var successfullyEditedFile = console.editfile(msgFilename, "Sysop", fromName, msgSubj, "Electronic Mail", false);
	if (successfullyEditedFile)
	{
		var msgBody = "";
		var msgFile = new File(msgFilename);
		if (msgFile.open("r"))
		{
			var fileLines = msgFile.readAll(16384);
			msgFile.close();
			for (var i = 0; i < fileLines.length; ++i)
				msgBody += fileLines[i] + "\r\n";
			if (msgBody != "")
			{
				var msgHdr = {
					from: fromName,
					from_net_addr: fromEmailAddr,
					replyto_net_addr: fromEmailAddr,
					to: sysopHandleOrName,
					to_ext: 1, // User number 1, which is the sysop
					subject: msgSubj,
					from_net_type: NET_INTERNET,
					to_net_type: NET_NONE,
					attr: MSG_ANONYMOUS
				};
				if (typeof(user.editor) === "string" && user.editor.length > 0)
				{
					if (typeof(xtrn_area.editor[user.editor]) === "object")
						msgHdr.editor = xtrn_area.editor[user.editor].name;
				}
				var msgbase = new MsgBase("mail");
				if (msgbase.open())
				{
					var savedNewMsg = msgbase.save_msg(msgHdr, msgBody);
					var lastMsgbaseError = msgbase.error;
					msgbase.close();
					if (savedNewMsg)
					{
						retObj.emailSysopSucceeded = true;
						console.print("\x01n\x01hYour email was sent.\x01n\r\n");
					}
					else
					{
						log(LOG_ERR, "Failed to save the email to the sysop: " + lastMsgbaseError);
					}
				}
				else
					log(LOG_ERR, "Failed to open the 'mail' messagebase (to send an email to the sysop)" + msgFilename);
			}
			else
			{
				log(LOG_INFO, "Email sysop: No message entered; not emailing the sysop");
				console.print("\x01n\x01y\x01hNot posting empty message\x01n\r\n\x01p");
			}
		}
	}

	// Delete the temporary message file
	retObj.delTempMsgFileSucceeded = file_remove(msgFilename);
	if (!retObj.delTempMsgFileSucceeded)
		log(LOG_ERR, "Failed to delete temp email message file: " + msgFilename);

	return retObj;
}

// Returns an object containing box border characters, depending on a
// given border style
//
// Parameters:
//  pBorderStyle: A string describing the border style. This can be "single" or "double".
//
// Return value: An object containng the following properties:
//               upperLeft: The upper-left corner character
//               upperRight: The upper-right corner character
//               lowerLeft: The lower-left corner character
//               lowerRight: The lower-right corner character
//               top: The top (horizontal) border character
//               bottom: The bottom (horizontal) border character
//               left: The left (vertical) border character
//               right: The right(vertical) border character 
function getBoxBorderChars(pBorderStyle)
{
	// The default is single-line border chars ("single")
	var borderChars = {
		upperLeft: CP437_BOX_DRAWINGS_UPPER_LEFT_SINGLE,
		upperRight: CP437_BOX_DRAWINGS_UPPER_RIGHT_SINGLE,
		lowerLeft: CP437_BOX_DRAWINGS_LOWER_LEFT_SINGLE,
		lowerRight: CP437_BOX_DRAWINGS_LOWER_RIGHT_SINGLE,
		top: CP437_BOX_DRAWINGS_HORIZONTAL_SINGLE,
		bottom: CP437_BOX_DRAWINGS_HORIZONTAL_SINGLE,
		left: CP437_BOX_DRAWINGS_LIGHT_VERTICAL,
		right: CP437_BOX_DRAWINGS_LIGHT_VERTICAL
	};
	var borderStyleLower = pBorderStyle.toLowerCase();
	if (borderStyleLower == "double")
	{
		borderChars.upperLeft = CP437_BOX_DRAWINGS_UPPER_LEFT_DOUBLE;
		borderChars.upperRight = CP437_BOX_DRAWINGS_UPPER_RIGHT_DOUBLE;
		borderChars.lowerLeft = CP437_BOX_DRAWINGS_LOWER_LEFT_DOUBLE;
		borderChars.lowerRight = CP437_BOX_DRAWINGS_LOWER_RIGHT_DOUBLE;
		borderChars.top = CP437_BOX_DRAWINGS_HORIZONTAL_DOUBLE;
		borderChars.bottom = CP437_BOX_DRAWINGS_HORIZONTAL_DOUBLE;
		borderChars.left = CP437_BOX_DRAWINGS_DOUBLE_VERTICAL;
		borderChars.right = CP437_BOX_DRAWINGS_DOUBLE_VERTICAL;
	}
	return borderChars;
}

// Displays a file using bbs.menu() under certain conditions: If not using RIP
// and only a .rip file exists for the filename, then don't display it.
function displayFileConsideringRIPConditions(pFilenameBase, pUsingRIP)
{
	if (pUsingRIP)
	{
		// Just use bbs.menu to (try to) display it
		bbs.menu(pFilenameBase);
	}
	else
	{
		// Not using RIP.
		// If only a .rip exists, then temporarily rename it before displaying
		// the file so that bbs.menu() won't use the .rip file
		const ripFilename = pFilenameBase + ".rip";
		var traditionalScreenFileExists = (file_exists(pFilenameBase + ".asc") || file_exists(pFilenameBase + ".ans") || file_exists(pFilenameBase + ".msg"));
		var ripExists = file_exists(ripFilename);
		if (traditionalScreenFileExists)
		{
			var ripBackupFilename = null;
			var ripRenameSucceeded = true;
			if (ripExists)
			{
				ripBackupFilename = pFilenameBase + ".ripBackup";
				ripRenameSucceeded = file_rename(ripFilename, ripBackupFilename);
				if (!ripRenameSucceeded)
					log(LOG_ERR, format("Failed to rename %s to %s", ripFilename, ripBackupFilename));
			}
			if (ripRenameSucceeded)
				bbs.menu(pFilenameBase);
			if (ripExists && ripRenameSucceeded && ripBackupFilename != null)
			{
				if (!file_rename(ripBackupFilename, ripFilename))
					log(LOG_ERR, format("Failed to rename %s back to %s", ripBackupFilename, ripFilename));
			}
		}
	}
}

////////////////////////////////////////////////////////
// RIP functions

// Returns whether the user's terminal supports RIP _AND_ a .rip file
// exists with a given filename base (without filename extension)
function termSupportsRIPAndRIPFileExists(pFilenameBase)
{
	return (console.term_supports(USER_RIP) && file_exists(pFilenameBase + ".rip"));
}

// Displays the RIP menu for the connect screen
function displayConnectScreenRIPMenu(pMainCfg, pThemeCfg)
{
	var retObj = {
		successful: false,
		validUserInputKeys: ""
	}
	if (!console.term_supports(USER_RIP))
		return retObj;

	// Set any settings applicable from the config objects
	var verticalRIPMenu = true;
	var RIPMenuX = 0;
	var RIPMenuY = 0;
	var RIPFont = RIP_FONT_DEFAULT; // Default 8x8 font
	//RIPFont = RIP_FONT_SANS_SERIF;
	// Font size1 for the normal default size, 2 for x2 magnification, 3
	// for x3 magnification etc..
	var RIPFontSize = 1;
	var btnTextColor = RIP_COLOR_YELLOW;
	var btnBorderColor = RIP_COLOR_WHITE;
	var btnDarkVal = RIP_COLOR_DK_GRAY;
	var btnSurfaceColor = RIP_COLOR_LT_BLUE;
	var addNewUserBtn = !Boolean(system.settings & SYS_CLOSED);
	var addGuestBtn = true;
	var addRetrievePasswdBtn = true;
	var addEmailSysopBtn = true;
	var addPageSysopBtn = true;
	if (pMainCfg != undefined && pMainCfg != null && typeof(pMainCfg) === "object")
	{
		if (pMainCfg.hasOwnProperty("MenuDisplayGuestAccountIfExists") && typeof(pMainCfg.MenuDisplayGuestAccountIfExists) === "boolean")
			addNewUserBtn = pMainCfg.MenuDisplayGuestAccountIfExists && !Boolean(system.settings & SYS_CLOSED);
		if (pMainCfg.hasOwnProperty("MenuDisplayGuestAccountIfExists") && typeof(pMainCfg.MenuDisplayGuestAccountIfExists) === "boolean")
			addGuestBtn = (gGuestUserNum > 0 && pMainCfg.MenuDisplayGuestAccountIfExists);
		if (pMainCfg.hasOwnProperty("MenuDisplayRetrievePassword") && typeof(pMainCfg.MenuDisplayRetrievePassword) === "boolean")
			addRetrievePasswdBtn = pMainCfg.MenuDisplayRetrievePassword;
		if (pMainCfg.hasOwnProperty("MenuDisplayEmailSysop") && typeof(pMainCfg.MenuDisplayEmailSysop) === "boolean")
			addEmailSysopBtn = pMainCfg.MenuDisplayEmailSysop;
		if (pMainCfg.hasOwnProperty("MenuDisplayPageSysop") && typeof(pMainCfg.MenuDisplayPageSysop) === "boolean")
			addPageSysopBtn = pMainCfg.MenuDisplayPageSysop;
	}
	if (pThemeCfg != undefined && pThemeCfg != null && typeof(pThemeCfg) === "object")
	{
		if (pThemeCfg.RIP.hasOwnProperty("RIPMenuVertical"))
		{
			var optType = typeof(pThemeCfg.RIP.RIPMenuVertical);
			if (optType === "boolean")
				verticalRIPMenu = pThemeCfg.RIP.RIPMenuVertical;
			else if (optType === "string")
			{
				var optionValLower = pThemeCfg.RIP.RIPMenuVertical.toLowerCase();
				verticalRIPMenu = (optionValLower == "yes" || optionValLower == "true");
			}
		}
		if (pThemeCfg.RIP.hasOwnProperty("RIPMenuX") && typeof(pThemeCfg.RIP.RIPMenuX) === "number" && pThemeCfg.RIP.RIPMenuX >= 0)
			RIPMenuX = pThemeCfg.RIP.RIPMenuX;
		if (pThemeCfg.RIP.hasOwnProperty("RIPMenuY") && typeof(pThemeCfg.RIP.RIPMenuY) === "number" && pThemeCfg.RIP.RIPMenuY >= 0)
			RIPMenuY = pThemeCfg.RIP.RIPMenuY;
		if (pThemeCfg.RIP.hasOwnProperty("RIPFont"))
		{
			var valType = typeof(pThemeCfg.RIP.RIPFont);
			if (valType === "number")
			{
				if (pThemeCfg.RIP.RIPFont >= RIP_FONT_DEFAULT && pThemeCfg.RIP.RIPFont <= RIP_FONT_BOLD)
					RIPFont = pThemeCfg.RIP.RIPFont;
			}
			else if (valType === "string")
				RIPFont = RIPFontStrToNumericVal(pThemeCfg.RIP.RIPFont);
		}
		if (pThemeCfg.RIP.hasOwnProperty("RIPFontSize") && typeof(pThemeCfg.RIP.RIPFontSize) === "number" && pThemeCfg.RIP.RIPFontSize > 0)
			RIPFontSize = pThemeCfg.RIP.RIPFontSize;
		var colorSettingName = "RIPBtnTextColor";
		if (pThemeCfg.RIP.hasOwnProperty(colorSettingName))
			btnTextColor = RIPColorSetting(pThemeCfg.RIP, colorSettingName);
		colorSettingName = "RIPBtnBorderColor";
		if (pThemeCfg.RIP.hasOwnProperty(colorSettingName))
			btnBorderColor = RIPColorSetting(pThemeCfg.RIP, colorSettingName);
		colorSettingName = "RIPBtnDarkVal";
		if (pThemeCfg.RIP.hasOwnProperty(colorSettingName))
			btnDarkVal = RIPColorSetting(pThemeCfg.RIP, colorSettingName);
		colorSettingName = "RIPBtnSurfaceColor";
		if (pThemeCfg.RIP.hasOwnProperty(colorSettingName))
			btnSurfaceColor = RIPColorSetting(pThemeCfg.RIP, colorSettingName);
	}

	// Store the number of buttons we'll display (for size calculations etc.)
	var numButtons = 2;
	if (addNewUserBtn) ++numButtons;
	if (addGuestBtn) ++numButtons;
	if (addRetrievePasswdBtn) ++numButtons;
	if (addEmailSysopBtn) ++numButtons;
	if (addPageSysopBtn) ++numButtons;

	// Set RIP styles & construct the RIP UI elements
	var btnFlags = RIP_BTN_STYLE_SUNKEN|RIP_BTN_STYLE_MOUSE|RIP_BTN_STYLE_BEVEL|RIP_BTN_STYLE_PLAIN;
	btnFlags |= RIP_BTN_STYLE_DROPSHADOW_LBL|RIP_BTN_STYLE_RECESSED|RIP_BTN_STYLE_RESET_AFTER_CLICK|RIP_BTN_STYLE_INVERTABLE;
	var btnStyleStr = RIPButtonStyleNumeric(0, 0, 2, btnFlags, 1, btnTextColor, 0, btnBorderColor, btnDarkVal, btnSurfaceColor, 0, 0, 0, 0, 0);
	var fontStyleStr = RIPFontStyleNumeric(RIPFont, 0, RIPFontSize, 0);
	// horizBtnPositions will be an array of X pixel positions for the buttons
	// when placed horizontally
	var horizBtnXPositions = null;
	// For the box surrounding the buttons, they're 7 pixels in
	// from the left & right sides to make room for the bevels
	var btnHorizMargin = 19;
	var btnVertMargin = 14;
	var btnWidth = 34;
	var btnHeight = 10;
	const SCREEN_LEFT = 0;
	const SCREEN_RIGHT = 640;
	// Coordinates & dimensions for the box to surround the buttons
	var boxHeight = 0;
	var boxWidth = 0;
	var boxX1 = RIPMenuX;
	var boxY1 = RIPMenuY;
	var boxX2 = 0;
	var boxY2 = 0;
	// estMaxTextLenPixels is for extimated maximum text length in pixels
	var estMaxTextLenPixels = 145;
	if (verticalRIPMenu)
	{
		// Vertical menu
		//boxHeight = (numButtons*btnHeight) + (numButtons*btnVertMargin);
		boxHeight = (btnHeight + btnVertMargin) * numButtons;
		boxHeight += btnHeight + 5; // Fudge factor
		boxWidth = btnWidth + (btnHorizMargin*2) + estMaxTextLenPixels;
		boxX2 = boxX1 + boxWidth;
		boxY2 = boxY1 + boxHeight;
	}
	else
	{
		// Horizontal menu: Use the width of the entire screen/viewport, and
		// place the menu at the desired screen row (Y position).
		// First, calculate the total width needed for the buttons & their text
		var totalWidth = 0;

		boxHeight = btnHeight + (btnVertMargin*2);
		boxWidth = SCREEN_RIGHT - SCREEN_LEFT - 14; // 14 to account for bevels on the sides
		boxX2 = SCREEN_RIGHT - 7; // To account for the bevel on the left
		boxY2 = boxY1 + boxHeight;

		// Horizontal button X positions
		horizBtnXPositions = [boxX1 + btnHorizMargin];
		for (var i = 1; i <= 2; ++i)
			horizBtnXPositions.push(horizBtnXPositions[i-1] + btnWidth + btnHorizMargin + 150);
	}

	// Create the buttons
	retObj.validUserInputKeys += "L"; // Log in
	var btnX = boxX1 + btnHorizMargin;
	var btnY = boxY1 + btnVertMargin;
	var buttons = [ RIPButtonInfo(btnX, btnY,
	                              boxX1 + btnHorizMargin + btnWidth, // x1 + btnWidth
	                              boxY1 + btnVertMargin + btnHeight, // y1 + btnHeight
	                              "  L  ", "L", "Log in", true) ];
	var spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
	btnX = spacing.btnX;
	btnY = spacing.btnY;
	if (addNewUserBtn)
	{
		retObj.validUserInputKeys += "N";
		buttons.push(RIPButtonInfo(btnX, btnY,
		                           btnX + btnWidth, btnY + btnHeight,
		                           "  N  ", "N", "New user", true));
		spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
		btnX = spacing.btnX;
		btnY = spacing.btnY;
		if (!verticalRIPMenu && spacing.newRow)
			boxY2 += btnHeight + btnVertMargin;
	}
	if (addGuestBtn)
	{
		retObj.validUserInputKeys += "G";
		buttons.push(RIPButtonInfo(btnX, btnY,
		                           btnX + btnWidth, btnY + btnHeight,
		                           "  G  ", "G", "Guest", true));
		spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
		btnX = spacing.btnX;
		btnY = spacing.btnY;
		if (!verticalRIPMenu && spacing.newRow)
			boxY2 += btnHeight + btnVertMargin;
	}
	if (addRetrievePasswdBtn)
	{
		retObj.validUserInputKeys += "R";
		buttons.push(RIPButtonInfo(btnX, btnY,
		                           btnX + btnWidth, btnY + btnHeight,
		                           "  R  ", "R", "Retrieve password", true));
		spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
		btnX = spacing.btnX;
		btnY = spacing.btnY;
		if (!verticalRIPMenu && spacing.newRow)
			boxY2 += btnHeight + btnVertMargin;
	}
	if (addEmailSysopBtn)
	{
		retObj.validUserInputKeys += "E";
		buttons.push(RIPButtonInfo(btnX, btnY,
		                           btnX + btnWidth, btnY + btnHeight,
		                           "  E  ", "E", "Email the sysop", true));
		spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
		btnX = spacing.btnX;
		btnY = spacing.btnY;
		if (!verticalRIPMenu && spacing.newRow)
			boxY2 += btnHeight + btnVertMargin;
	}
	if (addPageSysopBtn)
	{
		retObj.validUserInputKeys += "P";
		buttons.push(RIPButtonInfo(btnX, btnY,
		                           btnX + btnWidth, btnY + btnHeight,
		                           "  P  ", "P", "Page the sysop", true));
		spacing = calcRIPBtnSpacing(buttons, boxX1, verticalRIPMenu, btnWidth, btnHeight, btnHorizMargin, btnVertMargin, btnX, btnY, RIPFont, estMaxTextLenPixels, horizBtnXPositions);
		btnX = spacing.btnX;
		btnY = spacing.btnY;
		if (!verticalRIPMenu && spacing.newRow)
			boxY2 += btnHeight + btnVertMargin;
	}
	retObj.validUserInputKeys += "D"; // Disconnect
	buttons.push(RIPButtonInfo(btnX, btnY,
	                           btnX + btnWidth, btnY + btnHeight,
	                           "  D  ", "D", "Disconnect", true));

	// Create the box to show around the buttons
	var textColorNum = RIP_COLOR_BLACK;
	var innerBorderColorNum = RIP_COLOR_WHITE;
	var outerEdgeColorNum = RIP_COLOR_DK_GRAY;
	var bkgColorNum = RIP_COLOR_LT_GRAY;
	btnFlags = RIP_BTN_STYLE_CHISEL_EFFECT|RIP_BTN_STYLE_DROPSHADOW_LBL|RIP_BTN_STYLE_BEVEL|RIP_BTN_STYLE_SUNKEN;
	var btnSurroundingBoxStr = RIPButtonStyleNumeric(boxX2-boxX1, boxY2-boxY1, 2, btnFlags, 5, textColorNum, 0, innerBorderColorNum,
	                                                 outerEdgeColorNum, bkgColorNum, 1, 0, 0, 0, 0);
	btnSurroundingBoxStr += RIPButtonNumeric(boxX1, boxY1, boxX2, boxY2);

	// We could set the RIP pallette (back) to default before
	// displaying the menu, but that actually might not be a
	// good idea because if there's .rip background displayed,
	// it could use its own pallette, and changing the pallette
	// here will change the background image on the screen.
	// The theme configuration file could potentially specify
	// different colors that could look better with the .rip screen.
	/*
	var RIPPalletteCmd = RIPSetPaletteNumeric(RIP_COLOR_BLACK, RIP_COLOR_BLUE, RIP_COLOR_GREEN, RIP_COLOR_CYAN, RIP_COLOR_RED,
	                                          RIP_COLOR_MAGENTA, RIP_COLOR_BROWN, RIP_COLOR_LT_GRAY, RIP_COLOR_DK_GRAY,
	                                          RIP_COLOR_LT_BLUE, RIP_COLOR_LT_GREEN, RIP_COLOR_LT_CYAN, RIP_COLOR_LT_RED,
	                                          RIP_COLOR_LT_MAGENTA, RIP_COLOR_YELLOW, RIP_COLOR_WHITE);
	*/

	// Display the RIP commands & UI elements
	console.crlf();
	console.print("!" + RIPKillMouseFields() + RIPWriteMode(0) + RIPColor("00") + "\r\n");
	console.print("!" + fontStyleStr + btnSurroundingBoxStr + "\r\n");
	console.print("!" + btnStyleStr);
	for (var i = 0; i < buttons.length; ++i)
	{
		var btnCmdStr = RIPButtonWithText(buttons[i].x1, buttons[i].y1, buttons[i].x2, buttons[i].y2, buttons[i].btnText,
		                                  buttons[i].btnCmdKey, buttons[i].nearBtnTxt, buttons[i].nearBtnTextBesideBtn);
		printf("\r\n!%s", btnCmdStr);
	}
	console.crlf();

	retObj.successful = true;
	return retObj;
}
// Helper for displayConnectScreenRIPMenu(): This function helps with calculating
// RIP button spacing when creating them for the RIP button menu
function calcRIPBtnSpacing(pBtnArr, pBoxX1, pMenuVert, pBtnWid, pBtnHt, pBtnHorzMrgn, pBtnVrtMargn, pBtnX, pBtnY, pRIPFont, pEstMaxPixels, pHorizButtonXPositions)
{
	var retObj = {
		btnX: pBtnX,
		btnY: pBtnY,
		newRow: false
	};
	if (pMenuVert) // Vertical menu
	{
		retObj.btnY += pBtnHt + pBtnVrtMargn;
		retObj.newRow = true;
	}
	else
	{
		var XPosFoundInArray = false;
		if (Array.isArray(pHorizButtonXPositions) && pHorizButtonXPositions.length > 0)
		{
			// If the current X position isn't found in the array, then increment
			// If the current X position is in the array and is below the last
			// position in the array, then go to the next X position.  Otherwise,
			// loop back to the first X position in the array on a new row.
			var currentXPosIdx = pHorizButtonXPositions.indexOf(retObj.btnX);
			XPosFoundInArray = (currentXPosIdx >= 0 && currentXPosIdx < pHorizButtonXPositions.length);
			if (XPosFoundInArray)
			{
				if (currentXPosIdx < pHorizButtonXPositions.length-1)
					retObj.btnX = pHorizButtonXPositions[currentXPosIdx+1];
				else
				{
					retObj.btnX = pHorizButtonXPositions[0];
					retObj.btnY += pBtnHt + pBtnVrtMargn;
					retObj.newRow = true;
				}
			}
		}
		// If the X position wasn't found in the array, then increment it to the
		// right via pixel position
		if (!XPosFoundInArray)
		{
			var XOffset = pEstMaxPixels;
			if (pRIPFont == RIP_FONT_DEFAULT)
				XOffset = pBtnArr[pBtnArr.length-1].nearBtnTxt.length * 8;
			retObj.btnX += pBtnWid + pBtnHorzMrgn + XOffset;
			if (retObj.btnX >= 640 - pBtnWid - pBtnHorzMrgn - XOffset)
			{
				retObj.btnX = pBoxX1 + pBtnHorzMrgn;
				retObj.btnY += pBtnHt + pBtnVrtMargn;
				retObj.newRow = true;
			}
		}
	}
	return retObj;
}
function RIPButtonInfo(pX1, pY1, pX2, pY2, pBtnText, pBtnCmdKey, pNearBtnText, pNearBtnTextBesideBtn)
{
	return {
		x1: pX1,
		y1: pY1,
		x2: pX2,
		y2: pY2,
		btnText: pBtnText,
		btnCmdKey: pBtnCmdKey,
		nearBtnTxt: pNearBtnText,
		nearBtnTextBesideBtn: pNearBtnTextBesideBtn
	};
}
// Returns a RIP command string for a RIP button with some text next to/underneath it
function RIPButtonWithText(pX0, pY0, pX1, pY1, pButtonText, pCommandStr, pNearBtnTxt, pNearBtnTextBesideBtn)
{
	// Button
	var cmdKey = 0;
	if (typeof(pCommandStr) === "string" && pCommandStr.length > 0)
		cmdKey = ascii(pCommandStr.substring(0, 1));
	var rip = RIPButtonNumeric(pX0, pY0, pX1, pY1, cmdKey, 0, 0, [pButtonText], pCommandStr);
	// Text beside/below the button
	var nearBtnTxtX = 0;
	var nearBtnTxtY = 0;
	if (pNearBtnTextBesideBtn)
	{
		nearBtnTxtX = pX1 + 12;
		nearBtnTxtY = pY0 + 1;
	}
	else
	{
		// TODO: Make the near-button text positioning better
		// for this scenario?
		nearBtnTxtX = pX0;
		if (pNearBtnTxt.length > 0)
		{
			var offset = Math.floor(pNearBtnTxt.length / 2) * 3;
			nearBtnTxtX -= offset;
		}
		nearBtnTxtY = pY1 + 7;
	}
	// RIPTextXYNumeric(pX, pY, pText)
	rip += RIPTextXYNumeric(nearBtnTxtX, nearBtnTxtY, pNearBtnTxt);
	return rip;
}
// Checks pCfg for a RIP color setting and returns its numeric value.
// The setting can be a string or number. This assumes pCfg is a
// valid object.
function RIPColorSetting(pCfg, pSettingName)
{
	var numericColorVal = RIP_COLOR_BLACK;
	if (pCfg.hasOwnProperty(pSettingName))
	{
		var typeStr = typeof(pCfg[pSettingName]);
		if (typeStr === "string")
			numericColorVal = RIPColorStrToNumericVal(pCfg[pSettingName]);
		else if (typeStr === "number" && pCfg[pSettingName] >= RIP_COLOR_BLACK && pCfg[pSettingName] <= RIP_COLOR_WHITE)
			numericColorVal = pCfg[pSettingName];
	}
	return numericColorVal;
}
// Converts a RIP color description (string) to its numeric value as defined in
// rip_lib.js
function RIPColorStrToNumericVal(pRIPColorStr)
{
	const strUpper = pRIPColorStr.toUpperCase();
	var RIPColorVal = RIP_COLOR_BLACK;
	if (strUpper == "RIP_COLOR_BLACK")
		RIPColorVal = RIP_COLOR_BLACK;
	else if (strUpper == "RIP_COLOR_BLUE")
		RIPColorVal = RIP_COLOR_BLUE;
	else if (strUpper == "RIP_COLOR_GREEN")
		RIPColorVal = RIP_COLOR_GREEN;
	else if (strUpper == "RIP_COLOR_CYAN")
		RIPColorVal = RIP_COLOR_CYAN;
	else if (strUpper == "RIP_COLOR_RED")
		RIPColorVal = RIP_COLOR_RED;
	else if (strUpper == "RIP_COLOR_MAGENTA")
		RIPColorVal = RIP_COLOR_MAGENTA;
	else if (strUpper == "RIP_COLOR_BROWN")
		RIPColorVal = RIP_COLOR_BROWN;
	else if (strUpper == "RIP_COLOR_LT_GRAY")
		RIPColorVal = RIP_COLOR_LT_GRAY;
	else if (strUpper == "RIP_COLOR_DK_GRAY")
		RIPColorVal = RIP_COLOR_DK_GRAY;
	else if (strUpper == "RIP_COLOR_LT_BLUE")
		RIPColorVal = RIP_COLOR_LT_BLUE;
	else if (strUpper == "RIP_COLOR_LT_GREEN")
		RIPColorVal = RIP_COLOR_LT_GREEN;
	else if (strUpper == "RIP_COLOR_LT_CYAN")
		RIPColorVal = RIP_COLOR_LT_CYAN;
	else if (strUpper == "RIP_COLOR_LT_RED")
		RIPColorVal = RIP_COLOR_LT_RED;
	else if (strUpper == "RIP_COLOR_LT_MAGENTA")
		RIPColorVal = RIP_COLOR_LT_MAGENTA;
	else if (strUpper == "RIP_COLOR_YELLOW")
		RIPColorVal = RIP_COLOR_YELLOW;
	else if (strUpper == "RIP_COLOR_WHITE")
		RIPColorVal = RIP_COLOR_WHITE;
	return RIPColorVal;
}
function RIPFontStrToNumericVal(pRIPFontStr)
{
	if (typeof(pRIPFontStr) !== "string" || pRIPFontStr.length == 0)
		return RIP_FONT_DEFAULT;

	var RIPFont = RIP_FONT_DEFAULT;
	var strUpper = pRIPFontStr.toUpperCase();
	if (strUpper == "RIP_FONT_DEFAULT")
		RIPFont = RIP_FONT_DEFAULT;
	else if (strUpper == "RIP_FONT_TRIPLEX")
		RIPFont = RIP_FONT_TRIPLEX;
	else if (strUpper == "RIP_FONT_SMALL")
		RIPFont = RIP_FONT_SMALL;
	else if (strUpper == "RIP_FONT_SANS_SERIF")
		RIPFont = RIP_FONT_SANS_SERIF;
	else if (strUpper == "RIP_FONT_GOTHIC")
		RIPFont = RIP_FONT_GOTHIC;
	else if (strUpper == "RIP_FONT_SCRIPT")
		RIPFont = RIP_FONT_SCRIPT;
	else if (strUpper == "RIP_FONT_SIMPLEX")
		RIPFont = RIP_FONT_SIMPLEX;
	else if (strUpper == "RIP_FONT_TRIPLEX")
		RIPFont = RIP_FONT_TRIPLEX;
	else if (strUpper == "RIP_FONT_COMPLEX")
		RIPFont = RIP_FONT_COMPLEX;
	else if (strUpper == "RIP_FONT_EUROPEAN")
		RIPFont = RIP_FONT_EUROPEAN;
	else if (strUpper == "RIP_FONT_BOLD")
		RIPFont = RIP_FONT_BOLD;
	return RIPFont;
}
