diff --git a/xtrn/ddfilelister/ddfl_cfg.js b/xtrn/ddfilelister/ddfl_cfg.js
new file mode 100644
index 0000000000000000000000000000000000000000..d6fe829ed06ce0f78c9755172576a6228a3b9dd7
--- /dev/null
+++ b/xtrn/ddfilelister/ddfl_cfg.js
@@ -0,0 +1,590 @@
+// This is a menu-driven configurator for Digital Distortion File Lister.  If you're running
+// DDFileLister xtrn/ddfilelister (the standard location), then any changes are saved to
+// ddfilelister.cfg in sbbs/mods, so that custom changes don't get overridden due to an update.
+// If you have ddfilelister in a directory other than xtrn/ddfilelister, then the changes to
+// ddfilelister.cfg will be saved in that directory (assuming you're running ddmr_cfg.js from
+// that same directory).
+// Currently for ddfilelister 2.28b.
+//
+// If you're running ddfilelister from xtrn/ddfilelister (the standard location) and you want
+// to save the configuration file there (rather than sbbs/mods), you can use one of the
+// following command-line options: noMods, -noMods, no_mods, or -no_mods
+
+"use strict";
+
+
+require("sbbsdefs.js", "P_NONE");
+require("uifcdefs.js", "UIFC_INMSG");
+
+
+if (!uifc.init("DigDist. File Lister 2.28b Configurator"))
+{
+	print("Failed to initialize uifc");
+	exit(1);
+}
+js.on_exit("uifc.bail()");
+
+// DDFileLister base configuration filename, and help text wrap width
+var gCfgFileName = "ddfilelister.cfg";
+var gHelpWrapWidth = uifc.screen_width - 10;
+
+// When saving the configuration file, always save it in the same directory
+// as DDFileLister (or this script); don't copy to mods
+var gAlwaysSaveCfgInOriginalDir = false;
+
+// Parse command-line arguments
+parseCmdLineArgs();
+
+// Read the DDFileLister configuration file
+var gCfgInfo = readDDFileListerCfgFile();
+
+// Show the main menu and go from there.
+// This is in a loop so that if the user aborts from confirming to save
+// settings, they'll return to the main menu.
+var anyOptionChanged = false;
+var continueOn = true;
+while (continueOn)
+{
+	anyOptionChanged = doMainMenu() || anyOptionChanged;
+	// If any option changed, then let the user save the configuration if they want to
+	if (anyOptionChanged)
+	{
+		var userChoice = promptYesNo("Save configuration?", true, WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC);
+		if (typeof(userChoice) === "boolean")
+		{
+			if (userChoice)
+			{
+				var saveRetObj = saveDDFileListerCfgFile();
+				// Show an error if failed to save the settings.  If succeeded, only show
+				// a message if settings were saved to the mods directory.
+				if (!saveRetObj.saveSucceeded)
+					uifc.msg("Failed to save settings!");
+				else
+				{
+					if (saveRetObj.savedToModsDir)
+						uifc.msg("Changes were successfully saved (to the mods dir)");
+				}
+			}
+			continueOn = false;
+		}
+	}
+	else
+		continueOn = false;
+}
+
+
+
+///////////////////////////////////////////////////////////
+// Functions
+
+function doMainMenu()
+{
+	// For menu item text formatting
+	var itemTextMaxLen = 50;
+
+	// Configuration option object properties
+	// cfgOptProps must correspond exactly with optionStrs & menuItems
+	var cfgOptProps = [
+		"interfaceStyle", // String (lightbar/traditional)
+		"traditionalUseSyncStock", // Boolean
+		"sortOrder", // String (PER_DIR_CFG/NATURAL/NAME_AI/NAME_DI/NAME_AS/NAME_DS/DATE_A/DATE_D/ULTIME/DLTIME)
+		"displayUserAvatars", // Boolean
+		"displayNumFilesInHeader", // Boolean
+		"useFilenameIfShortDescriptionEmpty", // Boolean
+		"filenameInExtendedDescription", // String (always/ifDescEmpty/never)
+		"pauseAfterViewingFile", // Boolean
+		"blankNFilesListedStrIfLoadableModule", // Boolean
+		"themeFilename" // String
+	];
+	// Strings for the options to display on the menu
+	var optionStrs = [
+		"User Interface Style",
+		"Traditional interface: Use stock implementation",
+		"Sort order of file list",
+		"Display user avatars",
+		"Display number of files in header",
+		"Short descs: Use filename if empty",
+		"Use filename in extended description",
+		"Pause after viewing a file",
+		"Blank \"# Files Listed\" as loadable module",
+		"Theme Filename"
+	];
+	// Build an array of formatted strings to be displayed on the menu
+	// (the value formatting will depend on the variable type)
+	var menuItems = [];
+	for (var i = 0; i < cfgOptProps.length; ++i)
+	{
+		var propName = cfgOptProps[i];
+		menuItems.push(formatCfgMenuText(itemTextMaxLen, optionStrs[i], gCfgInfo.cfgOptions[propName]));
+	}
+
+	// Help text
+	// A dictionary of help text for each option, indexed by the option name from the configuration file
+	if (doMainMenu.optHelp == undefined)
+		doMainMenu.optHelp = getOptionHelpText();
+	if (doMainMenu.mainScreenHelp == undefined)
+		doMainMenu.mainScreenHelp = getMainHelp(doMainMenu.optHelp, cfgOptProps);
+
+	// Create a CTX to specify the current selected item index
+	if (doMainMenu.ctx == undefined)
+		doMainMenu.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var menuTitle = "DD File Lister Configuration";
+	var anyOptionChanged = false;
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		uifc.help_text = doMainMenu.mainScreenHelp;
+		var optionMenuSelection = uifc.list(winMode, menuTitle, menuItems, doMainMenu.ctx);
+		doMainMenu.ctx.cur = optionMenuSelection; // Remember the current selected item
+		if (optionMenuSelection == -1) // ESC
+			continueOn = false;
+		else
+		{
+			var optName = cfgOptProps[optionMenuSelection];
+			var itemType = typeof(gCfgInfo.cfgOptions[optName]);
+			uifc.help_text = doMainMenu.optHelp[optName];
+			if (optName == "sortOrder")
+			{
+				// Sort order
+				var optionsForCfgFile = ["PER_DIR_CFG", "NATURAL", "NAME_AI", "NAME_DI", "NAME_AS", "NAME_DS", "DATE_A", "DATE_D", "ULTIME", "DLTIME"];
+				var optionsForDisplay = ["Per dir config (PER_DIR_CFG)",
+				                         "Natural sort order (import date/time ascending) (NATURAL)",
+										 "Filename ascending, case insensitive (NAME_AI)",
+										 "Filename descending, case insensitive (NAME_DI)",
+										 "Filename ascending, case sensitive (NAME_AS)",
+										 "Filename descending, case sensitive (NAME_DS)",
+										 "Import date/time ascending sort order (DATE_A)",
+										 "Import date/time descending (DATE_D)",
+										 "Upload time (ULTIME)",
+										 "Download time (DLTIME)"];
+				var promptStr = optionStrs[optionMenuSelection];
+				// Return value: An object with the following properties:
+				//               chosenIdx: The index of the user's chosen value, or -1 if the user aborted
+				//               chosenValue: The user's chosen value, or null if the user aborted
+				var choiceRetObj = promptMultipleChoice(promptStr, optionsForDisplay, gCfgInfo.cfgOptions[optName]);
+				if (choiceRetObj.chosenIdx >= 0 && choiceRetObj.chosenValue != undefined && optionsForCfgFile[choiceRetObj.chosenIdx] != gCfgInfo.cfgOptions[optName])
+				{
+					gCfgInfo.cfgOptions[optName] = optionsForCfgFile[choiceRetObj.chosenIdx];
+					anyOptionChanged = true;
+					menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+				}
+			}
+			else if (optName == "filenameInExtendedDescription")
+			{
+				// Use of the filename in extended descriptions
+				// always, ifDescEmpty, never
+				var optionsForCfgFile = ["always", "ifDescEmpty", "never"];
+				var optionsForDisplay = ["Always",
+				                         "If description is empty",
+										 "Never"];
+				var promptStr = optionStrs[optionMenuSelection];
+				// Return value: An object with the following properties:
+				//               chosenIdx: The index of the user's chosen value, or -1 if the user aborted
+				//               chosenValue: The user's chosen value, or null if the user aborted
+				var choiceRetObj = promptMultipleChoice(promptStr, optionsForDisplay, gCfgInfo.cfgOptions[optName]);
+				if (choiceRetObj.chosenIdx >= 0 && choiceRetObj.chosenValue != undefined && optionsForCfgFile[choiceRetObj.chosenIdx] != gCfgInfo.cfgOptions[optName])
+				{
+					gCfgInfo.cfgOptions[optName] = optionsForCfgFile[choiceRetObj.chosenIdx];
+					anyOptionChanged = true;
+					menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+				}
+			}
+			else if (itemType === "boolean")
+			{
+				gCfgInfo.cfgOptions[optName] = !gCfgInfo.cfgOptions[optName];
+				anyOptionChanged = true;
+				menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+			}
+			else if (itemType === "number")
+			{
+				var promptStr = optionStrs[optionMenuSelection];
+				var initialVal = gCfgInfo.cfgOptions[optName].toString();
+				var minVal = 1;
+				var userInput = uifc.input(WIN_MID, promptStr, initialVal, 0, K_NUMBER|K_EDIT);
+				if (typeof(userInput) === "string" && userInput.length > 0)
+				{
+					var value = parseInt(userInput);
+					if (!isNaN(value) && value >= minVal && gCfgInfo.cfgOptions[optName] != value)
+					{
+						gCfgInfo.cfgOptions[optName] = value;
+						anyOptionChanged = true;
+						menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+					}
+				}
+			}
+			else
+			{
+				if (optName == "themeFilename")
+				{
+					// Theme filename
+					var userInput = promptThemeFilename();
+					if (typeof(userInput) === "string" && userInput != gCfgInfo.cfgOptions[optName])
+					{
+						gCfgInfo.cfgOptions[optName] = userInput;
+						anyOptionChanged = true;
+						menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+					}
+				}
+				else
+				{
+					// Generic multiple-choice
+					var options = [];
+					if (optName == "interfaceStyle")
+						options = ["Lightbar", "Traditional"];
+					var promptStr = optionStrs[optionMenuSelection];
+					var choiceRetObj = promptMultipleChoice(promptStr, options, gCfgInfo.cfgOptions[optName]);
+					// Return value: An object with the following properties:
+					//               chosenIdx: The index of the user's chosen value, or -1 if the user aborted
+					//               chosenValue: The user's chosen value, or null if the user aborted
+					if (choiceRetObj.chosenValue != null && choiceRetObj.chosenValue != undefined && choiceRetObj.chosenValue != gCfgInfo.cfgOptions[optName])
+					{
+						gCfgInfo.cfgOptions[optName] = choiceRetObj.chosenValue;
+						anyOptionChanged = true;
+						menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+					}
+				}
+			}
+		}
+	}
+
+	return anyOptionChanged;
+}
+
+// Formats text for a behavior option
+//
+// Parameters:
+//  pItemTextMaxLen: The maximum length for menu item text
+//  pItemText: The text of the item to be displayed on the menu
+//  pVal: The value of the option
+//
+// Return value: The formatted text for the menu item, with a Yes/No value indicating whether it's enabled
+function formatCfgMenuText(pItemTextMaxLen, pItemText, pVal)
+{
+	if (formatCfgMenuText.formatStr == undefined)
+		formatCfgMenuText.formatStr = "%-" + pItemTextMaxLen + "s %s";
+	// Determine what should be displayed for the value
+	var valType = typeof(pVal);
+	var value = "";
+	if (valType === "boolean")
+		value = pVal ? "Yes" : "No";
+	else
+	{
+		if (typeof(pVal) !== "undefined")
+			value = pVal.toString();
+	}
+	return format(formatCfgMenuText.formatStr, pItemText.substr(0, pItemTextMaxLen), value);
+}
+
+// Prompts the user for which dictionaries to use (for spell check)
+function promptThemeFilename()
+{
+	// Find theme filenames. There should be a DefaultTheme.cfg; also, look
+	// for theme .cfg filenames starting with DDMR_Theme_
+	var defaultThemeFilename = js.exec_dir + "DefaultTheme.cfg";
+	var themeFilenames = [];
+	if (file_exists(defaultThemeFilename))
+		themeFilenames.push(defaultThemeFilename);
+	themeFilenames = themeFilenames.concat(directory(js.exec_dir + "DDMR_Theme_*.cfg"));
+
+	// Abbreviated theme file names: Get just the filename without the full path,
+	// and remove the trailing .cfg
+	var abbreviatedThemeFilenames = [];
+	for (var i = 0; i < themeFilenames.length; ++i)
+	{
+		var themeFilename = file_getname(themeFilenames[i]).replace(/\.cfg$/, "");
+		abbreviatedThemeFilenames.push(themeFilename);
+	}
+	// Add an option at the end to let the user type it themselves
+	abbreviatedThemeFilenames.push("Type your own filename");
+
+	// Create a context, and look for the current theme filename & set the
+	// current index
+	var ctx = uifc.list.CTX();
+	if (gCfgInfo.cfgOptions.themeFilename.length > 0)
+	{
+		var themeFilenameUpper = gCfgInfo.cfgOptions.themeFilename.toUpperCase();
+		for (var i = 0; i < themeFilenames.length; ++i)
+		{
+			if (themeFilenames[i].toUpperCase() == themeFilenameUpper)
+			{
+				ctx.cur = i;
+				break;
+			}
+		}
+	}
+	// User input
+	var chosenThemeFilename = null;
+	var selection = uifc.list(WIN_MID, "Theme Filename", abbreviatedThemeFilenames, ctx);
+	if (selection == -1)
+	{
+		// User quit/aborted; do nothing
+	}
+	// Last item: Let the user input the filename themselves
+	else if (selection == abbreviatedThemeFilenames.length-1) 
+	{
+		var userInput = uifc.input(WIN_MID, "Theme filename", "", 0, K_EDIT);
+		if (typeof(userInput) === "string")
+			chosenThemeFilename = userInput;
+	}
+	else
+		chosenThemeFilename = file_getname(themeFilenames[selection]);
+
+	return chosenThemeFilename;
+}
+
+// Prompts the user to select one of multiple values for an option
+//
+// Parameters:
+//  pPrompt: The prompt text
+//  pChoices: An array of the choices
+//  pCurrentVal: The current value (to set the index in the menu)
+//
+// Return value: An object with the following properties:
+//               chosenIdx: The index of the user's chosen value, or -1 if the user aborted
+//               chosenValue: The user's chosen value, or null if the user aborted
+function promptMultipleChoice(pPrompt, pChoices, pCurrentVal)
+{
+	var retObj = {
+		chosenIdx: -1,
+		chosenValue: null
+	};
+
+	//uifc.help_text = pHelpText;
+	// Create a context object with the current value index
+	var currentValUpper = pCurrentVal.toUpperCase();
+	var ctx = uifc.list.CTX();
+	for (var i = 0; i < pChoices.length; ++i)
+	{
+		if (pChoices[i].toUpperCase() == currentValUpper)
+		{
+			ctx.cur = i;
+			break;
+		}
+	}
+	// Prompt the user and return their chosen value
+	//var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var winMode = WIN_MID;
+	var userSelection = uifc.list(winMode, pPrompt, pChoices, ctx);
+	if (userSelection >= 0 && userSelection < pChoices.length)
+	{
+		retObj.chosenIdx = userSelection;
+		retObj.chosenValue = pChoices[userSelection];
+	}
+	return retObj;
+}
+
+// Prompts the user Yes/No for a boolean response
+//
+// Parameters:
+//  pQuestion: The question/prompt for the user
+//  pInitialVal: Boolean - Whether the initial selection in the menu should
+//               be Yes (true) or No (false)
+//  pWinMode: Optional window mode bits.  If not specified, WIN_MID will be used.
+//
+//  Return value: Boolean true (yes), false (no), or null if the user aborted
+function promptYesNo(pQuestion, pInitialVal, pWinMode)
+{
+	var chosenVal = null;
+	var winMode = typeof(pWinMode) === "number" ? pWinMode : WIN_MID;
+	// Create a CTX to specify the current selected item index
+	var ctx = uifc.list.CTX();
+	ctx.cur = typeof(pInitialVal) === "boolean" && pInitialVal ? 0 : 1;
+	switch (uifc.list(winMode, pQuestion, ["Yes", "No"], ctx))
+	{
+		case -1: // User quit/aborted - Leave chosenVal as null
+			break;
+		case 0: // User chose Yes
+			chosenVal = true;
+			break;
+		case 1: // User chose No
+			chosenVal = false;
+			break;
+		default:
+			break;
+	}
+	return chosenVal;
+}
+
+///////////////////////////////////////////////////
+// Help text functions
+
+
+// Returns a dictionary of help text, indexed by the option name from the configuration file
+function getOptionHelpText()
+{
+	var optionHelpText = {};
+	optionHelpText["interfaceStyle"] = "Interface Style: Either Lightbar or Traditional";
+
+	optionHelpText["traditionalUseSyncStock"] = "Traditional interface use stock implementation: When using the traditional ";
+	optionHelpText["traditionalUseSyncStock"] += "interface, use the Synchronet stock file lister (don't use ddfilelister at all). If this ";
+	optionHelpText["traditionalUseSyncStock"] += "is false, ddfilelister's traditional interface will be used if interfaceStyle ";
+	optionHelpText["traditionalUseSyncStock"] += "is traditional.";
+
+	optionHelpText["sortOrder"] = "Sort order of file list: The sort order of the file list. Valid options are PER_DIR_CFG ";
+	optionHelpText["sortOrder"] += "(according to the directory configuration in SCFG > File Areas > library > File Directories > ";
+	optionHelpText["sortOrder"] += "dir > Advanced Options > Sort Value and Direction); NATURAL (natural sort order - same as ";
+	optionHelpText["sortOrder"] += "DATE_A); NAME_AI (filename ascending, case insensitive); FILENAME_DI (filename descending, ";
+	optionHelpText["sortOrder"] += "case insensitive); NAME__AS (filename ascending, case sensitive); NAME_DS (filename descending, ";
+	optionHelpText["sortOrder"] += "case sensitive); DATE_A (import date/time ascending); DATE_D (import date/time descending); ULTIME ";
+	optionHelpText["sortOrder"] += "(upload time); (DLTIME (download time)";
+
+	optionHelpText["displayUserAvatars"] = "Display user avatars: Whether or not to display user avatars for the uploader in extended ";
+	optionHelpText["displayUserAvatars"] += "file descriptions";
+
+	optionHelpText["displayNumFilesInHeader"] = "Display number of files in header: Whether or not to display the number of files in ";
+	optionHelpText["displayNumFilesInHeader"] += "the header at the top of the screen";
+
+	optionHelpText["useFilenameIfShortDescriptionEmpty"] = "Short descs - Use filename if empty: For short descriptions, if the ";
+	optionHelpText["useFilenameIfShortDescriptionEmpty"] += "description is empty, whether to use the filename instead";
+
+	optionHelpText["filenameInExtendedDescription"] = "Use filename in extended description: How to use the filename in the extended ";
+	optionHelpText["filenameInExtendedDescription"] += "description. Valid options are always (always use the filename in the description); ";
+	optionHelpText["filenameInExtendedDescription"] += "ifDescEmpty (only if the description is empty - also if the filename is too short to ";
+	optionHelpText["filenameInExtendedDescription"] += "fully be shown in the menu, the full filename will appear in the description); ";
+	optionHelpText["filenameInExtendedDescription"] += "never (never use the filename in the description)";
+
+	optionHelpText["pauseAfterViewingFile"] = "Pause after viewing a file: Whether nor not to pause & wait for user input after viewing a file";
+
+	optionHelpText["blankNFilesListedStrIfLoadableModule"] = "Blank \"# Files Listed\" as loadable module: When used as a loadable module, ";
+	optionHelpText["blankNFilesListedStrIfLoadableModule"] += "whether or not to blank out the \"# Files Listed\" string (from text.dat) so ";
+	optionHelpText["blankNFilesListedStrIfLoadableModule"] += "that Synchronet won't display it after the lister exits";
+
+	optionHelpText["themeFilename"] = "Theme Filename: The name of the color theme file";
+
+	// Word-wrap the help text items
+	for (var prop in optionHelpText)
+		optionHelpText[prop] = word_wrap(optionHelpText[prop], gHelpWrapWidth);
+
+	return optionHelpText;
+}
+
+// Returns help text for the main configuration screen (behavior settings)
+//
+// Parameters:
+//  pOptionHelpText: An object of help text for each option, indexed by the option name from the configuration file
+//  pCfgOptProps: An array specifying the properties to include in the help text and their order
+//
+// Return value: Help text for the main configuration screen (behavior settings)
+function getMainHelp(pOptionHelpText, pCfgOptProps)
+{
+	var helpText = "This screen allows you to configure behavior options for Digital Distortion File Lister.\r\n\r\n";
+	for (var i = 0; i < pCfgOptProps.length; ++i)
+	{
+		var optName = pCfgOptProps[i];
+		//helpText += pOptionHelpText[optName] + "\r\n\r\n";
+		helpText += pOptionHelpText[optName] + "\r\n";
+	}
+	return word_wrap(helpText, gHelpWrapWidth);
+}
+
+
+///////////////////////////////////////////////////
+// Non-UI utility functions
+
+
+// Parses command-line arguments & sets any applicable values
+function parseCmdLineArgs()
+{
+	for (var i = 0; i < argv.length; ++i)
+	{
+		var argUpper = argv[i].toUpperCase();
+		if (argUpper == "NOMODS" || argUpper == "NO_MODS" || argUpper == "-NOMODS" || argUpper == "-NO_MODS")
+			gAlwaysSaveCfgInOriginalDir = true;
+	}
+}
+
+// Reads the DDFileLister configuration file
+//
+// Return value: An object with the following properties:
+//               cfgFilename: The full path & filename of the DDFileLister configuration file that was read
+//               cfgOptions: A dictionary of the configuration options and their values
+function readDDFileListerCfgFile()
+{
+	var retObj = {
+		cfgFilename: "",
+		cfgOptions: {}
+	};
+
+	// Determine the location of the configuration file.  First look for it
+	// in the sbbs/mods directory, then sbbs/ctrl, then in the same directory
+	// as this script.
+	var cfgFilename = file_cfgname(system.mods_dir, gCfgFileName);
+	if (!file_exists(cfgFilename))
+		cfgFilename = file_cfgname(system.ctrl_dir, gCfgFileName);
+	if (!file_exists(cfgFilename))
+		cfgFilename = file_cfgname(js.exec_dir, gCfgFileName);
+	retObj.cfgFilename = cfgFilename;
+
+	// Open and read the configuration file
+	var cfgFile = new File(retObj.cfgFilename);
+	if (cfgFile.open("r"))
+	{
+		retObj.cfgOptions = cfgFile.iniGetObject();
+		cfgFile.close();
+	}
+
+	// In case some settings weren't loaded, add defaults
+	if (!retObj.cfgOptions.hasOwnProperty("interfaceStyle"))
+		retObj.cfgOptions.interfaceStyle = "Lightbar";
+	if (!retObj.cfgOptions.hasOwnProperty("traditionalUseSyncStock"))
+		retObj.cfgOptions.traditionalUseSyncStock = true;
+	if (!retObj.cfgOptions.hasOwnProperty("sortOrder"))
+		retObj.cfgOptions.sortOrder = "PER_DIR_CFG";
+	if (!retObj.cfgOptions.hasOwnProperty("pauseAfterViewingFile"))
+		retObj.cfgOptions.pauseAfterViewingFile = false;
+	if (!retObj.cfgOptions.hasOwnProperty("blankNFilesListedStrIfLoadableModule"))
+		retObj.cfgOptions.blankNFilesListedStrIfLoadableModule = true;
+	if (!retObj.cfgOptions.hasOwnProperty("displayUserAvatars"))
+		retObj.cfgOptions.displayUserAvatars = true;
+	if (!retObj.cfgOptions.hasOwnProperty("useFilenameIfShortDescriptionEmpty"))
+		retObj.cfgOptions.useFilenameIfShortDescriptionEmpty = true;
+	if (!retObj.cfgOptions.hasOwnProperty("filenameInExtendedDescription"))
+		retObj.cfgOptions.filenameInExtendedDescription = "ifDescEmpty";
+	if (!retObj.cfgOptions.hasOwnProperty("displayNumFilesInHeader"))
+		retObj.cfgOptions.displayNumFilesInHeader = true;
+	if (!retObj.cfgOptions.hasOwnProperty("themeFilename"))
+		retObj.cfgOptions.themeFilename = "defaultTheme.cfg";
+
+	return retObj;
+}
+
+// Saves the DDFileLister configuration file using the settings in gCfgInfo
+//
+// Return value: An object with the following properties:
+//               saveSucceeded: Boolean - Whether or not the save fully succeeded
+//               savedToModsDir: Boolean - Whether or not the .cfg file was saved to the mods directory
+function saveDDFileListerCfgFile()
+{
+	var retObj = {
+		saveSucceeded: false,
+		savedToModsDir: false
+	};
+
+	// If the configuration file was loaded from the standard location in
+	// the Git repository (xtrn/ddfilelister), and the option to always save
+	// in the original directory is not set, then the configuration file
+	// should be copied to sbbs/mods to avoid custom settings being overwritten
+	// with an update.
+	var defaultDirRE = new RegExp("xtrn[\\\\/]ddfilelister[\\\\/]" + gCfgFileName + "$");
+	var cfgFilename = gCfgInfo.cfgFilename;
+	if (defaultDirRE.test(cfgFilename) && !gAlwaysSaveCfgInOriginalDir)
+	{
+		cfgFilename = system.mods_dir + gCfgFileName;
+		if (!file_copy(gCfgInfo.cfgFilename, cfgFilename))
+			return false;
+		retObj.savedToModsDir = true;
+	}
+
+	var cfgFile = new File(cfgFilename);
+	if (cfgFile.open("r+")) // Reading and writing (file must exist)
+	{
+		for (var settingName in gCfgInfo.cfgOptions)
+			cfgFile.iniSetValue(null, settingName, gCfgInfo.cfgOptions[settingName]);
+		cfgFile.close();
+		retObj.saveSucceeded = true;
+	}
+
+	return retObj;
+}
diff --git a/xtrn/ddfilelister/readme.txt b/xtrn/ddfilelister/readme.txt
index a25a63afde1ac4b8189432ba4c70356474ea9683..8df0eb1c35e8616e3fb1a18d35c211a0b984f192 100644
--- a/xtrn/ddfilelister/readme.txt
+++ b/xtrn/ddfilelister/readme.txt
@@ -111,6 +111,14 @@ comprised of the following files:
 3. defaultTheme.cfg       The default theme file containing colors used in the
                           file lister
 
+4. ddfl_cfg.js            A menu-driven configurator for Digital Distortion File
+                          lister - This can be run at the command prompt using
+                          jsexec. For example:
+                          jsexec ddfl_cfg.js
+                          The .js can also be omitted from the filename when
+                          running it this way.
+						  
+
 The configuration files are plain text files, so they can be edited using any
 editor.