Back to Index Page generated: Jun 13, 2026, 7:54:52 PM

Expansion Station Options

Content

Warnings

  1. Information URL mismatch between OXP Manifest and Expansion Manager string length at character position 0

Manifest

from Expansion Manager's OXP list from Expansion Manifest
Description Utility for OXP's to allow pilots to configure equipment, read text when docked. Utility for OXP's to allow pilots to configure equipment, read text when docked.
Identifier oolite.oxp.cag.station_options oolite.oxp.cag.station_options
Title Station Options Station Options
Category Miscellaneous Miscellaneous
Author cag cag
Version 1.1.1 1.1.1
Tags
Required Oolite Version
Maximum Oolite Version
Required Expansions
Optional Expansions
Conflict Expansions
Dependent Expansions
  • oolite.oxp.Norby.cag.Telescope:2.1.4
  • oolite.oxp.cag.telescope_StationOptions.oxp:1.0
  • Information URL n/a
    Download URL https://wiki.alioth.net/img_auth.php/2/24/Cag.station_options.oxz n/a
    License CC BY-NC-SA 4 CC BY-NC-SA 4
    File Size n/a
    Upload date 1645495484

    Relationships Diagram

    Documentation

    Also read http://wiki.alioth.net/index.php/Station%20Options

    Station_Options_readme.txt

    Station Options OXP
    
    Instructions for pilots:
    
    Do not unzip the .oxz file, just move into the AddOns folder of your 
    Oolite installation.
    
    Instructions for authors:
    
    As the title suggests, this utility allows an oxp author to give pilots the 
    ability to edit options at the station, as well as read any text you wish
    to supply (e.g. the contents of the readme file).
    
    Apart from a single JS script line to register your oxp, this utility is 
    driven completely from the missiontext.plist file, thus having everything in
    one file.
    
    Included in the root folder of this oxp is a template file with all the
    instructions and some examples.  Simply copy this file into your oxp's
    Config folder and customize it (don't forget to remove the examples!).  If 
    you already have a missiontext.plist file, you will have to merge the two, 
    otherwise, just rename the template file to missiontext.plist.
    
    
    Changelog:
    
    v 1.0   initial release
    

    Equipment

    This expansion declares no equipment. This may be related to warnings.

    Ships

    This expansion declares no ships. This may be related to warnings.

    Models

    This expansion declares no models. This may be related to warnings.

    Scripts

    Path
    Scripts/cagsdebug.js
    this.name		 = "cagsdebug";
    this.author		 = "cag";
    this.copyright	 = "2017 cag";
    this.licence	 = "CC BY-NC-SA 3.0";
    this.description = "debugging helper fns";
    this.version	 = "1.0";
    
    /* jshint elision:true, shadow:false, esnext: true, curly: false, maxerr: 1000, asi: true, laxbreak: true, undef: true, unused:true
    */
    /* global log, worldScripts, Script, Vector3D, Quaternion
    */
    
    (function(){
    /* validthis: true
    */
    "use strict";
    
    // false prevents output, though functions are still tracked
    // - except with start's header, if it's forced by a child fn's msg or by its stop's msg.
    // So putting text in a stop will force output of start's header when there's no regular
    // output from add, begin or end
    // - useful for exceptional situations, where a fn quits early: put a stop w/ msg before the return
    //	 and output start's header as well; only see the fn output when something interesting happens
    this.$fns_watched = {
    'shipSpawned':				false,
    'shipScoopedOther':			false,
    'update_one_Sighting':		true,
    '_update_some_Sighting':	true,
    '_update_Sightings':		false,
    '_delete_Sighting':			true,
    '_add_Sighting':			true
    };
    
    // worldScripts.telescope.$cag.init();
    
    this.$project_name = 'telescope';
    
    this.$DEBUG_CONSOLE_WIDTH = 200;
    this.$DEBUG_OFF = true;
    //this.$DEBUG_OFF = false;
    
    this._display_map = function _display_map( map ) {
    	var that = _display_map;
    	var _showProps = ( that._showProps = that._showProps || worldScripts.cagsdebug._showProps );
    	
    	if( map.hasOwnProperty( 'map' ) ) {
    		map = map.map;
    	}
    	var tmp = _showProps( map, 'map', false, true, true );
    	const MFD_FRIENDLY = 1,			// bounty === 0 && !markedForFines
    		  MFD_UNSOCIABLE = 2,		// bounty || markedForFines
    		  MFD_ACTIVE = 4,			// .target || defenseTargets.length > 0
    		  MFD_HOSTILE = 8,			// in_ents_Targets ||
    		  MFD_NEARBY = 16,			// distance < scannerRange
    		  MFD_FARAWAY = 32,			// distance > scannerRange
    		  MFD_PROTECTED = 64;		// .withinStationAegis
    	const MFD_SALVAGE = 1,			// cargo, escape pods, derelicts
    		  MFD_MINING = 2,			// asteroids, boulders, splinters & metal fragments
    		  MFD_WEAPONS = 4,			// mines & missiles
    		  MFD_TRADERS = 8,			// ships .isTrader & escorts
    		  MFD_POLICE = 16,			// .isPolice
    		  MFD_PIRATES = 32,			// .isPirate & .isPirateVictim
    		  MFD_MILITARY = 64,		// scanClass === 'CLASS_MILITARY'
    		  MFD_ALIENS = 128,			// scanClass === 'CLASS_THARGOID'
    		  MFD_STATION = 512,		// scanClass === 'CLASS_STATION'
    		  MFD_NAVIGATION = 512,		// some stations & beacons (may include a ship if emitting a beacon)
    		  MFD_CELESTIAL = 1024;		// sun,  planets, moons
    
    	tmp += '\ndynamic flags';
    	var flags = map.dynamicMFD;
    	if( flags ) {
    		if( flags & MFD_FRIENDLY ) tmp += ':  FRIENDLY';
    		if( flags & MFD_UNSOCIABLE ) tmp += ':	UNSOCIABLE';
    		if( flags & MFD_ACTIVE ) tmp += ':	ACTIVE';
    		if( flags & MFD_HOSTILE ) tmp += ':	 HOSTILE';
    		if( flags & MFD_NEARBY ) tmp += ':	NEARBY';
    		if( flags & MFD_FARAWAY ) tmp += ':	 FARAWAY';
    		if( flags & MFD_PROTECTED ) tmp += ':  PROTECTED';
    	}
    	tmp += '\nstatic flags';
    	flags = map.staticMFD;
    	if( flags ) {
    		if( flags & MFD_SALVAGE ) tmp += ':	 SALVAGE';
    		if( flags & MFD_MINING ) tmp += ':	MINING';
    		if( flags & MFD_WEAPONS ) tmp += ':	 WEAPONS';
    		if( flags & MFD_TRADERS ) tmp += ':	 TRADERS';
    		if( flags & MFD_POLICE ) tmp += ':	POLICE';
    		if( flags & MFD_PIRATES ) tmp += ':	 PIRATES';
    		if( flags & MFD_MILITARY ) tmp += ':  MILITARY';
    		if( flags & MFD_ALIENS ) tmp += ':	ALIENS';
    		if( flags & MFD_STATION ) tmp += ':	 STATION';
    		if( flags & MFD_NAVIGATION ) tmp += ':	NAVIGATION';
    		if( flags & MFD_CELESTIAL ) tmp += ':  CELESTIAL';
    	}
    	log( tmp );
    }
    
    this._dump_map = function _dump_map() {		// dump Sightings list for telescope
    	var that = _dump_map;
    	var cd = ( that.cd = that.cd || worldScripts.cagsdebug );
    	var ws = ( that.ws = that.ws || worldScripts.telescope );
    	var curr_S = ( that.curr_S = that.curr_S || ws.$curr_Sighting );
    	
    	var pst = curr_S.ent || null;
    	var mapping = ws.$SightingsMap, maplen = mapping.length;
    	var map, ent, target, thargoids = '';
    	var out = '____ent_dist.rank   index	 rel. dir.n		 person	   [entity description minus position, scanClass & status labels ...]	 [ve_colour,  lb_size,	ml_size]\n';
    	// var out = '____ent_dist.rank   index	 rel. dir.n	   curr.grav.D	 person	   [entity description minus position, scanClass & status labels ...]	 [ve_colour,  lb_size,	ml_size]\n';
    	var i, padding = '________________';
    	var	 ent_dist, rank, index, rel_dir, gs_curr, entID, descrn, gs_maxed;
    	for( i = 0; i < maplen; i++ ) {
    		map = mapping[i]; ent = map.ent;
    		gs_curr = map.gs_curr_dist;
    		gs_maxed = gs_curr > 0 && gs_curr === map.gs_max_dist;
    		target = pst && pst === ent;
    		if(ent.isThargoid) { out += ent+'\n';
    			thargoids += '.	   dataKey = '+map.dataKey+', last_posn = '+map.last_posn+' roles = '+map.roles+'\n';
    		}
    		ent_dist = map.ent_dist;
    		if( ent_dist > 999999999 )
    			ent_dist = cd._number_str( ent_dist / 1000000000 ) + ' e9';
    		else
    			ent_dist = cd._number_str( ent_dist );
    		ent_dist = padding.slice( ent_dist.length-10 )+ ent_dist;
    		if( target ) {
    			// ent_dist = ent_dist.replace(/^_*/, '==>');
    			ent_dist = ent_dist[1] == '_' ? '=>' + ent_dist.substring(2) :
    					   ent_dist[0] == '_' ? '|>' + ent_dist.substring(1) :
    					   '>' + ent_dist;
    		}
    		rank = map.rank.toUpperCase();
    		index = i < 10 ? '__' + i : i > 99 ? i : '_' + i;
    		rel_dir = ws._relativeDirection( ent.position, map );		
    		rel_dir = rel_dir ? padding.slice( rel_dir.length-9 ) + rel_dir : '???';
    		// gs_curr = cd._number_str( gs_curr );							
    		// gs_curr = padding.slice( gs_curr.length-(gs_maxed ? 10 : 11) ) + gs_curr;
    		entID = ent.entityPersonality;
    		entID = entID === undefined ? 'xxxxx' : entID.toString();	
    		entID = padding.slice( entID.length-6 ) + entID;
    		descrn = ent.toString();
    		descrn = descrn.replace(/position\: /, '' );
    		descrn = descrn.replace(/scanClass\: /, '__' );
    		descrn = descrn.replace(/CLASS_/, '' );
    		descrn = descrn.replace(/status\: /, '__' );
    		descrn = descrn.replace(/STATUS_/, '' );
    		out += ent_dist+'_'+rank
    		out += map.swapable ? '%' : '_';
    		out += '	['+index+']'+(target ? '|>' : '  ')+'['+rel_dir+']'
    			+(target ? '|>' : '  ')
    			// +'['+gs_curr+(gs_maxed ? ' M' : ']')
    			+' #'+entID+'  '
    			+descrn+'	['+map.ve_colour+', '+map.lb_size+', '+map.ml_size+']'+(target ? '<==' : '')
    //			  +' Ms: '+ map.staticMFD.toString(2)+' Md: '+map.dynamicMFD.toString(2)
    			+' stat: '+ cd._number_str( map.staticMFD, 0, 2 ) + ' dyn: '+ cd._number_str( map.dynamicMFD, 0, 2)
    			+ (map.have_scanned ? ' h_s: ' + map.have_scanned :'')
    			+'\n';
    	}
    	out += 'ws.$SightingsMap.length = ' + mapping.length;
    	log(out);
    	if( thargoids ) log( 'Thargoids:\n' + thargoids );
    }
    
    this._rel2PS = function rel2PS( name, position, distance ) {
    	var cd = worldScripts.cagsdebug;
    	var ps = player && player.ship;
    	var ps_position = ps.position;
    	let posn =	position.constructor === Vector3D ? position :
    				Array.isArray(position) ? new Vector3D( position ) :
    				position.position || null;
    	if( !posn ) {
    		return 'missing position for ' + name;
    	}
    	let dist = distance || posn.distanceTo( ps_position );
    	let padding = '________________';
    	let pname = padding.slice( name.length - 16 ) + name;
    	if( name === 'lightball' ) pname = '__' + pname;
    	let vector = posn.subtract( ps_position );
    	let fwd, right, up, rpt = '';
    	fwd =	( ps.vectorForward.angleTo( vector ) *180/3.1415927 ).toFixed();
    	right =	( ps.vectorRight.angleTo( vector ) *180/3.1415927 ).toFixed();
    	up =	( ps.vectorUp.angleTo( vector ) *180/3.1415927 ).toFixed();
    	rpt += pname + ' is ' + ( fwd <= 90 ? 'forward ' : 'astern ' ) + fwd + '°, '
    							+ ( right <= 90 ? '	 starboard ' : '		port ') + right + '°, '
    							+ ( up <= 90 ? '	above ' : '	   below ') +  up
    							+ '° at ' + cd._number_str( dist ) + ' m';
    	// vector = vector.direction();
    	// fwd =	ps.vectorForward.dot( vector ).toFixed(5);
    	// right =	ps.vectorRight.dot( vector ).toFixed(5);
    	// up =	ps.vectorUp.dot( vector ).toFixed(5);
    	// rpt += '\n\t' + pname + ' is ' + ( fwd >= 0 ? 'forward ' : 'astern ' ) + ( Math.acos(fwd) *180/3.1415927 ).toFixed() + '°, '
    							// + ( right >= 0 ? '  starboard ' : '		  port ') + ( Math.acos(right) *180/3.1415927 ).toFixed() + '°, '
    							// + ( up >= 0 ? '	  above ' : '	 below ') +	 ( Math.acos(up) *180/3.1415927 ).toFixed()
    							// + '° at ' + cd._number_str( dist ) + ' m';
    	return rpt;
    }
    
    this._curr_S_report = function _curr_S_report() {
    	var cd = worldScripts.cagsdebug;
    	var cs = worldScripts.telescope.$curr_Sighting;
    	var ps = player && player.ship;
    	var scannerRange = ps.scannerRange;
    
    	let map = cs.map;
    	if( !map ) {
    		log('telescope', '_curr_S_report, $curr_Sighting.map is ' + map );
    		return;
    	}
    	let ent = cs.ent;
    	let map_ent_dist = cs.map.ent_dist;
    	let marker = cs.marker;
    	let lightball = cs.map.lightball;
    	let tmp = 'ps.speed = ' + ps.speed + ',	   viewDirection = ' + ps.viewDirection + ',  for ' + ent;
    	tmp += '\n ' + cd._rel2PS( 'ent', ent, map.ent_dist );
    	if( marker )
    		tmp += '\n ' + cd._rel2PS( 'marker', marker	 );
    	if( lightball )
    		tmp += '\n ' + cd._rel2PS( 'lightball', lightball );
    //	let ent_posn = map.rank === 'ukn'  ? map.last_posn : ent.position;
    
    	let marker_dist = scannerRange - 499.6;
    	if( map_ent_dist < scannerRange ) {
    		if( map_ent_dist < marker_dist )
    			marker_dist = map_ent_dist;
    	}
    //	let targ_dir = ent_posn.subtract( ps_position ).direction();
    //	let targ_posn = ps_position.add( targ_dir.multiply( marker_dist ) );//.add( speed_adj );
    //	tmp += '\n ' + cd._rel2PS( 'new marker', targ_posn, marker_dist );
    
    	log('telescope', tmp );
    }
    
    // debugger ///////////////////////////////////////////////////////////////////////////////////////
    
    /*	(function () {
    WS.telescope.$cag = worldScripts.cagsdebug._debug_msgs();
    WS.telescope.$cag.init();
    })()	// */
    
    this._debug_msgs = function _debug_msgs() {
    	function break_line( str, limit ) {			// break str into lines, avoiding splitting on an '=' if poss.
    		function trim( x ) {											// return index of 1st non-whitespace char.
    			var space;
    			var index = x;											// never modify arguments
    			do {
    				space = str[ index ];
    				if( space !== ' ' && space !== '\t' ) break;
    				index++;
    			} while( index < str.length );
    			return index;
    		}
    		function output( x ) {										// push indices for next line; return true if finished w/ str
    			var index = x;											// never modify arguments
    			strptr = trim( strptr );
    			lines.push( [ strptr, index ] );
    			index = trim( index );
    			if( index + limit >= str.length ) {						// just tail end remains
    				lines.push( [ index, -1 ] );
    				return true;										// finished w/ str
    			}
    			strptr = index;		i = index + limit;		first = -1;		newline = -1;
    			return false;											// still more to output
    		}
    		function _seek( dir ) {										// walk str in direction dir across whitespace; return presence of an '='
    			var equals = false;
    			do {													// scan in direction dir for end of whitespace or an '='
    				if( i + dir < 0 || i + dir >= str.length ) break;	// protect limits
    				i += dir;
    				letter = str[ i ];
    				if( letter === '=' ) equals = true;
    			} while( letter === ' ' || letter === '\t' || letter === '=' );
    			i -= dir;
    			return equals;											// return presence of an '='
    		}
    		var first = -1;		var letter, i;		var newline = -1;
    		var lines = [];		var strptr = 0;
    		for( i = strptr + limit; i > strptr; i-- ) {
    			if( newline === -1 ) {
    				newline = str.indexOf( '\n', strptr );
    				if( newline >= strptr && newline <= i )				// respect imbedded newlines
    					if( output( newline + 1 ) ) break;
    			} else
    				newline = 0;										// only check once per output()
    			letter = str[ i ];
    			if( letter === ' ' || letter === '\t' ) {
    				if( _seek( 1 ) || _seek( -1 ) ) {					// look fwd & back for '='s; found a bad break, ie. has '='
    					if( first < 0 ) first = i;						// save the first, just in case
    				} else {
    					if( output( i ) ) break;						// i pts to 1st letter in break
    					continue;
    				}
    			}
    			if( i - strptr <= 20 ) {								// no good break bound; reasonable lower limit on line length
    				if( first > -1 ) {									// use previously found (bad) break
    					if( output( first ) ) break;
    				} else {											// no break found, arbitrary cut using limit
    					if( output( strptr + limit ) ) break;
    				}
    			}
    		}
    		return lines;
    	}
    
    	function insert( str, frame ) {				// insert str into frame's msg buffer
    		var msg_len = frame.msg.length;
    		var pad_len = frame.pad.length;
    		var str_len = str.length;
    		if( msg_len === 0 ) {										// pad the 1st line
    			frame.len = pad_len;									// will be added on output but need to account for its length now
    		}
    		if( str_len + frame.len	 > DEBUG_CONSOLE_WIDTH ) {			// begin a new line
    			var lines = break_line( str, DEBUG_CONSOLE_WIDTH - pad_len );
    			var start, end, i;
    			for( i = 0; i < lines.length; i ++ ) {
    				[start, end] = lines[ i ];
    				frame.msg += str.substring( start, ( end === -1 ? str_len : end ) ) + '\n';
    			}
    		} else {
    			frame.msg += ( msg_len > 0 ? '	' : '' ) + str;
    		}
    		var nl_index = frame.msg.lastIndexOf( '\n', msg_len );
    		if( nl_index > -1 ) {										// calc length of last line in msg
    			frame.len = msg_len - nl_index;							// '\n' has length of 1
    		} else {
    			frame.len = msg_len;
    		}
    	}
    
    	function is_watched_fn( dbgfn, str ) {		// check user supplied $fns_watched to determine if msg should proceed
    		var caller = frame.caller;
    		if( fns_watched.length === 0 ) return true;					// fn limiting not used
    		if( frame === null ) {
    			log( project_name, 'ERR: _debug, null frame: debug cmd outside start...stop sequence.  is_watched_fn:  '+dbgfn+':  str = '+str+'\n############');
    			return true; // allow in hopes of locating errant cmd
    		}
    		if( !fns_watched.hasOwnProperty( caller ) ) return false;	// fn limiting is in play but fn missing -> deny by default
    		if( fns_watched[ caller ] !== true )						// its in use, fn found but turned off
    			return false;
    		return true;												// fn in list & set true!
    	}
    
    	function trim_space( str ) {				// trim white space at start & end of line; -can't use .trim() as it removes line breaks!
    		return str.replace(/^[ \t\u1680\u180e\u2000-\u200a\u202f\u205f\u3000\ufeff]+|[ \t\u1680\u180e\u2000-\u200a\u202f\u205f\u3000\ufeff]+$/g, '');
    	}
    
    	function change_breaks( line ) {			// collapse & convert all line breaks
    		return line.replace(/[\n\r\f\v\u00a0\u2028\u2029]+/g, '<BR>');
    	}
    
    	function output() {							// called by stop to output or start to flush pre-existing msg
    		if( DEBUG_OFF ) return;
    		var incl_hd = frame.incl_hd;
    		var msg = frame.msg;
    		if( !incl_hd && !msg )										// unless directed to output hd, only log if there's a msg
    			return;													// excl. header when no msg
    		if( incl_hd && frame.out_len > 0 )							// a force_hd not necess, as frame already has output
    			return;
    		var head = frame.head;
    		var out = curr_pad + '	' + frame.caller + ':: ';			// 1st line of output always starts w/ the caller (.len incr'd in .add)
    		if( head ) out += head;										// .head contains a newline char
    		out += msg;
    		out = trim_space( out );
    		out = change_breaks( out );
    		out = out.replace( /(<BR>)+$/g, '' );						// trim any trailing line break
    		out = out.replace( /(<BR>)+/g, '\n' + curr_pad + '	' );	// add padding to start of each line
    		if( out.slice( - curr_pad.length ) === curr_pad )			// out ends w/ just a pad
    			out = out.slice( 0, - curr_pad.length );
    		if( frame.logging ) log( project_name, out );				// writes to console and log file
    		else				log( out );								// writes only to console
    		frame.out_len += out.length;
    		frame.head = '';	msg = '';	frame.len = 0;
    		frame.incl_hd = false;										// reset for next time
    	}
    
    	function add_msg( via, str, format ) {		// insert format str into msg buffer
    		if( !is_watched_fn( via, str ) ) return;
    		if( !str ) return;
    		if( stack.length === 0 )
    			log( project_name, 'ERR: debug, no debug frame: check for premature ".stop()" or add a ".start()" statement. .' + via + ':	str = ' + str +'\n############');
    		insert( format, frame );
    	}
    
    	// add( str ) - append str to output msg
    	function add( str ) {						// user fn to append str to msg
    		if( DEBUG_OFF ) return;
    		add_msg( 'add', str, str );
    	}
    
    	// begin( str ) - output str at start of a new line (prepends a newline)
    	function begin( str ) {						// user fn to begin a new line w/ str
    		if( DEBUG_OFF ) return;
    		add_msg( 'begin', str, (frame.len > 0 ? '\n' + str : str) );
    	}
    
    	// end( str ) - output str at end of curr. line (appends a newline)
    	function end( str ) {						// user fn to end the current line w/ str
    		if( DEBUG_OFF ) return;
    		add_msg( 'end', str, str +'\n' );
    	}
    
    	// log_file( str, [con_log], [caller] )	 - stand-alone user fn to add output to log file
    	//										 - it ignores $DEBUG_OFF flag but terminates any further action if true
    	//										 - con_log flag can be used to output to console as well, in curr. frame
    	//										 - caller may be from any previous frame; if not found & con_log is true, output added to current frame
    	function log_file( str, con_log, caller ) {	// output data to log file independent of the current frame
    		log( project_name, caller + ', ' + str );					// writes to log file
    		if( DEBUG_OFF ) return;										// debugger if off, so just log msg
    		if( stack.length === 0 )	return;							// no active frame
    		if( con_log === undefined ) return;
    		if( caller === undefined ) {								// add str to curr. frame
    			add( str );
    			return;
    		}
    		var prev;
    		var len = stack.length;
    		for( var i = 0; i < len; i ++ ) {
    			prev = stack[ i ].caller;
    			if( prev === caller ) {
    				insert( str, prev )									// add str to caller's frame
    				return;
    			}
    		}
    		add( str );													// failed to locate caller, add to curr. frame
    	}
    
    	function Frame( c ) {	this.caller = c;		this.head = '';			this.msg = '';
    							this.pad = '';			this.out_len = 0;		this.len = 0;
    							this.incl_hd = false;	this.force_hd = 1;		this.logging = false;		}
    	var used_frames = [];
    	var used_frame_num = 0;
    	function free_frame( frame ) {
    //log('telescope', 'free_frame, for caller = ' + frame.caller + ', pool totals ' + (used_frame_num + 1)+ ', on stack: ' + (stack.length - 1) );
    		frame.caller = null;	frame.head = '';		frame.msg = '';
    		frame.pad = '';			frame.out_len = 0;		frame.len = 0;
    		frame.incl_hd = false;	frame.force_hd = 1;		frame.logging = false;
    		used_frames.push( frame );
    		used_frame_num++;
    	}
    	function alloc_frame( caller ) {
    		var frame;
    		if( used_frame_num > 0 ) {
    			frame = used_frames.pop();
    			used_frame_num--
    			frame.caller = caller;
    //log('telescope', 'alloc_frame for caller = ' + caller + ', allocating recycled frame, pool totals ' + used_frame_num + ', on stack: ' + (stack.length + 1) );
    		} else {
    			frame = new Frame( caller );
    log('telescope', 'alloc_frame, for caller = ' + caller + ', allocating NEW frame, pool totals ' + used_frame_num + ', on stack: ' + (stack.length + 1) );
    		}
    		stack.push( frame );
    		return frame;
    	}
    
    	// start( caller, [hd], [force_hd], [logging] )	 - open new debug level; caller is function's name (output toggled in $fns_watched)
    	//												 - hd is str (heading) that is output only if more str's comes in
    	//												 - force_hd, an int, over-rules this (default = 1; see Frame object).
    	//												   this give some context when a normally silent fn outputs a msg,
    	//												   by showing who called it. The higher the number, the farther up the frame stack
    	//												   headers are forced to output
    	//												 - logging is a boolean to also write to log file (vs. debug console only)
    	function start( caller, hd,					// user fn to init a msg frame, start's hd string is not counted in len, so if
    					force_hd, logging ) {		// no fn msg's between start & stop, no output
    		if( DEBUG_OFF ) return;
    		var len = stack.length;
    		if( len > 0 ) {												// not the root frame
    			output();												// flush any pending output
    			var fn_name = caller !== undefined ? caller
    											   : 'Anonomous' + stack.length;
    			var prev;
    			for( var i = len -1; i >= 0; i-- ) {					// check not already on stack (fix for GDERM bug)
    				prev = stack[ i ].caller;
    				if( prev === fn_name ) {
    					free_frame( stack.splice( i, 1 )[0] );
    					curr_pad = curr_pad.slice( 0, -PAD_LEN );		// just remove that one entry, as we know we're not recursing
    				}
    			}
    		}
    		frame = alloc_frame( caller );
    		var tmp_pad;
    		if( stack.length % 2 === 0 ) tmp_pad = '=========';			// alternate padding char's
    		else						 tmp_pad = '+++++++++';
    		frame.pad = tmp_pad.substr( 0, PAD_LEN );
    		curr_pad += frame.pad;
    		if( caller !== undefined ) {
    			frame.caller = caller;
    			frame.len += frame.caller.length;
    		} else {
    			log( project_name, 'ERR: _debug, invalid caller: function name needed for output to be formatted correctly. .start: str = ' + hd +'\n############');
    		}
    		if( frame === null ) {
    			log( project_name, 'ERR: _debug, internal error: unable to create frame; stack len = ' + len + ', .start:  caller: '+caller+'(),  str = ' + hd +'\n############');
    		}
    		if( logging !== undefined )	 frame.logging = logging;
    		if( force_hd !== undefined ) frame.force_hd = force_hd;
    		if( hd !== undefined ) {
    			tmp_pad = curr_pad;										// save current padding str and clear curr_pad
    			curr_pad = '';											//	 so we don't put any padding on the header
    			insert( hd + '\n', frame );								// a start header always ends its output line
    			curr_pad = tmp_pad;										// restore current padding
    		}
    		frame.head = trim_space( frame.msg );						// move to head
    		frame.msg = '';
    		frame.len = 0;												// reset len as header doesn't count
    	}
    
    	// _stop( caller, [str], [nolog] ) - close curr. level or regress to it if events interruped sequence; nolog suppresses log file output,
    	//									 over-ruling logging flag in start
    	function stop( caller, str, nolog ) {		// user fn to terminate a frame, ouputting any pending messages
    		if( DEBUG_OFF ) return;
    var have_str = str && str.length > 0; // debug
    		if( caller === undefined )
    			log( project_name, 'ERR: _debug, missing caller: function name needed for output to be formatted correctly. .stop:	str = ' + str +'\n############');
    		if( frame === null )
    			log( project_name, 'ERR: _debug, no debug frame: add a "start()" statement. .stop:	caller: '+caller+'(), str = ' + str +'\n############');
    		if( str && caller === frame.caller ) {
    			insert( '\n' + str, frame );
    have_str = false;
    		}
    		if( nolog !== undefined ) frame.logging = !nolog;			// nolog over-rules frame.logging
    		if( stack.length > 0 ) {
    			var msg_len = frame.msg.length;
    			output();												// flush any pending output
    			if( stack.length > 1 ) {								// not the root frame
    				var force_hd = ( msg_len > 0 ? frame.force_hd : 0 );
    				if( force_hd > 0 ) {								// set higher frame(s) to output
    					var i = stack.length;
    					while( i-- && force_hd > 0 ) {
    						stack[ i ].incl_hd = true;
    						force_hd--;
    					}
    				}
    			}
    			free_frame( frame );
    			frame = stack.pop();
    			if( str && caller === frame.caller ) {				// for when a .stop is skipped
    				log( project_name, 'ERR: _debug, missed .stop(): ' + frame.caller + '() stopped while ' + caller + '() still active. .stop:	 str = ' + str +'\n############');
    				insert( '\n' + str, frame );					// still output msg
    have_str = false;
    			}
    			curr_pad = curr_pad.slice( 0, -PAD_LEN );
    		} else {
    			frame = alloc_frame( caller );							// re-init safely after having lost track!
    		}
    if( have_str ) log('telescope', 'debug.stop, failed to output: ' + str );
    	}
    
    	function reset() {							// reset msg stack; meant for debug console but is also a user fn
    		var len;
    		len = stack.length;
    		while( len-- ) {
    			free_frame( stack[ len ] );
    		}
    		stack.length = 0;						// this will clear all ref's to frame objects
    		frame = null;
    		PAD_LEN = 3;
    		curr_pad = '';
    	}
    	function init( project ) {					// import local var's at runtime by calling this fn
    		var wc = worldScripts.cagsdebug;
    		if( project ) {
    			project_name = wc.$project_name = project;
    		} else {
    			project_name = wc.$project_name;
    		}
    		fns_watched = wc.$fns_watched;
    		DEBUG_CONSOLE_WIDTH = wc.$DEBUG_CONSOLE_WIDTH;
    		DEBUG_OFF = wc.$DEBUG_OFF;
    	}
    
    	var frame = null;
    	var stack = [];		// stack built of Frame's when debugging fns are nested
    	var PAD_LEN = 3;	// max 9 unless you enlarge 2 padding str's in start
    	var curr_pad = '';
    	var project_name, fns_watched, DEBUG_CONSOLE_WIDTH, DEBUG_OFF;
    
    	return {	 init:	init,
    				  add:	add,
    				begin:	begin,
    				  end:	end,
    			 log_file:	log_file,
    				reset:	reset,
    				start:	start,
    				 stop:	stop
    	};
    }
    
    // debugger ends //////////////////////////////////////////////////////////////////////////////////
    
    this._reportError = function _reportError( err, func, parms, depth, goDeep ) {
    	// constants - adjust as needed
    	var FILE_LEN = 100;		// cut-off len for file spec.
    	var FNAME_LEN = 40;		// cut-off len for function name
    	var ARGS_LEN = 60;		// cut-off len for arguments string
    	var STRING_LEN = 80;	// cut-off for long strings
    	var IPAD = ' ';			// inside padding, eg. after array open bracket, before close bracket
    /*	
    	err		(required) value passed in the catch statement
    	
    	func	(required) function that caught the error
    			- can also be a string with the function's name if the
    			  function is not named
    			  eg. this.startUp = function() { ...			-> pass 'startUp'
    			      this.startUp = function startUp() { ...	-> pass startUp
    			- using a named function has the advantage of reporting 
    			  any function properties  
    				
    	parms	(optional) parameters passed to func
    			- if there are multiple parms, pass them in an array
    			- can also include function variables if you want more 
    			  information dumped
    			  eg. this.shipSpawned = function shipSpawned( ship ) { 
    					var dist = player.ship.position.distanceTo( ship ); ...
    			      -> pass as parms: [ship, dist] to have both displayed
    				  
    	depth	(optional) # levels to expose contents of parameters/properties
    			- default is 1, use 0 to suppress overly long output
    			  eg. if a parm is an array of 3 items, 0 will print "<array of 3>",
    				  1 will print "[ item1, item2, item3 ]"
    				  - if item3 is an object: "[ item1, item2, <object of ..> ]"
    				  2 will print the contents of each item, in this case, 
    				    expanding the object
    				  3 will expand the object's properties, etc
    				  (ditto for nested arrays)
    			- NB: depth > 2 will likely produce large output, eg. ships
    			- will accept a 2 item array if you want different depths for 
    			  parameters vs. properties, ie. [ parm depth, prop depth ]
    			  eg. [ 2, 1 ]
    			
    	goDeep	(optional) boolean indicating whether or not to test hasOwnProperty
    			when exposing properties
    			- default is false meaning inherited properties won't be shown
    			- eg. if you dump player.ship, false shows 63 properties, while true 
    				  shows 226! (player properties + ship properties + entity properties)
    			- inherited properties are prefixed by a caret '^'
    				
    	example usage:
    	=============
    	
    	this.relativeDirection = function relativeDirection( position, map ) {
    		var that = relativeDirection;
    		var ws = ( that.ws = that.ws || worldScripts.telescope );
    		var ps = player && player.ship;
    		
    		try {
    			var dist = map.ent.positon.distanceTo( ps );			<== position mis-spelled
    			...
    			return relative_dirn;
    		} catch( err ) {
    			log( 'telescope', ws._reportError( err, relativeDirection, [position, map], 1 ) );
    			if( debug ) 
    				throw err;									
    		}
    	}
    
    	Latest.log output:
    	=================
    		
    	18:48:27.293 [LogEvents]: Asteroid 28897 spawned at 413 km
    	18:48:27.898 [telescope]:
    	function relativeDirection() 	 caught: 	TypeError: map.ent.positon is undefined
    		parameters: [ (70839.2, -96302.7, 664477), <object of 6> ]
    		properties: { ws: <object of 169> }
    		file: ../AddOns/Norby.cag.Telescope.oxp/Scripts/telescope.js
    			line: 6204,	relativeDirection( [object Vector3D], [object Object] )
    		file: ../AddOns/Norby.cag.Telescope.oxp/Scripts/cagsdebug.js
    			line:   47,	_display_map( [object Object] )
    	18:48:27.899 [script.javaScript.exception.unexpectedType]: ***** JavaScript exception (oolite-debug-console 1.89): TypeError: map.ent.positon is undefined
    	18:48:27.899 [script.javaScript.exception.unexpectedType]:       ../AddOns/Basic-debug.oxp/Scripts/oolite-debug-console.js, line 1117.
    */
    		
    	function trim_str( str ) {
    		var result, len = str.length;
    		if( len === 0 ) 
    			return '<empty string>';
    		result = str.replace( /[\u180e\u2000-\u200a\u202f\u205f\u3000]+/g, ' ' );
    		result = result.replace( /[\n]+/g, '\\n' ).replace( /[\t]+/g, '\\t' )
    		result = '"' + (len > STRING_LEN ? result.substr(0, STRING_LEN) + ' ...' : result) + '"';
    		return result
    	}
    
    	var padding = [];
    	function mkSpacePad( count ) {
    		if( typeof count === 'number' ) {
    			padding.length = count + 1;
    			return padding.join(' ');
    		}
    		return ' ';
    	}
     
    	function countObjKeys( obj, deep ) {	// Object.keys( obj ).length only counts hasOwnProperty ones
    		var count = 0;						// deep overrides goDeep
    		if( goDeep || deep ) {
    			for( let prop in obj ) 
    				if( prop )					// this is just to silence JSLint
    					count++;
    		} else {
    			count = Object.keys( obj ).length;
    		}
    		return count;
    	}
    	
    	function rptType( obj ) {
    		if( Array.isArray( obj ) ) {
    			let len = obj.length;
    			return len > 0 ? '<array of ' + len + '>' : '[]';
    		} else if( obj instanceof Script ) {
    			return '[Script "' + obj.name + '" version ' + obj.version + ']';
    		} else if( typeof obj === 'object' ) {
    			let len = countObjKeys( obj, true );	// ignore goDeep when counting
    			return len > 0 ? '<object of ' + len + '>' : '{}';
    		} else {
    			return obj;
    		} 
    	}
    	
    	function hasComplex( obj ) {
    		for( let prop in obj ) {
    			if( goDeep || obj.hasOwnProperty( prop ) ) {
    				let item = obj[ prop ];
    				if( Array.isArray( item ) || (typeof item === 'object' && item !== null) )
    					return true;
    			}
    		}
    		return false;
    	}
    
    	function showComplex( obj, recurse ) {		
    		var isArray = Array.isArray( obj );
    		var len = isArray ? obj.length : countObjKeys( obj );
    		if( len === 0 ) return isArray ? '[]' : '{}';
    		var index = 0, 
    			str = (isArray ? '[' : '{') + IPAD, 
    			strLen = str.length;
    		var recursable = recurse > 0 && hasComplex( obj );
    		for( let prop in obj ) {
    			if( goDeep || obj.hasOwnProperty( prop ) ) {
    				let item = obj[ prop ];
    				let propStr = isArray ? '' : 
    							(goDeep && !obj.hasOwnProperty( prop ) ? '^' : '') + prop + ': ';
    				let propLen = propStr.length;
    				str += propStr;
    				if( recursable ) {
    					if( index === 0 ) {
    						outStarts.push( (outStarts.length > 0 
    										? outStarts[outStarts.length-1] + propLen + strLen
    										: strLen + propLen + strLen) );
    					}
    					str += fmt_parm( item, recurse );
    					if( index < len - 1 ) {		// not the last one
    						let inset = outStarts.length > 1 ? outStarts[outStarts.length-2] : strLen;
    						str += ',\n' + mkSpacePad( indentLen + inset );
    					} else {
    						str += IPAD;
    					}
    				} else {
    					str += hasComplex( item ) ? rptType( item ) : fmt_parm( item, 0 );
    					str += index < len - 1 ? ', ' : IPAD;
    				}
    				index++;
    			}
    		}
    		if( recursable && index ) outStarts.pop();
    		return str + (isArray ? ']' : '}');
    	}
    	
    	var outStarts = [];	// stack of running total of recursed insets
    	var parents = [];	// check parm not in parents to avoid endless recursion
    	function fmt_parm( parm, recurse ) {
    		if( parents.indexOf( parm ) < 0 ) {
    			parents.push( parm );
    		} else  {
    			return parm;
    		} 
    		var type = typeof parm;
    		var str = '';
    		if( parm === null ) {
    			str += 'null';
    		} else if( type === 'undefined' ) {
    			str += 'undefined';
    		} else if( type === 'string' ) {
    			str += trim_str( parm );
    		} else if( type === 'boolean' ) {
    			str += (parm ? 'true' : 'false');
    		} else if( type === 'function' ) {
    			str += 'function ' + parm.name + '()';
    		} else if( parm instanceof Script ) {
    			str += '[Script "' + parm.name + '" version ' + parm.version + ']';
    		} else if( parm instanceof Vector3D ) {
    			str += 'Vector3D: (' + parm.x.toFixed() + ', ' 
    					+ parm.y.toFixed() + ', ' + parm.z.toFixed() + ')';
    		} else if( parm instanceof Quaternion ) {
    			str += 'Quaternion: (' + parm.w.toFixed() + ' + ' + parm.x.toFixed() + 'i + ' 
    					+ parm.y.toFixed() + 'j + ' + parm.z.toFixed() + 'k)';
    		} else if( Array.isArray( parm ) ) {
    			str += showComplex( parm, recurse <= 1 ? 0 : recurse - 1 );
    		} else if( type === 'object' && parm ) {
    			str += showComplex( parm, recurse <= 1 ? 0 : recurse - 1 );
    		} else {
    			str += rptType( parm );
    		}
    		parents.pop();
    		return str;
    	}
    
    	var funcProps = {};
    	function propsNotName( obj ) {
    		if( typeof obj !== 'function' ) return 0;	// backwards compatibity
    		for( let key in funcProps ) {				// reset object
    			if( funcProps.hasOwnProperty( key ) )
    				delete funcProps[ key ];
    		}
    		for( let key in obj ) {
    			if( key !== 'name' )
    				funcProps[ key ] = obj[ key ];
    		}
    		return Object.keys( funcProps ).length;
    	}
    	
    	var parmsLabel = '\n    parameters: ';
    	var indentLen = parmsLabel.length - 1;	// -1 for \n
    	var fnName = typeof func === 'function' ? func.name : func; // backwards compatibity
    	var rpt, parmMax, propMax,
    		bonus = Array.isArray( parms ) ? 1 : 0;			// don't count parms being an array as recursion (+ 1)
    	if( Array.isArray( depth ) ) {
    		parmMax = (depth.length > 0 && typeof depth[ 0 ] === 'number' ? ~~(depth[ 0 ]) : 1) + bonus;
    		propMax = (depth.length > 1 && typeof depth[ 1 ] === 'number' ? ~~(depth[ 1 ]) : 1) + bonus;
    	} else {
    		parmMax = propMax = (typeof depth === 'number' ? ~~(depth) : 1) + bonus;
    	}
    	if( err instanceof Error ) {
    		rpt = '\nfunction ' + fnName + '() \t caught: \t' + err.name + ': ' + err.message;
    	} else {		// for thrown strings (user defined errors)
    		rpt = '\nfunction ' + fnName + '() \t caught: \t' + err;
    	}
    	if( parms ) {
    		rpt += parmsLabel + fmt_parm( parms, parmMax );
    	}
    	if( propsNotName( func ) ) {
    		parmsLabel = '\n    properties: ';
    		indentLen = parmsLabel.length - 1;	// -1 for \n
    		rpt += parmsLabel + fmt_parm( funcProps, propMax + 1 );	// + 1 as funcProps is an object
    	}
    	
    	// err is the stack object with properties: message, fileName, lineNumber, stack, name
    	//  - stack is a long string containing <function call>@<filename>:<line #> separated by
    	//    '\n' for each call in the stack
    	if( err && err.stack ) {
    		var lastFile, parsed, frame, fnCall, args, file, line, pad;
    		var stk = err.stack.split( /[\n\r]+/ ); // split on line breaks
    		for( let i = 0, len = stk.length; i < len; i ++ ) {
    			// stack line format: fn(parms)@../AddOns/.../script.js:123
    			parsed = stk[ i ].match( /^\s*(\w+)\((.*?)\)@(.*?):(.*?)$/ );
    			if( !parsed || parsed.length < 5 ) break;
    			[frame, fnCall, args, file, line] = parsed;
    			if( file && file !== lastFile ) {	// suppress repeat of same filename
    				if( file.length > FILE_LEN ) 
    					file = file.substring( file.length - FILE_LEN ) + '...';
    				rpt += '\n    file: ' + file;
    				lastFile = file;
    			}
    			pad = line < 10 ? '   ' : line < 100 ? '  ' : line < 1000 ? ' ' : '' ;
    			rpt += '\n        line: ' + pad + line + ',	'; 
    			if( fnCall.length > FNAME_LEN ) fnCall = fnCall.substring(0, FNAME_LEN) + '...';
    			if( args.length > ARGS_LEN ) args = args.substring(0, ARGS_LEN) + '...';
    			if( args.length ) 					// add spaces inside function's parenthices
    				args = ' ' + args.replace( /,/g, ', ' ) + ' ';
    			rpt += fnCall + '(' + args + ')';	
    		}
    	}
    	return rpt;
    }
    	
    this._number_str = function _number_str( n, fixed, base ) {
    	var that = _number_str;
    	var round = ( that.round = that.round || Math.round );
    	var working = ( that.working = that.working || [] );
    
    	var comma, index, wk, len, str;
    	str = typeof n === 'string' ? parseFloat( n ) : n;
    	if( !isFinite( str ) ) return n.toString();
    	str = fixed > 0 ? str.toFixed( fixed ).toString( base || 10 )
    					: round( str ).toString( base || 10 );
    	len = str.length;
    	working.length = wk = 0;
    	for( index = 0; index < len; index++ ) working[ index ] = str[ index ];
    	index = str.indexOf( '.' );
    	comma = index >=0 ? (index > 0 ? index - 1 : 0) : len - 1;
    	for( ; comma > 0 ; comma -= 3 ) {
    		index = comma - 2;
    		if( (n < 0 ? index-1 : index) <= 0 ) break;
    		for( wk = len - 1; wk >= index; wk-- )
    			working[ wk + 1 ] = working[ wk ];
    		working[ index ] = ',';
    		len = working.length;
    	}
    	return working.join( '' );
    }
    
    //	worldScripts.cagsdebug
    //	worldScripts.cagsdebug._showProps( cs.map, 'map' )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', false,	false,	true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', true,		false,	true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', false,	true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', false,	true,	true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', true,		true,	true )
    //	worldScripts.cagsdebug._showProps( cs.map, 'map', false,	2,		true )
    
    //	worldScripts.cagsdebug._showProps =
    
    this._showProps =	function _showProps( obj, objName, newLine, show_deep, expand_arrays, show_type ) {
    	//										default:	true	true (1)		true			false
    	function trim_str( str ) {
    		var result, len = str.length;
    		if( len === 0 ) 
    			return '<empty string>';
    		result = str.replace( /[\u180e\u2000-\u200a\u202f\u205f\u3000]+/g, ' ' );
    		result = result.replace( /[ ]{3:}/g, '  ' );
    		result = result.replace( /[\n]+/g, '\\n' ).replace( /[\t]+/g, '\\t' )
    		// result = str.replace( /[\s]+/g, ' . ' );
    		result = '"' + (len > 90 ? result.substr(0, 90) + ' ...' : result) + '"';
    		return result
    	}
    
    	function mkPad( i, suppress ) {
    		if( i <= 0 || suppress ) return '';
    		padding.length = newLine ? i : 1;
    		return (newLine ? '.' :'') + padding.join( '    ' ); // '.' needed for console, as leading space trimmed
    	}
    	 
    	function rptType( obj, showIt ) {
    		if( Array.isArray( obj ) )
    			return show_type || showIt ? ' <array: ' + obj.length + ' elements> ' : ''; 
    		else if( typeof obj === 'object' )
    			return show_type || showIt ? ' <object: ' + Object.keys( obj ).length + ' keys> ' : ''; 
    		else
    			return ' <' + typeof obj + '> ';
    	}
     
    	function show_array( array, recurse ) {
    		if( array.length === 0 ) return '[ ]';
    		ilevel++;
    		var str = '[ ';
    		var pad = mkPad( ilevel, !expand_arrays );
    		var len = array.length;
    		for( var index = 0; index < len; index ++ ) {
    			str += !expand_arrays || !newLine ? (index > 0 ? ', ' : '') : '\n';
    			str += pad;
    			if( expand_arrays )
    				str += index + ': ' + fmt_prop( array[ index ], recurse );
    			else
    				str += fmt_prop( array[ index ], (recurse <= 1 ? 0 : recurse - 1) );
    		}
    		ilevel--;
    		if( len > 0 && expand_arrays && newLine ) str += '\n';
    		str += mkPad( ilevel, !len || !expand_arrays ) + ' ]' + rptType( array );
    		return str;
    	}
    
    	function show_obj( obj, recurse ) {
    		ilevel++;
    		var str = '{ ';
    		var pad = mkPad( ilevel );
    		var len = Object.keys( obj ).length;
    		for( var item in obj ) {
    			str += newLine ? '\n' : '    ';
    			str += pad;
    			if( Array.isArray( obj ) ) {
    				str += show_array( obj[ item ], recurse );
    			} else {
    				str += (obj.hasOwnProperty( item ) ? '' : '^') + item + ': ';
    				str += fmt_prop( obj[ item ], recurse );
    			}
    			str += ';';
    		}
    		ilevel--;
    		if( len > 0 && newLine ) str += '\n';
    		str += mkPad( ilevel, !len ) + ' }' + rptType( obj );
    		return str;
    	}
    	
    	function fmt_prop( prop, recurse ) {
    		if( parents.indexOf( prop ) < 0 )
    			parents.push( prop );
    		else 
    			return prop;
    		var type = typeof prop;
    		var str = '';
    		if( prop === null ) {
    			str += 'null';
    		} else if( type === 'undefined' ) {
    			str += 'undefined';
    		} else if( type === 'string' ) {
    			str += trim_str( prop ) + (show_type ? ' <string: ' + prop.length + ' char.s>' : '');
    		} else if( type === 'number' ) {
    			str +=	cd._number_str( prop, prop % 1 === 0 ? 0 : expand_arrays ? 3 : 1 )
    					+ (show_type ? ' <number>' : '');
    		} else if( type === 'boolean' ) {
    			str += prop + (show_type ? ' <boolean>' : '');
    		} else if( type === 'function' ) {
    			str += ' function ' + prop.name + '()';
    		} else if( Array.isArray( prop ) ) {
    			if( expand_arrays ) {
    				str += show_array( prop, (recurse <= 1 ? 0 : recurse - 1) );
    			} else {
    				str += rptType( prop, true );
    			}
    		} else if( type === 'object' && prop ) {
    			if( recurse > 0 ) {
    				str += show_obj( prop, (recurse <= 1 ? 0 : recurse - 1) );
    			} else {
    				str += rptType( prop, true );
    			}
    		} else {
    			str += prop + (show_type ? ' <'+type+'>' : '');
    		}
    		parents.pop();
    		return str;
    	}
    	
    	var that = _showProps;
    	var cd = ( that.cd = that.cd || worldScripts.cagsdebug );
    	
    	var padding = [], parents = [];
    	if( show_type === undefined ) 		show_type = false;
    	if( expand_arrays === undefined )	expand_arrays = true;	
    	if( show_deep === undefined ) 		show_deep = 1;
    	if( newLine === undefined ) 		newLine = true;
    	var rmax = !show_deep ? 0 : show_deep === true ? 1 : ~~(show_deep);
    	var ilevel = 1;
    
    	return (newLine ? '\n' : ' ') + objName + ': ' + fmt_prop( obj, rmax ) + (newLine ? '\n' : '');
    }
    
    }).call(this);
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    Scripts/station_options.js
    this.name		 = "station_options";
    this.author		 = "cag";
    this.copyright	 = "2018 cag";
    this.licence	 = "CC BY-NC-SA 4.0";
    this.description = "Station interface for setting Telescope options.";
    this.version	 = "1.1";
    
    /* jshint elision: true, shadow: true, esnext: true, curly: false, maxerr: 1000, asi: true,
    		  laxbreak: true, undef: true, unused: true, evil: true,  forin: true, eqnull: true,
    		  noarg: true, eqeqeq: true, boss: true, loopfunc: true, strict: true, nonew: true, noempty: false
    */
    
    /*jslint indent: 4, white: true, debug: true, continue: true, sub: true, css: false, todo: true,
    		 on: false, fragment: false, vars: true, nomen: true, plusplus: true, bitwise: true,
    		 regexp: true, newcap: true, unparam: true, sloppy: true, eqeq: true, stupid: true
    */
    
    /* global log, player, worldScripts, Script, Vector3D, Quaternion
    */
    
    (function(){
    /* validthis: true */
    
    "use strict";
    
    this.$O_initStationOptions = function _initStationOptions(
    		hostOxp,				// reference to oxp script
    		keyPrefix,				// string
    		optionsAllowedCallback,	// reference to script function
    		callPWSG,				// boolean
    		notifyCallback,			// reference to script function
    		suppressSummary,		// boolean
    		missionKeys				// object
    	 ) {
    /*
    	hostOxp
    		(required) a reference to your oxp, ie. worldScripts.myOxp
    
    	keyPrefix
    		(required) a string that is the common prefix to all of the entries
    		in your missiontext.plist that are to be used here (usually some
    		form of your oxp's name)
    		- see the missiontext-template.plist file
    
    	optionsAllowedCallback
    		(optional) a callback function in your oxp that returns true/false
    		indicating if your options facility is to be allowed
    		You may wish to restrict access to main stations only, systems of
    		a certain tech level/government, or based on device health
    		- default is to always allow the interface
    
    	callPWSG
    		(optional) a boolean indicating if you want playerWillSaveGame called
    		- if true, it will called after all the option changes have been
    		assigned to your oxp's variables
    		(assuming playerWillSaveGame is where you update your missionVariables)
    
    	notifyCallback
    		(optional) a callback function to notify your oxp that some option
    		have been altered.	Your oxp variables will already have been set;
    		this is for cases where some follow-up code may need to be run
    		- it returns 2 arrays of strings:
    			1st is names of variables changed
    			2nd is names of options pages containing these
    		- see the missiontext-template.plist file
    
    	suppressSummary
    		(optional) indicates if you want the summary page displayed
    		when the player exits the interface after having made any changes
    		- default is to show it
    		- true will shut it down completely
    		The summary page also reminds (nags) the player to save game if
    		autosave is turned off, so "summary" will only suppress the summary
    		but continue to nag and for completeness, "autosave" will show the
    		summary but suppress the reminder
    
    	missionKeys
    		(optional) an object containing keys referenced in missiontext.plist,
    		When expandMissionText is called, the 2nd parameter is an object used
    		in the expansion.  Since that is done here instead, a way is provided
    		to get your keys through (for substitution when inside square brackets)
    		- in addition to all the keys in station_options' missiontext.plist,
    		  the following are also available:
    
    			stn_optns_clockString		current value of clock.clockStringForTime( clock.adjustedSeconds )
    			stn_optns_curr_system		name of current system
    			// the rest are oxp dependent
    			stn_optns_page				name of the option page (from your _optionPages)
    			stn_optns_page_num			number of current page (eg. "Page [stn_optns_page_num] of 5")
    			stn_optns_next_page_num		number of next page (eg. "Switch to page [stn_optns_next_page_num]")
    			stn_optns_page_count		number of pages currently loaded
    			stn_optns_option			name of the current option when in an option page, null otherwise
    			stn_optns_changes_count		number of changes made in current session
    			stn_optns_changed			a map of {option: value} for changes made in current session
    */
    
    	try {
    		var that = _initStationOptions;
    		var so = (that.so = that.so || worldScripts.station_options);
    
    		if( !so._setInterfaces ) {						// 1st time through, create the closure
    			let closure = so._options_closure();
    			so._setInterfaces = closure._setInterfaces;
    			so._registerHostOxp = closure._registerHostOxp;
    			so._purgepools = closure._purgepools;
    			so._resetLocalVars = closure._resetLocalVars;
    
    			// longer prefix needed to avoid circular assignment
    			so._$_getReminder4Oxp = closure._getReminder4Oxp;
    			so._$_getAllowedCallback = closure._getAllowedCallback;
    			so._$_getCallPWSG = closure._getCallPWSG;
    			so._$_getNotifyCallback = closure._getNotifyCallback;
    			so._$_getSuppressSummary = closure._getSuppressSummary;
    			so._$_getMissionKeys = closure._getMissionKeys;
    
    			so._$_setAllowedCallback = closure._setAllowedCallback;
    			so._$_setCallPWSG = closure._setCallPWSG;
    			so._$_setNotifyCallback = closure._setNotifyCallback;
    			so._$_setSuppressSummary = closure._setSuppressSummary;
    			so._$_setMissionKeys = closure._setMissionKeys;
    
    			so._$_updateMissionKeys = closure._updateMissionKeys;
    
    			// delayed these worldScripts to avoid unnecessary calls at startup
    			so.playerBoughtNewShip = so.playerBoughtEquipment =
    				so.equipmentAdded = so.equipmentRemoved = so.equipmentRepaired =
    				so.shipDockedWithStation = so.startUpComplete = so._setStationInterfaceEntries;
    		}
    		var registered = so._registerHostOxp( hostOxp, keyPrefix, optionsAllowedCallback,
    											callPWSG, notifyCallback, suppressSummary, missionKeys );
    
    		so._setStationInterfaceEntries();
    		return registered;
    	} catch( err ) {
    		//  err, functn, parms, depth, goDeep
    		log( so.name, so._reportError( err, _initStationOptions,
    								[hostOxp, keyPrefix, optionsAllowedCallback, callPWSG,
    								notifyCallback, suppressSummary, missionKeys ] ) );
    	}
    }
    
    ///////////////////////////////////////////////////////////////////////////////
    // post init functions ////////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////
    
    // use these funcitons if you wish to alter the values you sent when
    // you called $O_initStationOptions() to register
    
    // Especially useful are the get/set/update functions for the missionKeys,
    // which can override any of the keys defined in your missiontext.plist file
    // and even any of those in this oxp's missiontext.plist (in case you want
    // to change, for example, the text/color of the buttons or any other text)
    
    this.$O_setAllowedCallback = function _setAllowedCallback( keyPrefix, newFunc ) {
    	var that = _setAllowedCallback;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_setAllowedCallback( keyPrefix, newFunc );
    }
    
    this.$O_getAllowedCallback = function _getAllowedCallback( keyPrefix ) {
    	var that = _getAllowedCallback;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getAllowedCallback( keyPrefix );
    }
    
    this.$O_setCallPWSG = function _setCallPWSG( keyPrefix, newBool ) {
    	var that = _setCallPWSG;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_setCallPWSG( keyPrefix, newBool );
    }
    
    this.$O_getCallPWSG = function _getCallPWSG( keyPrefix ) {
    	var that = _getCallPWSG;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getCallPWSG( keyPrefix );
    }
    
    this.$O_setNotifyCallback = function _setNotifyCallback( keyPrefix, newFunc ) {
    	var that = _setNotifyCallback;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_setNotifyCallback( keyPrefix, newFunc );
    }
    
    this.$O_getNotifyCallback = function _getNotifyCallback( keyPrefix ) {
    	var that = _getNotifyCallback;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getNotifyCallback( keyPrefix );
    }
    
    this.$O_setSuppressSummary = function _setSuppressSummary( keyPrefix, newBool ) {
    	var that = _setSuppressSummary;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_setSuppressSummary( keyPrefix, newBool );
    }
    
    this.$O_getSuppressSummary = function _getSuppressSummary( keyPrefix ) {
    	var that = _getSuppressSummary;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getSuppressSummary( keyPrefix );
    }
    
    this.$O_setMissionKeys = function _setMissionKeys( keyPrefix, newKeys ) {
    	var that = _setMissionKeys;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_setMissionKeys( keyPrefix, newKeys );
    }
    
    this.$O_getMissionKeys = function _getMissionKeys( keyPrefix ) {
    	var that = _getMissionKeys;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getMissionKeys( keyPrefix );
    }
    
    this.$O_updateMissionKeys = function _updateMissionKeys( keyPrefix, newKeys ) {
    	var that = _updateMissionKeys;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so._$_updateMissionKeys( keyPrefix, newKeys );
    }
    
    /*
    this fetches an object containing the current state of the summary
    report mechanism (if you didn't register with suppressSummary = true)
    		.reportSummary			boolean
    		.remindAutosave			boolean
    		.autosaveStopRemind		int, counter of how many times user reminded about Autosave
    		.suppressSummary		boolean
     */
    this.$O_getReminder4Oxp = function _getReminder4Oxp( keyPrefix ) {
    	var that = _getReminder4Oxp;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	return so._$_getReminder4Oxp( keyPrefix );
    }
    
    ///////////////////////////////////////////////////////////////////////////////
    // world event handlers ///////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////
    
    this.startUp = function startUp() {
    	var that = startUp;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	so.$Remind2Savegame = JSON.parse( missionVariables.$StationOptionsRemind2Savegame );
    }
    
    this._setStationInterfaceEntries = function _setStationInterfaceEntries() {
    	var that = _setStationInterfaceEntries;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	var ps = player && player.ship;
    	if( ps && ps.dockedStation ) {
    		if( so._setInterfaces ) {						// undefined if have not called _initStationOptions
    			so._setInterfaces();
    			so.$optionsStarted = false;					// flag used for hidding hud
    		}
    	}
    }
    
    this.shipWillLaunchFromStation = function shipWillLaunchFromStation() {
    	var that = shipWillLaunchFromStation;
    	var so = (that.so = that.so || worldScripts.station_options);
    	var ps = player && player.ship;
    
    	ps.hudHidden = false;
    	so.$optionsStarted = false;
    }
    
    this.playerWillSaveGame = function playerWillSaveGame( /*message*/ ) {
    	var that = playerWillSaveGame;
    	var so = (that.so = that.so || worldScripts.station_options);
    
    	missionVariables.$StationOptionsRemind2Savegame = JSON.stringify( so.$Remind2Savegame );
    }
    
    // rather than contend w/ the vagaries of guiScreenChanged,
    // we simply hide HUD on entry (initOptions) & restore it here
    this.missionScreenEnded = function missionScreenEnded() {
    	var that = missionScreenEnded;
    	var so = (that.so = that.so || worldScripts.station_options);
    	var ps = player && player.ship;
    
    	if( so.$optionsStarted ) {
    		ps.hudHidden = so.$hudHidden;					// state save upon options entry
    		so.$optionsStarted = false;
    		so._resetLocalVars();
    		so._purgepools();
    	}
    }
    
    this._reportError = function _reportError( err, functn, parms, depth, goDeep ) {
    	// constants - adjust as needed
    	var FILE_LEN = 100;		// cut-off len for file spec.
    	var FNAME_LEN = 60;		// cut-off len for function name
    	var ARGS_LEN = 80;		// cut-off len for arguments string
    	var STRING_LEN = 120;	// cut-off for long strings
    	var IPAD = ' ';			// inside padding, eg. after array open bracket, before close bracket
    
    	function trim_str( str ) {
    		var result, len = str.length;
    		if( len === 0 )
    			return '<empty string>';
    		result = str.replace( /[\u180e\u2000-\u200a\u202f\u205f\u3000]+/g, ' ' );
    		result = result.replace( /[\n]+/g, '\\n' ).replace( /[\t]+/g, '\\t' )
    		result = '"' + (len > STRING_LEN ? result.slice(0, STRING_LEN) + ' ...' : result) + '"';
    		return result
    	}
    
    	var padding = [];
    	function mkSpacePad( count ) {
    		if( typeof count === 'number' ) {
    			padding.length = count + 1;
    			return padding.join(' ');
    		}
    		return ' ';
    	}
    
    	function countObjKeys( obj, deep ) {	// Object.keys( obj ).length only counts hasOwnProperty ones
    		var count = 0;						// deep overrides goDeep
    		if( goDeep || deep ) {
    			for( let prop in obj )
    				if( prop )					// this is just to silence JSLint
    					count++;
    		} else {
    			count = Object.keys( obj ).length;
    		}
    		return count;
    	}
    
    	function rptType( obj ) {
    		if( Array.isArray( obj ) ) {
    			let len = obj.length;
    			return len > 0 ? '<array of ' + len + '>' : '[]';
    		} else if( obj instanceof Script ) {
    			return '[Script "' + obj.name + '" version ' + obj.version + ']';
    		} else if( typeof obj === 'object' ) {
    			let len = countObjKeys( obj, true );	// ignore goDeep when counting
    			return len > 0 ? '<object of ' + len + '>' : '{}';
    		} else {
    			return obj;
    		}
    	}
    
    	function hasComplex( obj ) {
    		for( let prop in obj ) {
    			if( goDeep || obj.hasOwnProperty( prop ) ) {
    				let item = obj[ prop ];
    				if( Array.isArray( item ) || (typeof item === 'object' && item !== null) )
    					return true;
    			}
    		}
    		return false;
    	}
    
    	function showComplex( obj, recurse ) {
    		var isArray = Array.isArray( obj );
    		var len = isArray ? obj.length : countObjKeys( obj );
    		if( len === 0 ) return isArray ? '[]' : '{}';
    		var index = 0,
    			str = (isArray ? '[' : '{') + IPAD,
    			strLen = str.length;
    		var recursable = recurse > 0 && hasComplex( obj );
    		for( let prop in obj ) {
    			if( goDeep || obj.hasOwnProperty( prop ) ) {
    				let item = obj[ prop ];
    				let propStr = isArray ? '' :
    							(goDeep && !obj.hasOwnProperty( prop ) ? '^' : '') + prop + ': ';
    				let propLen = propStr.length;
    				str += propStr;
    				if( recursable ) {
    					if( index === 0 ) {
    						outStarts.push( (outStarts.length > 0
    										? outStarts[outStarts.length-1] + propLen + strLen
    										: strLen + propLen + strLen) );
    					}
    					str += fmt_parm( item, recurse );
    					if( index < len - 1 ) {		// not the last one
    						let inset = outStarts.length > 1 ? outStarts[outStarts.length-2] : strLen;
    						str += ',\n' + mkSpacePad( indentLen + inset );
    					} else {
    						str += IPAD;
    					}
    				} else {
    					str += hasComplex( item ) ? rptType( item ) : fmt_parm( item, 0 );
    					str += index < len - 1 ? ', ' : IPAD;
    				}
    				index++;
    			}
    		}
    		if( recursable && index ) outStarts.pop();
    		return str + (isArray ? ']' : '}');
    	}
    
    	var outStarts = [];	// stack of running total of recursed insets
    	var parents = [];	// check parm not in parents to avoid endless recursion
    	function fmt_parm( parm, recurse ) {
    		if( parents.indexOf( parm ) < 0 ) {
    			parents.push( parm );
    		} else  {
    			return parm;
    		}
    		var type = typeof parm;
    		var str = '';
    		if( parm === null ) {
    			str += 'null';
    		} else if( type === 'undefined' ) {
    			str += 'undefined';
    		} else if( type === 'string' ) {
    			str += trim_str( parm );
    		} else if( type === 'boolean' ) {
    			str += (parm ? 'true' : 'false');
    		} else if( type === 'function' ) {
    			str += 'function ' + parm.name + '()';
    		} else if( parm instanceof Script ) {
    			str += '[Script "' + parm.name + '" version ' + parm.version + ']';
    		} else if( parm instanceof Vector3D ) {
    			str += 'Vector3D: (' + parm.x.toFixed() + ', '
    					+ parm.y.toFixed() + ', ' + parm.z.toFixed() + ')';
    		} else if( parm instanceof Quaternion ) {
    			str += 'Quaternion: (' + parm.w.toFixed() + ' + ' + parm.x.toFixed() + 'idx + '
    					+ parm.y.toFixed() + 'j + ' + parm.z.toFixed() + 'k)';
    		} else if( Array.isArray( parm ) ) {
    			str += showComplex( parm, recurse <= 1 ? 0 : recurse - 1 );
    		} else if( type === 'object' && parm ) {
    			str += showComplex( parm, recurse <= 1 ? 0 : recurse - 1 );
    		} else {
    			str += rptType( parm );
    		}
    		parents.pop();
    		return str;
    	}
    
    	var funcProps = {};
    	function propsNotName( obj ) {
    		if( typeof obj !== 'function' ) return 0;	// backwards compatibity
    		for( let key in funcProps ) {				// reset object
    			if( funcProps.hasOwnProperty( key ) )
    				delete funcProps[ key ];
    		}
    		for( let key in obj ) {
    			if( key !== 'name' && obj.hasOwnProperty( key ) )
    				funcProps[ key ] = obj[ key ];
    		}
    		return Object.keys( funcProps ).length;
    	}
    
    	var parmsLabel = '\n    parameters: ';
    	var indentLen = parmsLabel.length - 1;	// -1 for \n
    	var fnName = typeof functn === 'function' ? functn.name : functn; // backwards compatibity
    	var rpt, parmMax, propMax,
    		bonus = Array.isArray( parms ) ? 1 : 0;			// don't count parms being an array as recursion (+ 1)
    	if( Array.isArray( depth ) ) {
    		parmMax = (depth.length > 0 && typeof depth[ 0 ] === 'number' ? ~~(depth[ 0 ]) : 1) + bonus;
    		propMax = (depth.length > 1 && typeof depth[ 1 ] === 'number' ? ~~(depth[ 1 ]) : 1) + bonus;
    	} else {
    		parmMax = propMax = (typeof depth === 'number' ? ~~(depth) : 1) + bonus;
    	}
    	if( err instanceof Error ) {
    		rpt = '\nfunction ' + fnName + '() \t caught: \t' + err.name + ': ' + err.message;
    	} else {		// for thrown strings (user defined errors)
    		rpt = '\nfunction ' + fnName + '() \t caught: \t' + err;
    	}
    	if( parms ) {
    		rpt += parmsLabel + fmt_parm( parms, parmMax );
    	}
    	if( propsNotName( functn ) ) {
    		parmsLabel = '\n    properties: ';
    		indentLen = parmsLabel.length - 1;	// -1 for \n
    		rpt += parmsLabel + fmt_parm( funcProps, propMax + 1 );	// + 1 as funcProps is an object
    	}
    
    	// err is the stack object with properties: message, fileName, lineNumber, stack, name
    	//  - stack is a long string containing <function call>@<filename>:<line #> separated by
    	//    '\n' for each call in the stack
    	if( err && err.stack ) {
    		var lastFile, parsed, frame, fnCall, args, file, line, pad;
    		var stk = err.stack.split( /[\n\r]+/ ); // split on line breaks
    		for( let idx = 0, len = stk.length; idx < len; idx ++ ) {
    			// stack line format: fn(parms)@../AddOns/.../script.js:123
    			parsed = stk[ idx ].match( /^\s*(\w+)\((.*?)\)@(.*?):(.*?)$/ );
    			if( !parsed || parsed.length < 5 ) break;
    			[frame, fnCall, args, file, line] = parsed;
    			if( file && file !== lastFile ) {	// suppress repeat of same filename
    				if( file.length > FILE_LEN )
    					file = file.slice( file.length - FILE_LEN ) + '...';
    				rpt += '\n    file: ' + file;
    				lastFile = file;
    			}
    			pad = line < 10 ? '   ' : line < 100 ? '  ' : line < 1000 ? ' ' : '' ;
    			rpt += '\n        line: ' + pad + line + ',	';
    			if( fnCall.length > FNAME_LEN ) fnCall = fnCall.slice(0, FNAME_LEN) + '...';
    			if( args.length > ARGS_LEN ) args = args.slice(0, ARGS_LEN) + '...';
    			if( args.length ) 					// add spaces inside function's parenthices
    				args = ' ' + args.replace( /,/g, ', ' ) + ' ';
    			rpt += fnCall + '(' + args + ')';
    		}
    	}
    	return rpt;
    }
    
    ///////////////////////////////////////////////////////////////////////////////
    // station options closure ////////////////////////////////////////////////////
    ///////////////////////////////////////////////////////////////////////////////
    
    
    this.$hudHidden = false;
    this.$optionsStarted = false;
    this.$Remind2Savegame = null;
    
    /*		(function() { // station options IIFE for debugging
    	player.credits += 10000;	// to buy stuff
    	if( worldScripts.logevents ) {
    		delete worldScripts.logevents.guiScreenChanged;
    //		delete worldScripts.logevents.guiScreenWillChange;
    	}
    	worldScripts.telescope.$DebugMessages = false;
    	worldScripts.telescope._reload_config();
    
    })()	// */
    
    /*		(function() { // station options IIFE for reloading new closure code
    	console.clearConsole();
    	var ws = worldScripts.telescope;
    	var so = worldScripts.station_options;
    	delete so._setInterfaces; // so closure will be re-invoked
    
    	// so.$O_initStationOptions( ws, 'telescope_', ws._stnOptionsAllowed, true, ws._reloadFromStn ); // , true	, 'summary'	, 'autosave'
        ws._startStationOptions();
    
    // for testing multiple oxps
    //var fs = worldScripts.fps_monitor;
    //so.$O_initStationOptions( fs, 'fps_monitor_', null, false, fs._reload_options );
    
    	so._setInterfaces();
    
    	// player.ship.hudHidden = false;
    	so.$optionsStarted = false;
    
    })()	// */
    
    // ^gui screen.*?[\w\s]+(?=[\n]^[^g])
    // : ' +  + '
    
    ///
    this._options_closure = function _options_closure() {
    	var so = worldScripts.station_options;
    
    var cd = worldScripts.cagsdebug; /// disable B4publish
    
    	/*
    	 *	constants
    	 */
    	var ARROW = '  ->  ',			// these are for summary pages
    		COMMA = ',  ', COLON = ':';
    	var PUNCTUATION = ',./<>?;\':"[]{}-=_+!@#$%^&*';
    	// from the wiki:
    	//	 "There are 21 lines available for display of the message and choices combined.
    	//	  (In 1.77, this is extended to 27 lines if the player's HUD is hidden)"
    	// - we always hide the HUD
    //	  var MAXSCREENLINES = oolite.compareVersion( '1.77' ) > 0 ? 21 : 27; // 21 for versions < 1.77
    	var MAXSCREENLINES = 27; 		// 21 for versions < 1.77 but we require min ver 1.79
    	var MAXSCREENWIDTH = 32.04;		// in em's, max length for text, oolite is a little forgiving
    	var SO_PREFIX = '$O_';			// need a unique prefix for station_options
    
    	/*
    	 *	system functions
    	 */
    	var floor = Math.floor, pow = Math.pow, abs = Math.abs,
    		strFontLen = defaultFont.measureString,
    		SpaceLen = strFontLen( ' ' ),
    		gameSettings = oolite.gameSettings;
    
    	/*
    	 *	oxp dependent variables from _initStationOptions
    	 */
    	var hostOxp, keyPrefix, optionsAllowedCallback, callPWSG,
    		notifyCallback, suppressSummary;
    
    	/*
    	 *	oxp dependent variables read/derived from missiontext
    	 */
    	var hostVarPrefix, lowestValue, highestValue, maxPrecision,
    		maxVectorLen, allow_reset, formatting,
    		optionPages = {}, pageLabels = [], optionTabStops = [];
    
    	/*
    	 *	other oxp dependent variables
    	 */
    	var decodeExprn, decodeTerm, // oxp specific regular expressions (see initREs)
    		decodeAssign, decodeFnCall;//  "
    	var registeredOxps = {};	// dictionary of oxp specific variables
    	var optionInfo = {};		// dictionary of options for an oxp
    
    	/*
    	 *	local variables globally available ('glocals')
    	 */
    	var changesMade = {}; 		// dictionary of pending variable changes
    	var currentChoice = {};		// objects for info common to functions
    	var decodedChoice = {};		// - properties include key, type, defawlt, min, max, numBits, selection, error, currently
    	var missionKeys;			// oxp specific map of expansion keys, initialized with defaultMissionKeys
    	var expandKeys = {};		// map of internal expansion keys used for expandText
    	var validOptions = {};		// working map of available options
    	// regular expressions used in more than 1 function; the rest are w/ their function
    	var matchListOfStr = /[^']+(?=(?:'\s*,\s*')|(?:'\s*[,]?\s*$))/g;
    	var matchNumbers = /([+-]?\d+[.]?\d*|\.\d+)/g;
    	var ps, currentScreenIdx;
    
    	/*
    	 *	objects
    	 */
    	var defaultMissionKeys = {
    		stn_optns_clockString: '',
    		stn_optns_curr_system: '',
    		stn_optns_buttons: {},
    		// the rest are oxp dependent
    		stn_optns_page: '',
    		stn_optns_page_num: 0,
    		stn_optns_next_page_num: 0,
    		stn_optns_page_count: 0,
    		stn_optns_option: null,
    		stn_optns_changes_count: 0,
    		stn_optns_changed: {},
    		// : , : , : , : , : , :
    	};
    ///?replace all changesMade w/ missionKeys.stn_optns_changed
    /// - only a single store of changes
    /// - allows oxp to alter
    
    	var infoTemplate = {
    		option: null,			// text of option name
    		optionStr: null,		// option display text; same as .option if rename ('=') not used
    		optionLen: 0,			// defaultFont measure of option name
    		page: null,				// name of page option (currently) resides on
    		type: null,				// one of text, toggle, number, decimal, vector, bitflags, choice
    		selection: null,		// for types bitflags, choice
    		numBits: null,			// for type bitflags
    		allBits: null,			// for type bitflags
    		// these next 3 may not be specified
    		defawlt: null,			// default value
    		min: null,				// range for types number, decimal, bitflags, choice & vector
    		max: null,				// range for types number, decimal, bitflags, choice & vector
    		isConditional: false,	// does option have a _condition in plist
    		isSuppressed: false,	// current state if is conditional on other options
    		condition: null,		// text of any _condition (if no substitutions)
    		controls: null,			// list of options that depend on this ones value
    		relyOnOptions: null,	// list of options this one relies on
    		relyOnHostVars: null,	// list of hostVars this one relies on
    		hasAssign: false,		// does option have a _assign in plist
    		assign: null,			// text of any _assign (if no substitutions)
    		hasExecute: false,		// does option have a _execute in plist
    		execute: null,			// text of any _assign (if no substitutions)
    		currently: null,		// current value of the option
    	};
    
    	///////////////////////////////////////////////////////////////////////////////
    	// exposed functions //////////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    // : ' +  + '
    	function _getReminder4Oxp( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getReminder4Oxp, prefix ) );
    		} else {										// return a copy
    			return getObject( getRemindObj( prefix ) );
    		}
    	}
    
    	function _notRegisteredMsg( caller, prefix ) {
    		var msg = (typeof caller === 'function' ? caller.name : caller);
    		if( !prefix ) {
    			msg += ', missing or invalid prefix in function call: ' + prefix;
    		} else {
    			msg += ', the host oxp "' + prefix
    				+ '" must register before using this function';
    		}
    		return msg;
    	}
    
    	function _setAllowedCallback( prefix, newFunc ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _setAllowedCallback, prefix ) );
    		} else {
    			registeredOxps[ prefix ].optionsAllowedCallback = newFunc;
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				optionsAllowedCallback = newFunc;
    			}
    		}
    	}
    
    	function _getAllowedCallback( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getAllowedCallback, prefix ) );
    		} else {
    			return registeredOxps[ prefix ].optionsAllowedCallback;
    		}
    	}
    
    	function _setCallPWSG( prefix, newBool ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _setCallPWSG, prefix ) );
    		} else {
    			registeredOxps[ prefix ].callPWSG = newBool;
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				callPWSG = newBool;
    			}
    		}
    	}
    
    	function _getCallPWSG( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getCallPWSG, prefix ) );
    		} else {
    			return registeredOxps[ prefix ].callPWSG;
    		}
    	}
    
    	function _setNotifyCallback( prefix, newFunc ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _setNotifyCallback, prefix ) );
    		} else {
    			registeredOxps[ prefix ].notifyCallback = newFunc;
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				notifyCallback = newFunc;
    			}
    		}
    	}
    
    	function _getNotifyCallback( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getNotifyCallback, prefix ) );
    		} else {
    			return registeredOxps[ prefix ].notifyCallback;
    		}
    	}
    
    	function _setSuppressSummary( prefix, newBool ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _setSuppressSummary, prefix ) );
    		} else {
    			registeredOxps[ prefix ].suppressSummary = newBool;
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				suppressSummary = newBool;
    			}
    		}
    	}
    
    	function _getSuppressSummary( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getSuppressSummary, prefix ) );
    		} else {
    			return registeredOxps[ prefix ].suppressSummary;
    		}
    	}
    
    	// create a NEW set of missionKeys by combining newKeys
    	// with defaultMissionKeys
    	// - if you want to preserve existing key, use _updateMissionKeys
    	function _setMissionKeys( prefix, newKeys ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _setMissionKeys, prefix ) );
    		} else {
    			let reg = registeredOxps[ prefix ];
    			freeObject( reg.missionKeys, true ); 		// purge the old set but not objects referenced
    			let defKeys = getObject( defaultMissionKeys );// make a copy of defaultMissionKeys
    			updateMissionKeys( prefix, defKeys, newKeys );// updates defKeys with newKeys
    			reg.missionKeys = defKeys;
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				missionKeys = defKeys;
    			}
    		}
    	}
    
    	// returns a COPY of missionKeys
    	// any changes to it will have no effect until you send it back
    	// using _setMissionKeys or _updateMissionKeys
    	function _getMissionKeys( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _getMissionKeys, prefix ) );
    		} else {										// return a copy
    			return getObject( registeredOxps[ prefix ].missionKeys );
    		}
    	}
    
    	// add/updates the current missionKeys with the values in newKeys
    	function _updateMissionKeys( prefix, newKeys ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, _notRegisteredMsg( _updateMissionKeys, prefix ) );
    		} else {
    			updateMissionKeys( prefix, registeredOxps[ prefix ].missionKeys, newKeys );
    			if( so.$optionsStarted && prefix === keyPrefix ) {
    				// set glocal if oxp's instance is running
    				missionKeys = registeredOxps[ prefix ].missionKeys;
    			}
    		}
    	}
    
    	function updateMissionKeys( prefix, obj, update ) {	// also called by _registerHostOxp
    		var that = updateMissionKeys;
    		var defaults = (that.defaults = that.defaults || Object.keys( defaultMissionKeys ));
    
    		var oxpsMissionKeys = registeredOxps[ prefix ].missionKeys;
    		for( let key in update ) {
    			if( update.hasOwnProperty( key ) ) {
    				// update map preserving defaultMissionKeys
    				if( defaults.indexOf( key ) >= 0 && update[ key ] !== oxpsMissionKeys[ key ] ) {
    					log( so.name, 'updateMissionKeys, key "' + key
    						+ '" belongs to station_options and is read-only; that update will be ignored' );
    				} else {
    					obj[ key ] = update[ key ];
    				}
    			}
    		}
    	}
    
    
    	///////////////////////////////////////////////////////////////////////////////
    	// registration functions /////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    	function _registerHostOxp( oxpRef, prefix, allowedFunc,
    								doPWSG, notifyFunc, noReport, oxpKeys ) {
    
    		function initMissionKeys( keys ) {
    			var mKeys = getObject( defaultMissionKeys );// make a copy of defaultMissionKeys
    			if( keys ) {
    				for( let prop in keys ) {
    					if( keys.hasOwnProperty( prop ) ) {
    						mKeys[ prop ] = keys[ prop ];
    					}
    				}
    			}
    			return mKeys;
    		}
    
    		registerFailures.length = 0;
    		_resetLocalVars();
    		try{
    			if( registeredOxps.hasOwnProperty( prefix ) ) {	  // already registered!
    				// only happens (?) when reloading entire host script into debugger, update map
    				log( so.name, '_registerHostOxp, WARNING: missiontext prefix already registered: ' + prefix );
    				registeredOxps[ prefix ].hostOxp = oxpRef;
    				registeredOxps[ prefix ].optionsAllowedCallback = allowedFunc || null;
    				registeredOxps[ prefix ].callPWSG = doPWSG === undefined ? false : doPWSG;
    				registeredOxps[ prefix ].notifyCallback = notifyFunc || null;
    				registeredOxps[ prefix ].suppressSummary = noReport === undefined ? false : noReport;
    				registeredOxps[ prefix ].missionKeys = initMissionKeys( oxpKeys );
    				return false;
    			}
    			var title, summary, category;				// options required for interface
    			missionKeys = initMissionKeys( oxpKeys ); 	// needs to be set for expandText
    			keyPrefix = prefix;
    			title = expandText( prefix + 'interface_title' );
    			if( !title || title.length === 0 )
    				registerFailures.push( prefix + 'interface_title' );
    			summary = expandText( prefix + 'interface_summary' );
    			if( !summary || summary.length === 0 )
    				registerFailures.push( prefix + 'interface_summary' );
    			category = expandText( prefix + 'interface_category' );
    			if( !category || category.length === 0 )
    				registerFailures.push( prefix + 'interface_category' );
    			if( registerFailures.length > 0 ) {
    				log( so.name, '_registerHostOxp, ERROR registering "' + prefix
    					+ '", failed to expand: ' + registerFailures.join(', ') );
    				return false;
    			}
    			registeredOxps[ prefix ] = {
    					hostOxp: oxpRef,
    					optionsAllowedCallback: allowedFunc || null,
    					callPWSG: doPWSG === undefined ? false : doPWSG,
    					notifyCallback: notifyFunc || null,
    					suppressSummary: noReport === undefined ? false : noReport,
    					missionKeys: missionKeys,
    					interface_title: title,
    					interface_summary: summary,
    					interface_category: category,
    					callback: function _initOptions() { initOptions( prefix ) }
    				};
    			// calling to check for errors at registration, not first usage
    			updatePageData( prefix, true );			// true => reportOnly
    			let verdict = updateRegistry( prefix );
    			_purgepools();
    			return verdict;
    		} catch( err ) {
    			log( so.name, so._reportError( err, _registerHostOxp,
    						[ oxpRef, prefix, allowedFunc,
    						  doPWSG, notifyFunc, noReport, oxpKeys ] ) );
    			throw err;
    		}
    	}
    
    	function _resetLocalVars() {							// clear any data from previous user/session
    		// values from _initStationOptions
    		hostOxp = keyPrefix = optionsAllowedCallback = callPWSG = notifyCallback = suppressSummary = undefined;
    		// values from missiontext
    		hostVarPrefix = lowestValue = highestValue = maxPrecision = maxVectorLen = allow_reset = formatting = undefined;
    		// derived values
    		decodeExprn = decodeTerm = decodeAssign = decodeFnCall = undefined;
    		// working objects
    		clearObject( changesMade, true );				// true => keepObjects (ie. del prop only, not what it references) as sent to hostOxp
    		if( missionKeys && missionKeys.hasOwnProperty( 'stn_optns_changed' ) ) {
    			clearObject( missionKeys.stn_optns_changed );// clear previous changes
    		}// do not clear missionKeys object as stored in registeredOxps
    		clearObject( currentChoice );
    		clearObject( decodedChoice );
    		clearObject( expandKeys );
    		clearObject( validOptions );
    		clearObject( optionPages );
    		clearObject( optionInfo );
    		missionKeys = undefined;
    		// working arrays
    		allOptions.length = 0;
    		pageLabels.length = 0;
    		optionTabStops.length = 0;
    		// local variables
    		currentScreenIdx = undefined;
    		ps = player && player.ship;
    	}
    
    	function initOptions( prefix ) {
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, 'initOptions, missiontext prefix NOT registered: ' + prefix );
    			return;
    		}
    		if( guiScreen != "GUI_SCREEN_INTERFACES"  ) {
    			return;
    		}
    		_resetLocalVars();
    		keyPrefix = prefix;
    		// missionKeys must be initialized before any calls to expandText()
    		missionKeys = registeredOxps[ prefix ].missionKeys;
    		// initialize session
    		so.$optionsStarted = true;
    		so.$hudHidden = ps.hudHidden; 					// save HUD state
    		ps.hudHidden = true;
    		// from oolite JS ref: hudAllowsBigGui
    		//	"Whether the HUD allows a "big GUI" or not. Most useful for determining
    		//	 whether mission screens will have 21 or 27 lines.
    		//	 If hudHidden is true this will also be true, even if the HUD which is
    		//	 hidden would not normally allow a big GUI."
    		MAXSCREENLINES = ps.hudAllowsBigGui ? 27 : 21;
    		currentChoice.option = null;
    		scroller.reset();
    		displayOptions();
    	}
    
    // : ' +  + '
    	var allOptions = [];
    	function updatePageData( reportOnly ) {				// reportOnly used at registration to not return on errors
    		hostOxp = registeredOxps[ keyPrefix ].hostOxp;
    		hostVarPrefix = expandText( keyPrefix + 'hostVarPrefix' );
    		if( !hostVarPrefix || hostVarPrefix.length === 0 ) {
    			registerFailures.push( keyPrefix + 'hostVarPrefix' );
    		} else {
    			initREs( hostVarPrefix );
    		}
    		var optionPagesStr = expandText( keyPrefix + 'optionPages' );
    		if( !optionPagesStr || optionPagesStr.length === 0 ) {
    			registerFailures.push( keyPrefix + 'optionPages' );
    		} else {
    			let pages = optionPagesStr.match( matchListOfStr );
    			if( pages === null ) {
    				log(so.name, 'updatePageData, error parsing "' + keyPrefix + '_optionPages" key '
    					+ ', expandText() returned "' + optionPagesStr + '"' );
    				if( !reportOnly )
    					log(so.name, '\tError is critical, aborting.');
    				if( registerFailures.length )
    					log(so.name, 'also failed to expand: ' + registerFailures.join(', ') );
    				if( !reportOnly ) {
    					return false;
    				}
    			}
    			// save page labels, as object property order is indeterminate
    			pageLabels.length = 0;
    			let pageCount = parsePages( pages );
    			if( pageCount === 0 ) {
    				log(so.name, 'updatePageData, for missiontext keyPrefix "' + keyPrefix
    					+ '" error parsing pages: "' + pages + '"' );
    				if( !reportOnly )
    					log(so.name, '\tError is critical, aborting.');
    				if( registerFailures.length )
    					log(so.name, 'also failed to expand: ' + registerFailures.join(', ') );
    				if( !reportOnly ) {
    					return false;
    				}
    			}
    			// populate optionInfo even on registration (!reportOnly) for immediate
    			// error detection ie. don't have to F4 and open page(s) to find any errors
    			createOptionInfo( pages, allOptions );
    		}
    		return true;
    	}
    
    // : ' +  + '
    	function initREs( prefix ) {						// oxp specific re's
    		// no lookbehinds in js
    		var reQuoted = "(?:'([^'\\\\]*(?:\\\\'[^'\\\\]*)*)')";
    		// - allows escaped single quotes
    		// - uses Friedl's: "unrolling-the-loop" technique
    		//   ie. normal* (special normal*)* where special is an escaped single quote
    		var reDecimal = '([+-]?\\d*[.]\\d+|[+-]?\\d+[.]\\d*)';
    		var reInteger = '([+-]?\\d+)';
    		var reVector = '(\\[[^[]*\\])';
    		var reOptionOrHost = '[!\\s*]{0,}[' + prefix + ']?\\w+';
    		var reOperators = '([=!<>]+)';
    		var exprnTerm = '(' + reQuoted + '|' + reVector + '|' + reDecimal + '|' + reInteger
    						+ '|' + reOptionOrHost + ')';
    		decodeTerm = new RegExp( '(\\s*)' + exprnTerm, 'g' );	// see createOptionInfo
    		// (\s*)((?:'([^'\\]*(?:\\'[^'\\]*)*)')|(\[[^[]*\])|([+-]?\d*[.]\d+|[+-]?\d+[.]\d*)|([+-]?\d+)|[!\s*]{0,}[$]?\w+)
    		/*	match indices
    				[0] entire matched string
    				[1] leading whitespace
    				[2] the left term
    				[3] unquoted version of [1] if quoted
    				[4] a vector if [1] is one
    				[5] a decimal if [1] is one
    				[6] an integer if [1] is one
    		*/
    		decodeExprn = new RegExp( '(?:[()\\s]*)' + exprnTerm + '(?:\\s*' + reOperators + '\\s*'
    									+ exprnTerm + ')*(?:[()\\s]*)', 'g' );
    		// (?:[()\s]*)((?:'([^'\\]*(?:\\'[^'\\]*)*)')|(\[[^[]*\])|([+-]?\d*[.]\d+|[+-]?\d+[.]\d*)|([+-]?\d+)|[!\s*]{0,}[$]?\w+)(?:\s*([=!<>]+)\s*((?:'([^'\\]*(?:\\'[^'\\]*)*)')|(\[[^[]*\])|([+-]?\d*[.]\d+|[+-]?\d+[.]\d*)|([+-]?\d+)|[!\s*]{0,}[$]?\w+))*(?:[()\s]*)		// - expression can be a exprnTerm OR exprnTerm op exprnTerm
    		/*	match indices
    				[0] entire matched string
    				[1] leading whitespace
    				[2] the left term
    				[3] unquoted version of [1] if quoted
    				[4] a vector if [1] is one
    				[5] a decimal if [1] is one
    				[6] an integer if [1] is one
    			NB: the rest can be undefined if there is no comparison operator
    				[6] comparison operator
    				[7] leading whitespace
    				[8] the right term
    				[9] unquoted version of [7] if quoted
    			   [10] a vector if [7] is one
    			   [11] a decimal if [7] is one
    			   [12] an integer if [7] is one
    		*/
    		decodeAssign = new RegExp( '\\s*([' + prefix + ']?\\w+)\\s*[=](?!=)\\s*([^;\\r\\n]+)(?:[;\\s]|$)*', 'g' );
    		// \s*([$]?\w+)\s*[=](?!=)\s*([^;\r\n]+)(?:[;\s]|$)*
    		/*
    				[0] entire matched string
    				[1] assignment target (option or hostVar)
    				[2] expression
    		*/
    		//												   NB: SO_PREFIX = '$O_'
    		decodeFnCall = new RegExp( '(\\s*)(?:(worldScripts[.]\\w+)[.](\\w+)|([$]O_\\w+)|((?:[' + prefix
    						+ ']|[$_]+)\\w+))([(].*(?=[)][^)]*(?:;|$))[)])(\\s*)(?:[;\\s]|$)*', 'g' );
    		// (\s*)(?:(worldScripts[.]\w+)[.](\w+)|([$]O_\w+)|((?:[$]|[$_]+)\w+))([(].*(?=[)][^)]*(?:;|$))[)])(\s*)(?:[;\s]|$)*
    		/*
    				[0] entire matched string
    				[1] leading whitespace
    				[2] hostOxp name, eg. worldScripts.telescope
    				[3] function name following [1]
    				[4] function name of a station_options exposed function call
    				[5] function name of an hostOxp function call, ie. starts with hostVarPrefix
    				[6] function parameters
    				[7] trailing whitespace
    		*/
    	}
    
    	function parsePages( pages ) {
    		var pageCount = 0;
    		for( let idx = 0, len = pages.length; idx < len; idx++ ) {
    			let page = pages[ idx ];
    			pageLabels.push( page );
    			let optnStr = expandText( keyPrefix + page + '_options' );
    			if( optnStr ) {
    				pageCount++;
    				let options = optnStr.match( matchListOfStr );
    				if( options ) {
    					parseOptionList( page, options );
    				} else {
    					log(so.name, 'parsePages, error parsing "_options" value: ' + optnStr
    						+ '\n  page = ' + page + ', pageCount = ' + pageCount + ', pageLabels = ' + pageLabels 
    						+ '\n  expandMissionText called w/ "' + (keyPrefix + page + '_options') + '"' );
    				}
    			} else {
    				log(so.name, 'parsePages, missing "_options" key, page = ' + page
    					+ ', pageLabels = ' + pageLabels + ', pageCount = ' + pageCount
    					+ '\n  expandMissionText called w/ "' + (keyPrefix + page + '_options') + '"' );
    			}
    		}
    		return pageCount;
    	}
    
    	function parseOptionList( page, optionsList ) {
    		var that = parseOptionList;
    		var optionVars = (that.optionVars = that.optionVars || []);
    		optionVars.length = 0;
    		
    		for( let idx = 0, len = optionsList.length; idx < len; idx++ ) {
    			let varName = parseOption( page, optionsList[ idx ] );
    			if( varName ) 								// parseOption reports errors
    				optionVars.push( varName );
    		}
    		if( !optionPages.hasOwnProperty( page ) ) {
    			optionPages[ page ] = getArray();
    		} else {
    			optionPages[ page ].length = 0;
    		}
    		let dest = optionPages[ page ];
    		dest.push.apply( dest, optionVars );
    		// - this adds each element of 'optionVars' to the 'dest' array,
    		//   vs. dest = dest.concat( optionVars );
    		//   - concat returns a NEW array (more garbage)
    		allOptions.push.apply( allOptions, optionVars );
    	}
    
    	function parseOption( page, option ) {
    		var varName, optString;
    		// remove any option strings (vs. variables) from page's '_options'
    		if( option.indexOf( ':' ) >= 0 ) {
    			let names = option.split( ':' );
    			if( names.length !== 2 ) {
    				let msg =  'parseOption, ERROR: invalid option renaming "' + option + '"';
    				msg += '\n    the correct format is variableName:optionName, ';
    				msg += '\n    where optionName appears on option the pages';
    				msg += '\n    and worldScripts.myOxp[ hostVarPrefix + variableName ] ';
    				msg += '\n    is assigned the value.';
    				log( so.name, msg );
    				return;
    			}
    			varName = names[ 0 ].trim();
    			optString = names[ 1 ].trim();
    		} else {
    			optString = varName = option.trim();
    		}
    		if( expandText( keyPrefix + varName ) === null ) {
    			log( so.name, 'parseOption, ERROR: missing expansion option "' 
    				+  ( keyPrefix + varName )
    				+ '"; should be in missiontext.plist or missionKeys' );
    			return;
    		}
    		makeInfo( page, optString, varName );
    		return varName;
    	}
    
    	function makeInfo( page, optString, varName ) {
    		try {
    			if( expandText( keyPrefix + varName ) === null ) {
    				log( so.name, 'makeInfo, ERROR: missing expansion option "' 
    					+  ( keyPrefix + varName )
    					+ '"; should be in missiontext.plist or missionKeys' );
    				return;
    			}
    			var result = getObject( infoTemplate );
    			optionInfo[ varName ] = result;
    			result.option = varName;
    			result.optionStr = optString;
    			result.optionLen = measureWord( optString );
    			result.page = page;
    			result.controls = getArray();
    			result.relyOnOptions = getArray();
    			result.relyOnHostVars = getArray();
    			decodeOption( varName, result );
    			result.currently = getOptionValue( varName );
    		} catch( err ) {
    			log( so.name, so._reportError( err, makeInfo, [page, optString, varName] ) );
    			throw err;
    		}
    	}
    
    	function createOptionInfo( pages, allOptions ) {
    
    		function addToList( value, list ) {
    			 if( list && list.indexOf( value ) < 0 ) {
    				 list.push( value );
    			 }
    		}
    
    		function addToValueList( option, value, info, isHostVar ) {
    			if( isHostVar ) {
    				addToList( value, info.relyOnHostVars );
    			} else {
    				addToList( value, info.relyOnOptions );
    				addToList( option, optionInfo[ value ].controls );
    				// - option that dictates whether other options are shown
    			}
    		}
    
    		try{
    			refreshSOKeys();
    			var foundOne, value, matchStr, leadWS, term, unQuoted, aVector, aDecimal, anInt;
    			// scan '_condition' for options & hostVars
    			for( let idx = 0, len = pages.length; idx < len; idx++ ) {
    				let ignore, nextTerm, page = pages[ idx ];
    				let options = optionPages[ page ];
    				for( let opt = 0, optLen = options.length; opt < optLen; opt++ ) {
    					let option = options[ opt ];
    					let info = optionInfo[ option ];
    					let condition = expandText( keyPrefix + option + '_condition' );
    					if( condition === null )
    						continue;
    					info.isConditional = true;
    					foundOne = false;
    					decodeTerm.lastIndex = 0;			// reset re's .lastIndex as it doesn't reset on a new string!!!!
    					while( true ) {
    						nextTerm = decodeTerm.exec( condition );
    /* debugging regex
    if( nextTerm !== null ) {
    	log('\n match found using \n' + decodeTerm + '\n  upon \n' + condition + '\nnextTerm:\n' );
    	let mNames = [ 'matchStr', 'leading', 'term', 'unQuoted', 'aVector', 'aDecimal', 'anInt',
    				  'operator', 'leading', 'rHandSide', 'rUnQuoted', 'rVector', 'rDecimal', 'rInteger' ]
    	for( let x = 0, len = nextTerm.length; x < len; x++ ) {
    		log(mNames[x] + '\t\t' + (x < 10 ? ' ' + x : x) + ': ' + nextTerm[x] );
    	}
    }
    */
    						if( nextTerm === null ) {
    							if( foundOne )
    								break;
    							throw( 'createOptionInfo, "' + option + '", decodeTerm cannot decode condition \n\t"'
    										+ condition + '"\n\tusing: \n\t' + decodeTerm );
    						}
    						[ matchStr, leadWS, term, unQuoted, aVector, aDecimal, anInt ] = nextTerm;
    						if( term.length > 0 ) {
    							[ value, ignore ] = splitExclamations( term );
    						} else {
    							throw( 'createOptionInfo, decodeTerm match produced empty capture: ' + nextTerm );
    						}
    						freeArray( nextTerm );
    						let isHostVar = startsWith( value, hostVarPrefix ),
    							isOption = allOptions.indexOf( value ) >= 0;
    						if( !isHostVar && !isOption )  // ignore true, false, null, etc
    							continue;
    						foundOne = true;
    						addToValueList( option, value, info, isHostVar );
    					}
    				}
    			}
    /// _showProps( obj, objName, newLine, show_deep, expand_arrays, show_type )
    // log('createOptionInfo, exit' + cd._showProps(optionInfo, 'optionInfo', 1,2,1));
    		} catch( err ) {
    			log( so.name, so._reportError(err, createOptionInfo, [pages, allOptions]));
    			throw err;
    		}
    	}
    
    // : ' +  + '
    	var registerFailures = [];
    	function updateRegistry( prefix ) {					// called upon activation of Interface (F4) screen
    		// had to split into 2 parts as refreshSOKeys() must precede this but follow updatePageData()
    		registerFailures.length = 0;
    
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name,
    				'updateRegistry, missiontext prefix NOT registered: "' + prefix  + '"' );
    			return false;
    		}
    		var host = registeredOxps[ prefix ];
    		hostOxp = host.hostOxp;
    		// these are alterable vis exposed functions, so registeredOxps is up to date
    		optionsAllowedCallback = host.optionsAllowedCallback;
    		callPWSG = host.callPWSG;
    		notifyCallback = host.notifyCallback;
    		suppressSummary = host.suppressSummary;
    		// missionKeys set in initOptions
    
    		// as all missiontext keys may be overridden, update related effects
    		// (hostVarPrefix is in updatePageData())
    		lowestValue = expandText( prefix + 'lowestValue' );	// is optional, no failure msg
    		if( !lowestValue || lowestValue.length === 0 ) {
    			lowestValue = null;							// value when turned off or not present
    		}
    		highestValue = expandText( prefix + 'highestValue' );	// is optional, no failure msg
    		if( !highestValue || highestValue.length === 0 ) {
    			highestValue = null;						// value when turned off or not present
    		}
    		maxPrecision = expandText( prefix + 'maxPrecision' );
    		if( !maxPrecision || maxPrecision.length === 0 ) {
    			maxPrecision = 0;							// default value, no failure msg
    		}
    		maxVectorLen = expandText( prefix + 'maxVectorLen' );
    		if( !maxVectorLen || maxVectorLen.length === 0 ) {
    			maxVectorLen = 0;							// default value, no failure msg
    		}
    		allow_reset = expandText( prefix + 'allow_reset_of_page' );
    		if( !allow_reset || allow_reset.length === 0 ) {
    			allow_reset = false;						// default value, no failure msg
    		} else {
    			allow_reset = allow_reset === 'yes' || allow_reset === 'true' ? true : false;
    		}
    		formatting = expandText( prefix + 'formatting_of_page' );
    		if( !formatting || formatting.length === 0 ) {
    			formatting = false;							// default value, no failure msg
    		} else {
    			formatting = formatting === 'yes' || formatting === 'true' ? true : false;
    		}
    
    		let tabStopsStr = expandText( prefix + 'optionTabStops' );
    		if( !tabStopsStr || tabStopsStr.length === 0 ) {
    			registerFailures.push( prefix + 'optionTabStops' );
    		} else {
    			optionTabStops.length = 0;
    			if( !parseTabStops( tabStopsStr ) )
    				registerFailures.push( prefix + 'optionTabStops' );
    		}
    
    		let interfaceChanged = false;
    		let title = checkOverride( prefix, 'interface_title' );
    		if( !title || title.length === 0 ) {
    			registerFailures.push( prefix + 'interface_title' );
    		} else {
    			interfaceChanged = interfaceChanged || title !== host.title;
    			host.title = title;
    		}
    		let summary = checkOverride( prefix, 'interface_summary' );
    		if( !summary || summary.length === 0 ) {
    			registerFailures.push( prefix + 'interface_summary' );
    		} else {
    			interfaceChanged = interfaceChanged || summary !== host.summary;
    			host.summary = summary;
    		}
    		let category = checkOverride( prefix, 'interface_category' );
    		if( !category || category.length === 0 ) {
    			registerFailures.push( prefix + 'interface_category' );
    		} else {
    			interfaceChanged = interfaceChanged || category !== host.category;
    			host.category = category;
    		}
    		if( interfaceChanged ) {
    			setOneInterface( prefix );
    		}
    
    		if( registerFailures.length ) {
    			log( so.name, 'updateRegistry, failed to expand: ' + registerFailures.join(', ') );
    			return false;
    		}
    		return true;
    	}
    
    	var splitTabStops = /[^{]*[{]\s*([^}]*)\s*[}]\s*[,]?\s*/g;
    	function parseTabStops( tabStops ) {
    		var tabStopList = tabStops.split( splitTabStops );
    		if( tabStopList ) {
    			for( let idx = 0, len = tabStopList.length || 0; idx < len; idx++ ) {
    				let tabs = tabStopList[ idx ];
    				if( !tabs || tabs.length === 0 )
    					continue;
    				let lst = tabs.match( matchNumbers );
    				if( lst ) {
    					let lastNum = 0, result = getArray();
    					for( let lx = 0, len = lst.length; lx < len; lx++ ) {
    						let curr = parseFloat( lst[ lx ] );
    						if( curr < 0 || (lastNum > 0 && curr <= lastNum) ) { // allow first to be zero
    							let repl = lastNum + 0.1;
    							log(so.name, 'parseTabStops, bad tab value "' + curr + '" <= "'
    								+ lastNum + '" (previous), using "' + repl );
    							log(so.name, '                (tabs must be increasing in value)' );
    							curr = repl;
    						}
    						result.push( curr );
    						lastNum = curr;
    					}
    					optionTabStops.push( result );
    				} else {
    					log(so.name, 'parseTabStops, invalid numbers in "_optionTabStops" key'
    						+ ', tabs = ' + tabs
    						+ ', expandMissionText called w/ "' + (keyPrefix + 'optionTabStops') + '"' );
    					return false;
    				}
    			}
    		} else {
    			log(so.name, 'parseTabStops, error parsing  "_optionTabStops" key'
    				+ ', tabStops = ' + tabStops
    				+ ', expandMissionText called w/ "' + (keyPrefix + 'optionTabStops') + '"' );
    			return false;
    		}
    		return true;
    	}
    
    	function expandText( textKey, keys ) {
    		var that = expandText;
    		var merged = (that.merged = that.merged || {});	// working map assembled for each text expansion
    
    		if( !textKey || textKey.length === 0 )
    			return null;								// expandMissionText returns null if textKey not present
    
    		var keyMap = missionKeys,
    			result = null;								// expandMissionText returns null if textKey not present
    		if( keys ) {									// create merger of default and oxp supplied keys
    			clearObject( merged, true );				// true => keepObjects (ie. del prop only, not what it references)
    			for( let prop in missionKeys ) {
    				if( missionKeys.hasOwnProperty( prop ) ) {
    					merged[ prop ] = missionKeys[ prop ];
    				}
    			}
    			for( let prop in keys ) {
    				if( keys.hasOwnProperty( prop ) ) {
    					merged[ prop ] = keys[ prop ];
    				}
    			}
    			keyMap = merged;
    		}
    
    		// missionKeys override; if the key is an empty string, it's treated
    		// as if it was deleted
    		var inMissionKeys = missionKeys !== undefined && missionKeys.hasOwnProperty( textKey )
    										&& missionKeys[ textKey ].length > 0;
    		if( inMissionKeys ) {
    			/// need ' || {}' else Oolite crashes
    			result = expandDescription( missionKeys[ textKey ], keyMap || {} );
    		} else {
    			/// need ' || {}' else Oolite crashes
    			result = expandMissionText( textKey, keyMap || {} );
    		}
    		return result;
    	}
    
    	function checkOverride( prefix, prop ) {
    		// values from missiontext can be overridden using missionKeys
    		// while those passed at registration are altered via the exposed
    		// functions (ie. the registeredOxps[ prefix ].prop value is always current)
    		// - the two sets of values may overlap (eg. interface_title)
    		var result = null, 
    			host = registeredOxps[ prefix ],
    			mKey = prefix + prop,
    			check = expandText( mKey );					// respects missionKeys
    		if( check !== null ) {
    			result = check;
    		} else if( host.hasOwnProperty( prop ) ) {
    			result = host[ prop ];
    		}
    		if( missionKeys.hasOwnProperty( mKey ) && check === null ) {
    			let msg = 'checkOverride, missionKeys override "' + mKey + '":\n\t';
    			msg += missionKeys[ mKey ] + '\n\texpansion failed';
    			if( result ) {
    				msg += ', reverting to registered value "' + result + '"';
    			} else {
    				msg += ', using default (if it exists) otherwise null';
    			}
    			log( so.name, msg );
    		}
    		return result;
    	}
    
    // : ' +  + '
    	function refreshSOKeys( soKeys ) {
    		// called from createOptionInfo, initOptions, nextOptionsPage, processChoice, setChoice, setOptionValue
    		var that = refreshSOKeys;
    		var mkKeyProps = (that.mkKeyProps = that.mkKeyProps || []);
    		mkKeyProps.length = 0;
    		
    		function refreshSOPageKeys( index ) {
    			var numPages = pageLabels.length;
    			missionKeys.stn_optns_page_count = numPages;
    			if( numPages > 0 && index >= 0 && index < numPages) {
    				missionKeys.stn_optns_page = pageLabels[ index ];
    				missionKeys.stn_optns_page_num = index + 1;
    				missionKeys.stn_optns_next_page_num = (( index + 1 ) % ( numPages || 1 )) + 1;
    			} else {
    				missionKeys.stn_optns_page = '';
    				missionKeys.stn_optns_page_num = 0;
    				missionKeys.stn_optns_next_page_num = 0;
    			}
    		}
    
    		if( missionKeys === undefined ) {
    			log('refreshSOKeys, ERROR: missionKeys === undefined' );
    			return;
    		}
    		missionKeys.stn_optns_clockString = clock.clockStringForTime( clock.adjustedSeconds );
    		missionKeys.stn_optns_curr_system = system.name;
    		// the rest are oxp dependent
    		if( soKeys && soKeys.hasOwnProperty( 'index' ) ) {
    			refreshSOPageKeys( soKeys.index );
    		} else if( pageLabels.length > 0 && currentScreenIdx < pageLabels.length ) {
    			refreshSOPageKeys( currentScreenIdx );
    		} else {
    			refreshSOPageKeys( 0 );
    		}
    		// missionKeys.stn_optns_option = ??? - handled in processChoice
    		missionKeys.stn_optns_changes_count = changesMade.hasOwnProperty( 'stnOptsPending' )
    												? changesMade.stnOptsPending : 0;
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// formatting functions ///////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    	function splitExclamations( term ) {
    		var count = 0;
    		if( term[ 0 ] === '!' ) {
    			// count & strip all leading exclamation marks
    			let idx, len;
    			for( idx = 0, len = term.length; idx < len; idx++ ) {
    				let it = term[ idx ];
    				if( it === '!' ) {
    					count++;
    				} else if( it !== ' ' && it !== '\t' ) {
    					break;
    				}
    			}
    			term = term.slice( idx );
    		}
    		return [ term, count ];
    	}
    
    	function startsWith( term, prefix ) { 				// String.startsWith not in our JS
    		return term.slice( 0, prefix.length ) === prefix;
    	}
    
    	function endsWith( term, suffix ) { 				// String.endsWith not in our JS
    		return term.slice( -suffix.length ) === suffix;
    	}
    
    	function setOneInterface( prefix ) {
    		var that = setOneInterface;
    		var interfaceDefn = (that.interfaceDefn = that.interfaceDefn || {});
    		clearObject( interfaceDefn );
    
    		if( !registeredOxps.hasOwnProperty( prefix ) ) {
    			log( so.name, 'setOneInterface, missing for prefix "' + prefix + '", aborting' );
    			return;
    		}
    		var host = registeredOxps[ prefix ];
    		var checkPermission = host.optionsAllowedCallback;
    		var allowed = checkPermission && typeof checkPermission === 'function'
    					  ? checkPermission() : true;
    		if( allowed ) {									// turn it on in F4 screen
    			interfaceDefn.title = host.interface_title;
    			interfaceDefn.summary = host.interface_summary;
    			interfaceDefn.category = host.interface_category;
    			interfaceDefn.callback = host.callback.bind( so );
    			if( ps.dockedStation )						// is null when reloading via console & not docked!
    				ps.dockedStation.setInterface( prefix, interfaceDefn );
    		} else {										// ensures it's off
    			if( ps.dockedStation )
    				ps.dockedStation.setInterface( prefix, null );
    		}
    	}
    
    	function _setInterfaces() {							// called from equipmentAdded, equipmentAdded, shipDockedWithStation, etc.,
    														//   to start up interface(s)
    		ps = player && player.ship;
    		for( var prefix in registeredOxps ) {			// set/clear interface based on callback
    			if( registeredOxps.hasOwnProperty( prefix ) ) {
    				setOneInterface( prefix );
    			} else {
    				log( so.name, '_setInterfaces, missing  for prefix "' + prefix + '", skipping' );
    			}
    		}
    	}
    
    	function nextOptionsPage() { 						// find next array of options using currentScreenIdx
    		var that = nextOptionsPage;
    		var soKeys = (that.soKeys = that.soKeys || {});
    		clearObject( soKeys );
    
    		var numPages = pageLabels.length;
    		currentScreenIdx = ( currentScreenIdx + 1 ) % numPages;
    		soKeys.index = currentScreenIdx;
    		refreshSOKeys( soKeys );
    		// check range in case pageLabels was changed
    		return currentScreenIdx >= 0 && currentScreenIdx < numPages
    				? pageLabels[ currentScreenIdx ] : null;
    	}
    
    // : ' +  + '
    	function displayOptions( via ) { 					// optional 'via' for choice that got us here
    		var that = displayOptions;
    		var screen = (that.screen = that.screen || {}); // re-used for runScreen; kept local to fn as static keys retained
    		var summary, screenLines;
    
    		// moved from initOptions to allow dynamic pages
    		if( !updatePageData() )
    			return;
    		if( currentScreenIdx === undefined )			// got here via initOptions
    			currentScreenIdx = 0;
    		refreshSOKeys();	/// relies on pageLabels, which is set in updatePageData
    		if( !updateRegistry( keyPrefix ) )
    			return;
    
    		var currentScreen = pageLabels[ currentScreenIdx ];
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) { 	// 1st time through; this part is static
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = true;
    			screen.textEntry = false;
    		}
    		screen.screenID = 'stn_optns_displayOptions_' + keyPrefix + currentScreen + '_page';
    		screen.titleKey = keyPrefix + currentScreen + '_title';
    		summary = expandText( keyPrefix + currentScreen + '_summary' );
    		if( !summary ) {
    			log( so.name, so._reportError('missing or invalid "' + keyPrefix + currentScreen
    				+ '_summary" in missiontext.plist'));
    			return
    		}
    		let lines = measureText( currentScreen + '_summary', summary );
    		if( formatting ) {
    			formattedPage.length = 0;
    			let textInfo = textIndices[ keyPrefix ][ currentScreen + '_summary' ];
    			formatEols( summary, textInfo, 0, lines );
    			screen.message = formattedPage.join( '\n' );
    		} else {
    			screen.message = summary;
    		}
    		screenLines = MAXSCREENLINES - lines;  			// # lines left after summary
    		screen.allowInterrupt = changesMade.cag$stnOptsPending === 0;
    		screen.choices = buildOptions( screenLines, screen, via );
    		mission.runScreen( screen, processChoice.bind( so ) );
    	}
    
    	// buttons shared by options list and sub-pages
    	// - the last button on sub-pages, "Return to ...", is dynamic and always recycled
    	var blankLine, scrollButton, scrollTopButton, nextPgButton,
    		saveButton, abortButton, exitButton, saveEditButton, resetButton;
    
    	function initButtons() {							// buttons for options list and sub-pages
    		var alignment = expandText( 'stn_optns_button_alignment' );
    		var defaultColor = expandText( 'stn_optns_default_color' );
    
    		if( blankLine === undefined ) {
    			blankLine = getChoice( '' );
    		}
    		if( scrollButton  === undefined ) {
    			scrollButton = getChoice( expandText( 'stn_optns_scroll_down' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_scroll_down' ] = scrollButton;
    		} else {
    			scrollButton.text = expandText( 'stn_optns_scroll_down' );
    			scrollButton.color = expandText( 'stn_optns_scroll_down_button_color' ) || defaultColor;
    			scrollButton.alignment = alignment;
    			scrollButton.unselectable = false;
    		}
    		if( scrollTopButton === undefined ) {
    			scrollTopButton = getChoice( expandText( 'stn_optns_scroll_top' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_scroll_top' ] = scrollTopButton;
    		} else {
    			scrollTopButton.text = expandText( 'stn_optns_scroll_top' );
    			scrollTopButton.color = expandText( 'stn_optns_scroll_top_button_color' ) || defaultColor;
    			scrollTopButton.alignment = alignment;
    			scrollTopButton.unselectable = false;
    		}
    		if( nextPgButton === undefined ) {
    			nextPgButton = getChoice( expandText( 'stn_optns_list_next_page' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_list_next_page' ] = nextPgButton;
    		} else {
    			nextPgButton.text = expandText( 'stn_optns_list_next_page' );
    			nextPgButton.color = expandText( 'stn_optns_list_next_page_button_color' ) || defaultColor;
    			nextPgButton.alignment = alignment;
    			nextPgButton.unselectable = false;
    		}
    		if( saveButton === undefined ) {
    			saveButton = getChoice( expandText( 'stn_optns_list_save' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_list_save' ] = saveButton;
    		} else {
    			saveButton.text = expandText( 'stn_optns_list_save' );
    			saveButton.color = expandText( 'stn_optns_list_save_button_color' ) || defaultColor;
    			saveButton.alignment = alignment;
    			saveButton.unselectable = false;
    		}
    		if( abortButton === undefined ) {
    			abortButton = getChoice( expandText( 'stn_optns_list_abort' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_list_abort' ] = abortButton;
    		} else {
    			abortButton.text = expandText( 'stn_optns_list_abort' );
    			abortButton.color = expandText( 'stn_optns_list_abort_button_color' ) || defaultColor;
    			abortButton.alignment = alignment;
    			abortButton.unselectable = false;
    		}
    		if( exitButton === undefined ) {
    			exitButton = getChoice( expandText( 'stn_optns_list_exit' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_list_exit' ] = exitButton;
    		} else {
    			exitButton.text = expandText( 'stn_optns_list_exit' );
    			exitButton.color = expandText( 'stn_optns_list_exit_button_color' ) || defaultColor;
    			exitButton.alignment = alignment;
    			exitButton.unselectable = false;
    		}
    		if( saveEditButton === undefined ) {
    			saveEditButton = getChoice( expandText( 'stn_optns_option_save' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_option_save' ] = saveEditButton;
    		} else {
    			saveEditButton.text = expandText( 'stn_optns_option_save' );
    			saveEditButton.color = expandText( 'stn_optns_option_save_button_color' ) || defaultColor;
    			saveEditButton.alignment = alignment;
    			saveEditButton.unselectable = false;
    		}
    		if( resetButton === undefined ) {
    			resetButton = getChoice( expandText( 'stn_optns_option_reset' ) );
    			missionKeys.stn_optns_buttons[ 'stn_optns_option_reset' ] = resetButton;
    		} else {
    			resetButton.text = expandText( 'stn_optns_option_reset' );
    			resetButton.color = expandText( 'stn_optns_option_reset_button_color' ) || defaultColor;
    			resetButton.alignment = alignment;
    			resetButton.unselectable = false;
    		}
    	}
    
    	function isCommonButton( key ) { 					// check for static buttons
    		do {
    			if( key === 'UU_blank' )	break;			// blank line for spacing
    			if( key === 'XX_reset' )	break;			// 'Reset to factory default'
    			if( key === 'YY_save' )		break;			// 'Apply changes and exit', color get toggled
    			if( key === 'ZZ_abort' )	break;			// top level only, 'Exit discarding changes'
    			if( key === 'ZZ_exit' )		break;			// top level only, 'Exit (no changes)'
    			if( key === 'WW_next_pg' )	break;			// top level only, 'Switch to next page'
    			if( key === 'ZZ_return' )	return false;	// specific to sub-page, so always recycled
    			// not testing 'VV_more' (top level only) or 'ZZ_more' (sub-page only) as require different handling
    			return false;
    		} while( false );
    		return true;
    	}
    
    	function buildOptions( screenLines, screen, via ) {
    		try {
    			var that = buildOptions;
    			var choices = (that.choices = that.choices || null); // re-used for runScreen; kept local to fn as static keys retained
    			var optionRows = (that.optionRows = that.optionRows || {});
    			var lastStart = that.lastStart || 0;		// index of starting option on page just displayed
    			var lastEnd = that.lastEnd || 0;			// index of last option showing on page just displayed
    			var lastValid = that.lastValid || 0;		// number of valid options on page just displayed
    			var lastHost = that.lastHost || null;
    			if( via === 'WW_next_pg' || via === 'XX_reset' || currentChoice.option === null ) {
    				that.lastStart = that.lastEnd = that.lastValid = 0;
    			}
    
    			var currentScreen = pageLabels[ currentScreenIdx ],
    				options = optionPages[ currentScreen ],
    				start, end, tabs, option,
    				lastValidKey, lastValidShowing = false,
    				len = options.length, numValid = 0,
    				lines = screenLines - 1;				// # lines for list (-1 for blank line before list)
    			var haveChanges = changesMade.hasOwnProperty( 'cag$stnOptsPending' );
    
    			initButtons();
    			if( !choices ) {							// first time, create static choices
    				blankLine.unselectable = true;
    				choices = that.choices = getObject();
    				choices[ 'UU_blank' ] = blankLine;
    				choices[ 'YY_save' ] = saveButton;
    				choices[ 'ZZ_exit' ] = exitButton;
    				lines -= 3;
    			} else {
    				for( var x in choices ) {				// re-use objects
    					if( choices.hasOwnProperty( x ) ) {
    						if( isCommonButton( x ) ) {		// keep, are static
    							lines--;
    						} else if( x === 'VV_more' ) {	// just delete the reference
    							delete choices[ x ];
    						} else {
    							freeChoice( choices[ x ] );
    							delete choices[ x ];
    						}
    					}
    				}
    			}
    
    			if( !lastHost || lastHost !== keyPrefix ) { // being run by a different oxp
    				let hasReset = choices.hasOwnProperty( 'XX_reset' );
    				if( allow_reset && !hasReset ) {
    					choices[ 'XX_reset' ] = resetButton;
    					lines--;
    				} else if( !allow_reset && hasReset ) {
    					delete choices[ 'XX_reset' ];
    					lines++;
    				}
    				let hasNextPg = choices.hasOwnProperty( 'WW_next_pg' );
    				if( pageLabels.length > 1 && !hasNextPg ) {
    					choices[ 'WW_next_pg' ] = nextPgButton;
    					lines--;
    				} else if( pageLabels.length === 1 && hasNextPg ) {
    					delete choices[ 'WW_next_pg' ];
    					lines++;
    				}
    				that.lastHost = keyPrefix;				// so we only do this once, not every call
    			}
    
    			if( haveChanges ) { 						// changes have been made
    				delete choices[ 'ZZ_exit' ];
    				choices[ 'ZZ_abort' ] = abortButton;
    				saveButton.color = expandText( 'stn_optns_default_color' ) || 'yellowColor';
    				saveButton.unselectable = false;
    			} else {
    				delete choices[ 'ZZ_abort' ];
    				choices[ 'ZZ_exit' ] = exitButton;
    				saveButton.color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    				saveButton.unselectable = true;
    			}
    
    			tabs = currentScreenIdx >= optionTabStops.length
    					? null : optionTabStops[ currentScreenIdx ];
    			// insert ALL valid options into 'validOptions'
    			lastValidKey = formatOptions( tabs );
    			numValid = Object.keys( validOptions ).length;
    			if( numValid > lines ) {
    				lines--;	 							// for 'more' button
    			}
    
    			start = lastStart;							// guess page didn't shift
    			end = lastEnd > 0 ? lastEnd
    							  : lastStart + lines - 1;	// -1 so last option starts next page
    			let initChKey = via;
    			if( via === 'WW_next_pg' ) {				// first time on page
    				scroller.reset( numValid );
    				end = (numValid < lines ? numValid : lines) - 1;// 0 indexed
    				start = scroller.curr( numValid, lines, end );
    			} else if( via === 'VV_more' ) {			// scroll page
    				start = scroller.next( numValid, lines );
    				end = start + lines - 1;				// 0 indexed
    			} else if( currentChoice.option === null ) {	// first page of opened F4 interface
    				if( via === undefined ) {
    					start = scroller.curr( numValid, lines, end );
    				}
    				initChKey = haveChanges ? 'YY_save' : 'WW_next_pg';
    			} else {									// back from editing option
    				initChKey = currentChoice.option;
    			}
    			screen.initialChoicesKey = initChKey;
    
    			let delta = lastValid > 0 ? numValid - lastValid : 0;// ?did # options change
    			if( numValid <= lines ) {					// fits on one page
    				end = numValid - 1;						// 0 indexed
    				start = scroller.curr( numValid, lines, end );
    			} else if( delta !== 0 ) {					// number of options has changed (conditionals)
    				end = lastStart + (numValid > lines ? lines : numValid) - 1;
    				// set position so initChKey remains the same, if possible
    				// ie. when sub-options collapse/expand
    				if( delta > 0 ) { 						// option expanded
    					let row = optionRows[ initChKey ];	// option's place in displayed options
    					if( row + delta >= lines - 1 ) { 	// sub-options extend beyond end of page
    						let valid = Object.keys( validOptions );
    						let place = valid.indexOf( initChKey ); // option's place in all valid options
    						end = place + delta;			// shift up to see all sub-options
    					}
    				} else if( delta < 0 ) {				// option collapsed
    					if( lastEnd + delta > numValid - 1 ) {// not enough remaining options to fill to end of page
    						end = numValid - 1 - delta;
    					}
    				}
    				start = scroller.curr( numValid, lines, end );
    			}
    			that.lastStart = start;
    			that.lastEnd = end;
    			that.lastValid = numValid;
    
    			clearObject( optionRows );
    			let skip = 0, 								// # of VALID options skipped before starting
    				count = 0;								// # of VALID options displayed
    			for( let idx = 0; idx < len; idx++ ) {		// set up option buttons
    				option = options[ idx ];
    				if( !validOptions.hasOwnProperty( option ) )
    					continue;
    				if( skip++ < start )					// option has scrolled above top of page
    					continue;
    				lastValidShowing = option === lastValidKey;
    				let prefix = count < 10 ? '0' + count : count;
    				let choiceKey = prefix + '_' + option;
    				let nextOption = getChoice( validOptions[ option ] )
    				// setup nextOption for display
    				nextOption.alignment = 'LEFT';
    				if( optionInfo[ option ].isSuppressed ) {
    					// the only suppressed options that are added to validOptions are
    					// controllers that aren't controlled (ie. top level in dependency)
    					// and those options solely dependent on oxp vars, not options
    					nextOption.color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    				} else if( option === currentChoice.option ) {
    					nextOption.color = expandText( 'stn_optns_lastUsed_color' ) || 'greenColor';
    				} else if( changesMade.hasOwnProperty( option ) ) {
    					nextOption.color = expandText( 'stn_optns_optionChanged_color' ) || 'cyanColor';
    				}
    				if( option === currentChoice.option ) {
    					screen.initialChoicesKey = choiceKey;
    				}
    				optionRows[ option ] = count;
    				choices[ choiceKey ] = nextOption;
    				if( ++count >= lines ) break;
    			}
    			if( numValid > lines ) {					// add 'more' button
    				if( lastValidShowing ) {
    					choices[ 'VV_more' ] = scrollTopButton;
    				} else {
    					choices[ 'VV_more' ] = scrollButton;
    				}
    				if( screen.initialChoicesKey === 'VV_more' ) {
    					choices[ 'VV_more' ].color = expandText( 'stn_optns_lastUsed_color' ) || 'greenColor';
    				}
    			}
    			return choices;
    		} catch( err ) {
    			log( so.name, so._reportError( err, buildOptions, [screenLines, via] ) );
    			throw err;
    		}
    	}
    
    // : ' +  + '
    	function checkCondition( option ) {
    		var that = checkCondition;
    		var invalid = (that.invalid = that.invalid || [ false, 0 ]);
    		var indents = (that.indents = that.indents || []);// .length only array for .join() for dependency markers
    		indents.length = 0;
    
    		var line = '', inserted = 0;
    		var conditionKey = keyPrefix + option + '_condition';
    		var info = optionInfo[ option ];
    		var conditions = expandText( conditionKey );
    		var subsShowing = expandText( 'stn_optns_subOptions_showing' ),
    			subsHidden = expandText( 'stn_optns_subOptions_hidden' ),
    			subsMarker = expandText( 'stn_optns_subOptions_marker' );
    		var showingLen = measureWord( subsShowing ), 
    			hiddenLen = measureWord( subsHidden );
    		info.condition = conditions;
    		if( conditions ) {
    			info.isSuppressed = !conditionMet( option, conditionKey, conditions );
    			do {
    				if( !info.isSuppressed ) break;
    				// don't suppress top level controllers
    				if( info.controls.length > 0 && info.relyOnOptions.length === 0
    											 && info.relyOnHostVars.length === 0)
    					break;
    				// or those that rely solely on hostVars
    				if( info.relyOnHostVars.length !== 0 && info.relyOnOptions.length === 0 )
    					break;
    				return invalid;
    			} while( false );
    		}
    		var indentIt = null;
    		if( info.controls.length > 0 ) {				// prefix option to show whether it controls other options
    			let currVal = getOptionValue( option );
    			let controlStr = currVal ? subsShowing : subsHidden,
    				controlLen = currVal ? showingLen : hiddenLen;
    			line = controlStr;
    			inserted += controlLen;
    			indentIt = false;
    		}
    /* looks too busy -> allow option entry but all buttons disabled
    		if( info.relyOnHostVars.length > 0 ) {
    			// prepend option with '$' to indicate # hostVar dependencies
    			indents.length = info.relyOnHostVars.length + 1;
    			let hostVarStr = indents.join( '$' );
    			line += hostVarStr;							// mark option as hostVar dependent
    			inserted += strFontLen( hostVarStr );
    			indentIt = indentIt !== false ? true : false;
    		}
     */
    		let reliesUpon = info.relyOnOptions;			// list, may be empty
    		let indentStr = '';
    		if( reliesUpon.length > 0 ) {
    			// check all parent options are present
    			let maxCtrlStrLen = 0;
    			for( let idx = 0, len = reliesUpon.length; idx < len; idx++ ) {
    				let parent = reliesUpon[ idx ];
    				if( !startsWith( parent, hostVarPrefix ) && !optionInfo.hasOwnProperty( parent ) ) {
    					log(so.name, 'checkCondition, ERROR, "' + option + '" relies on "'
    						+ parent + '" which is missing!  conditions: ' + conditions );
    					continue;
    				}
    				// set indent to size of parent's prefix
    				let pHidden = optionInfo[ parent ].isSuppressed;
    				let pCtrlStrLen = pHidden ? hiddenLen : showingLen;
    				if( pCtrlStrLen > maxCtrlStrLen ) {
    					maxCtrlStrLen = pCtrlStrLen;
    				}
    			}
    			// prepend option with colons to indicate # option dependencies
    			indentStr = paddingText( '', maxCtrlStrLen );
    			indents.length = reliesUpon.length + 1;
    			let optionStr = indents.join( subsMarker );
    			line += optionStr;							// mark option as conditional
    			inserted += strFontLen( optionStr );
    			indentIt = indentIt !== false ? true : false;
    		}
    		line = (indentIt ? indentStr : '') + line + info.optionStr;
    		return [ line, inserted ];
    	}
    
    	function formatOptions( tabs ) {
    		// check which options are valid; find max column shift
    		var that = formatOptions;
    		var valid = (that.valid = that.valid || []);
    		var lines = (that.lines = that.lines || []);
    		var shiftedTabs = (that.shiftedTabs = that.shiftedTabs || []);
    		valid.length = 0;
    		lines.length = 0;
    		shiftedTabs.length = 0;
    
    		var currentScreen = pageLabels[ currentScreenIdx ],
    			options = optionPages[ currentScreen ];
    		var idx, len, option, line, insert, maxInsert = 0;
    		for( idx = 0, len = options.length; idx < len; idx++ ) {// find all non-suppressed options
    			option = options[ idx ];
    			// returns start of line (option, special char?), length of any special char
    			[ line, insert ] = checkCondition( option );
    			if( line === false ) {						// option is suppressed
    				continue;
    			}
    			if( insert > maxInsert ) {
    				maxInsert = insert;
    			}
    			valid.push( option );
    			lines.push( line );
    		}
    		// shift tabs to account for inserted markers
    		for( idx = 0, len = tabs.length; idx < len; idx ++ ) {
    			shiftedTabs[ idx ] = tabs[ idx ] + maxInsert;
    		}
    		clearObject( validOptions );
    		for( idx = 0, len = valid.length; idx < len; idx++ ) {// format valid options
    			option = valid[ idx ];
    			line = lines[ idx ];
    			line += shiftedTabs && shiftedTabs.length > 0
    					? paddingText( line, shiftedTabs[ 0 ] ) : '';	// '\t\t'
    			validOptions[ option ] = formatLine( option, line, shiftedTabs );
    		}
    		return valid[ valid.length - 1 ];				// last valid option
    	}
    
    // : ' +  + '
    	function formatLine( option, line, tabs ) {
    		var value = getOptionValue( option );
    		var type = typeof value;
    		// space for middle column, ie. option's value
    		var gap = tabs && tabs.length > 1
    				? tabs[ 1 ] - tabs[ 0 ] : 0;
    
    		// short description
    		var brief = expandText( keyPrefix + option );
    		if( brief && brief.length > 0 ) {
    			let endSpec = brief.indexOf( ')' );
    			if( endSpec > 0 )
    				brief = brief.slice( endSpec + 1 ).trim(); // skip past type, default & range(?)
    		}
    
    		if( value === null ) {
    		 	line += 'null';
    		} else if( type === 'undefined' ) {
    			line += ''; 								// blank for page of only text
    		} else if( type === 'string' ) {
    			line += value;
    		} else if( type === 'boolean' ) {
    			line += expandText( ( value ? 'stn_optns_boolean_true' : 'stn_optns_boolean_false' ) );
    		} else if( type === 'number' ) {
    			let num = floor( value ) === value			// an integer
    					  ? value : value.toPrecision( maxPrecision < 1 ? 1 : maxPrecision );
    			let precision = maxPrecision - 1;
    			while( gap > 0 && precision >= 1 && measureWord( '' + num ) > gap ) { // overflows the gap
    				num = value.toPrecision( precision );
    				precision--;
    			}
    			line += num;
    		} else if( Array.isArray( value ) || value instanceof Vector3D ) {
    			line += formatArray( option, value, line, brief, tabs );
    		} else {										// something else
    			line += '{object}';
    			if( !changesMade.hasOwnProperty( option ) ) {// an array is only created when getOptionValue from host
    				freeObject( value );					// else we're using the one in changesMade
    			}
    		}
    
    		// assemble
    		line += tabs && tabs.length > 1
    				? paddingText( line, tabs[ 1 ] ) : '\t';
    		line += brief;
    		while( strFontLen( line ) > MAXSCREENWIDTH ) {	// truncate long lines on word boundary
    			line = line.slice( 0, line.lastIndexOf( ' ' ) );
    		}
    		return line;
    		// return getChoice( line );
    	}
    
    	function formatArray( option, value, line, brief, tabs ) {
    		// return string representing the array/vector value of 'key'
    		// so it fits within tab stops
    		var that = formatArray;
    		var items = (that.items = that.items || []);
    		items.length = 0;
    
    		function getItems( precision ) {
    			items.length = 0;
    			for( let idx = 0; idx < len; idx++ ) {
    				let item = value[ idx ];
    				if( item % 1 === 0 ) {					// remove .0's
    					items.push( item );
    				} else {
    					// .toPrecision may return exponential notation
    					let precise = item.toPrecision( precision );
    					let pStr = precise.toString();
    					let eIdx = pStr.indexOf( 'e' );
    					if( eIdx >= 0 ) {
    						let dot = pStr.indexOf( '.' );
    						if( dot < 0 ) {
    							dot = eIdx;
    						}
    						precise = dot >= precision ? item.toFixed( 0 )
    								: item.toFixed( precision - dot );
    					}
    					items.push( precise );
    				}
    			}
    		}
    
    		var gap = tabs && tabs.length > 1
    				? tabs[ 1 ] - tabs[ 0 ] : 0;
    		var fmtStr, valueF, len = value.length,
    			typeStr = Array.isArray( value ) ? '{array}' : '{vector}',
    			vectorLen = maxVectorLen, result = '';
    		var hairSpace = String.fromCharCode( 0x200a );
    
    		getItems( vectorLen );
    		if( gap > 0 ) {
    			if( !vectorLen || vectorLen < 1 ) {
    				// !vectorLen is zero when user turns it off: default is to round to integer
    				// vectorLen = floor(maxPrecision / len + 0.5);  // round( maxPrecision / len )
    				vectorLen = 1;	  						// argument to .toPrecision must be >= 1
    			}
    			while( true ) { 							// try to squeeze value into gap
    				fmtStr = items.join( ', ' );
    				valueF = measureWord( fmtStr );
    				if( valueF < gap ) 						// not <= so there's a space to next column
    					break;
    				fmtStr = items.join( ',' + hairSpace );
    				valueF = measureWord( fmtStr );
    				if( valueF < gap )  					// not <= so there's a hairSpace to next column
    					break;
    				fmtStr = items.join( ',' );
    				valueF = measureWord( fmtStr );
    				if( valueF <= gap ) 					// butts up against next column
    					break;
    				vectorLen--;
    				if( vectorLen < 1 )
    					break;
    				getItems( vectorLen );
    			}
    			result = valueF > gap ? typeStr : fmtStr;
    		} else {										// gap == 0 => no tabStops specified
    			let lineF = measureWord( line ),
    				briefF = measureWord( brief );
    			fmtStr = items.join( ', ' );
    			valueF = measureWord( fmtStr );
    			if( lineF + valueF + briefF > MAXSCREENWIDTH ) {
    				fmtStr = value.join( ',' + hairSpace );
    				valueF = measureWord( fmtStr );
    			}
    			if( lineF + valueF + briefF > MAXSCREENWIDTH ) {
    				fmtStr = value.join( ',' );
    				valueF = measureWord( fmtStr );
    			}
    			result = lineF + briefF + valueF > MAXSCREENWIDTH ? typeStr : fmtStr;
    		}
    		if( !changesMade.hasOwnProperty( option ) ) {		// an array is only created when getOptionValue from host
    			freeArray( value );							// else are using the one in changesMade
    		}
    		return result;
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// evaluation functions ///////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    // : ' +  + '
    	var classifiedTerm = {};
    	function classifyTerm( term, option, context ) {
    		var that = classifyTerm;
    		var exclamations = (that.exclamations = that.exclamations || []);
    
    		// check if 'term' is an option or hostVar & fetch its value
    		// - return quoted strings as will be used in eval calls
    		var count, value = term, type = null, opposite = false;
    		if( term.length === 0 ) {
    			term = value = "''";
    			return [ term, value, type, opposite ];
    		}
    		[ term, count ] = splitExclamations( term );
    		if( count > 0 ) {
    			exclamations.length = count + 1;
    			opposite = exclamations.join( '!' );
    		}
    		if( startsWith( term, SO_PREFIX ) ) {
    			type = so.hasOwnProperty( term ) && typeof so[ term ] === 'function'
    					? 'soFunction' : 'unknown';
    		} else if( term === so.name ) {
    			type = 'soFunction';
    		} else if( term === 'worldScripts' ) {
    			type = 'oxpFunction';
    		} else if( startsWith( term, hostVarPrefix ) ) {
    			if( !hostOxp.hasOwnProperty( term ) ) {
    				type = 'hostVariable'
    				log( so.name, 'classifyTerm, for "' + option
    					+ '", term "' + term + '" not found in: '
    					+ keyPrefix + ', assuming it is a variable\n\t' + context );
    			} else if( typeof so[ term ] === 'function' ) {
    				type = 'hostFunction';
    			} else {
    				type = 'hostVariable';
    				value = getOptionValue( stripPrefix( term ), true );	// true => get from hostOxp
    				if( value === undefined ) {
    					log( so.name, 'classifyTerm, for "' + option
    						+ '", "' + term + '" is undefined: ' + context );
    				}
    			}
    		} else {
    			let page;
    			if( optionInfo.hasOwnProperty( term ) ) {
    				page = optionInfo[ term ].page;
    			}
    			if( page ) {								// it's an option
    				optionInfoToObj( term, classifiedTerm );
    				type = classifiedTerm.type;
    				if( classifiedTerm.error ) {
    					type = 'unknown';					// checkValue will have logged error
    				}
    				value = getOptionValue( term );
    				if( value === undefined ) {
    					log( so.name, 'classifyTerm, for "' + option
    						+ '", option "' + term + '" is undefined: ' + context );
    				} else {
    					value = checkValue( value, classifiedTerm, option );
    				}
    			} else if( global.hasOwnProperty( term ) ) {
    				type = typeof global[ term ] === 'function' ? 'ooFunction' : 'unknown';
    			}
    		}
    		if( typeof value === 'string' && value.length === 0 ) {
    			value = "''";
    		}
    		return [ term, value, type, opposite ];
    	}
    
    	var termSplitter = /\s*(?:[()]*)([=<>&~^|&?:]+|[!][=]{1,2})\s*/;	// exclude solo '!' as stays with term
    	var matchCompareOp = /\s*([=!<>]+)\s*/;
    	// NB: global flag 'g' must not be used as it's for successive matches only
    	// (.exec remember index from previous use; set re's .lastIndex=0 if must use /g)
    	var indexingArray = /'\s*([[][^\]]*[\]])\s*''\s*([[][^\]]*[\]])\s*'/g;
    
    	function substituteValues( string, option, assignKey ) {
    		var that = substituteValues;
    		var delayed = (that.delayed = that.delayed || []);
    
    		function valueFromString( term, unquoted, vector, decimal, integer ) {
    			var value;
    			if( integer ) 					value = parseInt( integer, 10 );
    			else if( decimal )				value = parseFloat( decimal );
    			else if( vector )				value = JSON.parse( vector );
    			else if( term === 'null' ) 		value = null;
    			else if( term === 'undefined' )	value = undefined;
    			else if( term === 'true' ) 		value = true;
    			else if( term === 'false' ) 	value = false;
    			else if( unquoted ) {
    				// return quoted strings as will be used in eval calls
    				if( /\\'/.test( term ) ) {				// remove escapes for escaped quotes;
    					value = '"' + unquoted.replace( /\\'/g, "'" ) + '"';
    				} else {
    					value = isSingleQuoted( term ) ? term : "'" + stripQuotes( term ) + "'";
    				}
    			} else { 									// some other unquoted string (?option or hostVar)
    				value = term;
    			}
    			return value;
    		}
    
    		function indexInList( list, item ) {
    			var addQuotes = isSingleQuoted( item );
    			for( let idx = 0, len = list.length; idx < len; idx++ ) {
    				let it = list[ idx ];
    				if( item === (addQuotes ? "'" + it + "'"  : it) ) {
    					return idx;
    				}
    			}
    			return -1;
    		}
    
    		function valueToType( value, type, expression ) {
    
    			function indexError( kind ) {
    				return 'valueToType, ' + kind + ' (' + coerced + ') for value "' + value
    								+ '" out of range for option "' + classifiedTerm.option
    								+ '", \n\tmust be in interval 0..' + (choices.length - 1)
    								+ ' for ' + kind + ' values: ' + choices
    								+ '\n\tsee "' + option + '": ' + expression;
    			}
    
    			var choices, coerced = value;
    			if( type === 'hostVariable' ) {
    				if( Array.isArray( value ) ) {
    					coerced = valueToType( value, 'vector', expression );
    				} else if( typeof value === 'string' ) {
    					coerced = valueFromString( value, stripQuotes( value ) )
    				}
    			} else if( type === 'choice' && typeof value === 'string' ) {
    				choices = classifiedTerm.selection;		// ASSUMES prior call to classifyTerm
    				if( !choices || choices.length === 0 )
    					throw( 'valueToType, cannot find choices for: ' + classifiedTerm.option
    							+ '; see "' + option + '": ' + expression  );
    				coerced = indexInList( choices, value );
    				if( coerced < 0 || coerced >= choices.length ) {
    					throw indexError( 'choice' );
    				}
    			} else if( type === 'vector' ) {			// validate term's elements
    				let isArray = Array.isArray( value );
    				if( !isArray || value.length === 0 ) {
    					throw( 'valueToType, ' + (isArray ? 'empty' : 'missing') + ' array  for "'
    							+ classifiedTerm.option + '"; see "' + option + '": ' + expression  );
    				}
    				for( let vi = 0, len = value.length; vi < len; vi++ ) {
    					let item = value[ vi ];
    					let rangeChk = valueInRange( item, classifiedTerm );
    					if( typeof rangeChk === 'string' )
    						// not between min & max or if no range in spec., not between lowestValue & highestValue
    						throw( 'valueToType, invalid value ( ' + item + ' ) for "'
    								+ classifiedTerm.option + '" : ' + rangeChk
    								+ '; see "' + option + '": ' + expression  );
    				}
    				/* 	convert arrays to string for JS compare (JS has no inherent array comparison)
    					(JSON.stringify'd arrays have [], .toString does not)
    					- no benefit using JSON as condition strings are first converted to
    					  numbers, so insignificant differences won't matter (eg. -0, trailing zeros)
    				*/
    				// use single quotes to be consistent with strings which are always single quoted
    				coerced = "'[" + value.toString() + "]'";
    			} else if( type === 'bitflags' && typeof value === 'string' ) {
    				choices = classifiedTerm.selection;		// ASSUMES prior call to classifyTerm
    				if( !choices || choices.length === 0 )
    					throw( 'valueToType, cannot find bitflag strings for: ' + classifiedTerm.option
    							+ '; see "' + option + '": ' + expression  );
    				coerced = indexInList( choices, value );
    				if( coerced < 0 || coerced >= choices.length ) {
    					throw indexError( 'bitflagStrs' );
    				}
    			} else if( type === 'number' || type === 'decimal' || type === 'bitflags' ) {
    				let rangeChk = valueInRange( value, decodedChoice );
    				if( typeof rangeChk === 'string' )
    					// not between min & max or if no range in spec., not between lowestValue & highestValue
    					throw( 'valueToType, invalid value ( ' + value + ' ) for "'
    							+ classifiedTerm.option + '": ' + rangeChk
    							+ '; see "' + option + '": ' + expression  );
    			} else if( type === 'toggle' ) {
    				if( typeof value === 'string' ) {
    					let choices = classifiedTerm.selection;// ASSUMES prior call to classifyTerm
    					if( !choices || choices.length === 0 )
    						throw( 'valueToType, cannot find choices for: ' + classifiedTerm.option
    							+ '; see "' + option + '": ' + expression  );
    					let idx = indexInList( choices, value );
    					coerced = idx === 0 ? false : true;
    				}
    			}											// no validation for 'text'
    			return coerced;
    		}
    
    		function optionValue( matchStr, leadWS, term, unQuoted, aVector, aDecimal, anInt, offset, origStr ) {
    			var that = optionValue;
    			var previous = (that.previous = that.previous ||
    							{ 'term': null, 'value': null, 'unQuoted': null, 'type': null, 'operator': null });
    			var numTerms = (that.numTerms = that.numTerms || -1);// -1 as numTerms++ for each, indices are 0-based
    
    			var context = origStr.slice( offset );
    			matchCompareOp.lastIndex = 0;				// reset re's .lastIndex as it doesn't reset on a new string!!!!
    			var operator = matchCompareOp.exec( previous.operator );
    			operator = operator ? operator[ 1 ] : null; // coerceValue only uses it for messages
    			var [ term, value, type, opposite ] = classifyTerm( term, option, context );
    			if( type === null ) {
    				value = valueFromString( term, unQuoted, aVector, aDecimal, anInt );
    				if( typeof value === 'string' ) {
    					if( matchCompareOp.test( previous.operator ) ) {
    						value = valueToType( value, previous.type, context );
    					} else if( assignKey ) {
    						if( optionInfo.hasOwnProperty( assignKey ) ) {
    							optionInfoToObj( term, classifiedTerm );
    							value = valueToType( value, optionInfo[ assignKey ].type, context );
    						}
    					}
    				} else if( Array.isArray( value ) ) {
    					value = valueToType( value, 'vector', context );
    				}
    			} else if( type !== 'unknown' && !endsWith( type, 'Function' ) ) {
    				value = valueToType( value, type, context );
    				if( type !== 'hostVariable' ) {
    					if( previous.type === null && operator && previous.unQuoted ) {
    						// missed coercion as option trails string
    						delayed.push( [ numTerms - 1, // -1 due to the operator's that.numTerms++;
    										valueToType( previous.value, type, context ) ] );
    					}
    				}
    			}
    			that.numTerms++;
    			that.previous.term = term;
    			that.previous.value = value;
    			that.previous.unQuoted = unQuoted;
    			that.previous.type = type;
    			termSplitter.lastIndex = 0;					// reset re's .lastIndex as it doesn't reset on a new string!!!!
    			let trailing = termSplitter.exec( origStr.slice( offset + matchStr.length ) );
    			if( trailing ) {							// operator after term, could be comparison or logical
    				that.previous.operator = trailing[ 1 ];
    				that.numTerms++;
    			} else {
    				that.previous.operator = null;
    			}
    			return leadWS + ( opposite ? opposite : '') + value;
    		}
    
    		delayed.length = 0;
    		optionValue.numTerms = -1;
    		let result = string.replace( decodeTerm, optionValue );
    		if( delayed.length ) {
    			let splat = result.split( termSplitter );
    			// .split can have empty strings, eg. when space between termSplitter char.s
    			let terms = splat.filter( function( item ) { return item !==''; } );
    			for( let idx = 0, len = delayed.length; idx < len; idx++ ) {
    				let [ count, target ] = delayed[ idx ];
    				terms[ count ] = target;
    			}
    			result = terms.join( ' ' );
    		}
    		// for an array index, remove doubled single quotes
    		result = result.replace( indexingArray, '$1$2' );
    		return result;
    	}
    
    // : ' +  + '
    	function conditionMet( option, conditionKey, conditions ) {
    		try {
    
    			decodeExprn.lastIndex = 0;					// reset re's .lastIndex as it doesn't reset on a new string!!!!
    			var substituted = substituteValues( conditions, conditionKey );
    if( startsWith( option, 'AutoScan' ) ) {
    	log('conditionMet, conditions: ' + conditions );
    	log('             substituted: ' + substituted );
    }
    			try {
    				// use strict protects the global namespace
    				let result = eval( "'use strict'; " + substituted );
    				return result;
    			} catch( err ) {
    				log( so.name, 'conditionMet caught ' + err + ' on:'
    					+ '\n    eval( >>' + substituted + '<< );'
    					+ '\n    where the argument was derived via substitution from:'
    					+ '\n        ' + conditions
    					+ '\n    in "' + conditionKey + '"' );
    				return true; 							// don't let error suppress option
    			}
    
    		} catch( err ) {
    			// FYI: can throw object, existing (or constructed in throw statement), eg. conditionalOption
    			if( typeof err === 'string' ) {				// one of mine
    				if( err.length ) {						// zero length string => error already issued
    					log( so.name, 'conditionMet, ' + err );
    				}
    				return true; 							// don't let error suppress option
    			} else
    				log( so.name, so._reportError( err, conditionMet, [ option, conditionKey, conditions ] ) );
    				throw err;
    		}
    	}
    
    // : ' +  + '
    	function doAssignments( option ) {
    		var assignKey = keyPrefix + option + '_assign';
    		var expanded = expandText( assignKey );
    		if( !expanded )
    			return;
    		try {
    			var search, foundOne, result, index, lastIndex;
    			decodeAssign.lastIndex = 0;					// reset re's .lastIndex as it doesn't reset on a new string!!!!
    			var statement, substituted, expression,
    				statements = expanded.split( /[;]/ );
    			foundOne = false;
    			index = lastIndex = 0;
    			for( let idx = 0, len = statements.length; idx < len; idx++ ) {
    				expression = '';
    				statement = statements[ idx ];
    				if( statement.length === 0 )
    					continue;
    				decodeAssign.lastIndex = 0;				// reset re's .lastIndex as it doesn't reset on a new string!!!!
    				search = decodeAssign.exec( statement );
    				if( search === null ) {					// not an assignment statement
    					if( foundOne )
    						break;
    					throw( 'doAssignments, unable to parse _assign specifier for "'
    						+ option + '" : ' + statement + '\n\t_assign: ' + expanded );
    				}
    				index = search.index;
    				let [ matchStr, target, assignment ] = search;
    				if( index > lastIndex ) {
    					log( so.name, 'doAssignments, invalid assignment statement: "' + statement
    						+ ', nothing should precede the target : "' + target + '"' );
    					continue;
    				}
    				foundOne = true;
    				let value, optType, isNot;
    				[ target, value, optType, isNot ] = classifyTerm( target, assignKey, matchStr );
    				substituted = substituteValues( assignment, assignKey, target );
    				lastIndex = index;
    				expression += 'worldScripts.' + hostOxp.name + '.'
    							+ (optType === 'hostVariable' ? '' : hostVarPrefix)
    							+ target + ' = ' + substituted;
    				freeArray( search );
    
    				try{
    					result = eval( "'use strict'; " + expression );
    					if( missionKeys.hasOwnProperty( target ) ) {
    						missionKeys[ target ] = getOptionValue( target );
    					}
    				} catch( err ) {
    					log( so.name, 'doAssignments caught ' + err + ' on:'
    						+ '\n    eval( ' + expression + ' );'
    						+ '\n    for "' + assignKey );
    				}
    				if( optType !== null ) {
    					let option = stripPrefix( target );
    					let startVal = getOptionValue( option, true );// true => from hostOxp
    					if( changesMade.hasOwnProperty( option ) && changesMade[ option ] !== startVal ) {
    						log( so.name, 'doAssignments, WARNING: ' + assignKey + ' overwrites "' + option
    							+ '" that player changed from: ' + startVal + ' to: ' + changesMade[ option ]
    							+ '; its valus is now: ' + result );
    					}
    					removeChangeMade( option ); 		// now rendered moot
    				}
    			}
    		} catch( err ) {
    			log( so.name, so._reportError( err, doAssignments, option ) );
    			if( typeof err === 'string' ) {
    				log( so.name, err );
    			} else {
    				throw err;
    			}
    		}
    	}
    
    	function doExecutes( option ) {
    		var executeKey = keyPrefix + option + '_execute';
    		var expanded = expandText( executeKey );
    		if( !expanded )
    			return;
    		try {
    			var search, foundOne, result, index, lastIndex;
    
    			decodeFnCall.lastIndex = 0;					// reset re's .lastIndex as it doesn't reset on a new string!!!!
    			var statement, substituted, expression,
    				statements = expanded.split( /[;]/ );
    			for( let idx = 0, len = statements.length; idx < len; idx++ ) {
    				statement = statements[ idx ];
    				if( statement.length === 0 )
    					continue;
    				decodeAssign.lastIndex = 0;				// reset re's .lastIndex as it doesn't reset on a new string!!!!
    				let assign = decodeAssign.test( statement );
    				if( assign ) {
    					log( so.name, 'doExecutes, assignment detected: ' + statement
    						+ '\n\tIgnoring as it will not work due to complete value substitution.  Move it into an "_assign" option');
    					continue;
    				}
    				substituted = substituteValues( statement, executeKey );
    				foundOne = false;
    				expression = '';
    				index = lastIndex = 0;
    				while( true ) {
    					search = decodeFnCall.exec( substituted );
    					if( search === null ) {					// no function calls
    						if( foundOne )
    							break;
    						throw( 'doExecutes, unable to parse "'
    							+ executeKey + '" : '
    							+ substituted.slice( lastIndex )
    							+ '\n\t_execute: ' + expanded );
    					}
    					index = search.index;
    					let [ matchStr, leading, wsName, wsFn, soFn, oxpFn, args, trailing ] = search;
    					foundOne = true;
    					expression += substituted.slice( lastIndex, index );
    					lastIndex = index;
    					if( wsName && wsFn ) {
    						let script = eval( wsName );
    						if( script !== undefined ) {	// oxp is present
    							if( script.hasOwnProperty( wsFn )
    									&& typeof script[ wsFn ] === 'function' ) {
    								expression += leading + wsName + '.'
    											+ wsFn + args + trailing;
    								index += matchStr.length;
    							} else {
    								log( so.name, 'doExecutes, unable to locate function "'
    									+ wsFn + '"in "' + executeKey + '" : '
    									+ substituted.slice( index )
    									+ '\n\t_execute: ' + expanded );
    							}
    						} else {
    							log( so.name, 'doExecutes, unable to locate worldScripts "'
    								+ wsName + '"in "' + executeKey + '" : '
    								+ substituted.slice( index )
    								+ '\n\t_execute: ' + expanded );
    						}
    					} else if( soFn ) {
    						if( so.hasOwnProperty( soFn )
    								&& typeof so[ soFn ] === 'function' ) {
    							expression += leading + 'worldScripts.' + so.name
    										+ '.' + soFn + args + trailing;
    							index += matchStr.length;
    						} else {
    							log( so.name, 'doExecutes, unable to locate ' + so.name + ' function "'
    								+ soFn + '"in "' + executeKey + '" : '
    								+ substituted.slice( index )
    								+ '\n\t_execute: ' + expanded );
    						}
    					} else if( oxpFn ) {
    						if( hostOxp.hasOwnProperty( oxpFn )
    								&& typeof hostOxp[ oxpFn ] === 'function' ) {
    							expression += leading + 'worldScripts.' + hostOxp.name
    										+ '.' + oxpFn + args + trailing;
    							index += matchStr.length;
    						} else {
    							log( so.name, 'doExecutes, unable to locate global function "'
    								+ oxpFn + '"in "' + executeKey + '" : '
    								+ substituted.slice( index )
    								+ '\n\t_execute: ' + expanded );
    						}
    					} else {
    						throw( 'doExecutes, unable to decode "' + executeKey + '": ' + matchStr );
    					}
    				}
    
    				try{
    					result = eval( "'use strict'; " + expression ); // + ';'
    				} catch( err ) {
    					log( so.name, 'doExecutes caught ' + err + ' on:'
    						+ '\n    eval( ' + expression + ' );'
    						+ '\n    for "' + executeKey + '", after substituting values in:'
    						+ '\n        ' + expanded );
    				}
    				freeArray( search );
    			}
    		} catch( err ) {
    			log( so.name, so._reportError( err, doExecutes, option ) );
    			if( typeof err === 'string' ) {
    				log( so.name, err );
    			} else {
    				throw err;
    			}
    		}
    	}
    // : ' +  + '
    
    	///////////////////////////////////////////////////////////////////////////////
    	// processing functions ///////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    	function optionInfoToObj( option, obj ) {
    		// clearObject( obj, true );				// true -> keepObjects
    		for( let prop in obj ) {				// reset to defaults (infoTemplate)
    			if( obj.hasOwnProperty( prop ) ) {
    				if( infoTemplate.hasOwnProperty( prop ) ) {
    					obj[ prop ] = infoTemplate[ prop ];
    				} else {
    					delete obj[ prop ];
    				}
    			}
    		}
    		if( optionInfo.hasOwnProperty( option ) ) {		
    			var info = optionInfo[ option ];
    			for( let prop in info ) {
    				if( info.hasOwnProperty( prop ) ) {
    					obj[ prop ] = info[ prop ];
    				}
    			}
    			obj.currently = getOptionValue( option );
    		}
    	}
    
    	var keyFromChoice = /^(\d\d)_(\w+)/; 				// parse choice key: NN_<variable name>
    	function processChoice( choice ) {					// for an optionPages selection
    		try {
    // log('processChoice, ===> ' + cd._showProps(choice, 'choice',0,1));
    			var currentScreen = pageLabels[ currentScreenIdx ];
    			refreshSOKeys();
    			missionKeys.stn_optns_option = null;
    			clearObject( currentChoice ); 				// re-use object
    			if( !choice || choice === 'ZZ_exit' || choice === 'ZZ_abort' ) {
    				clearObject( changesMade, true );		// true => keepObjects (ie. del prop only, not what it references)
    				clearObject( missionKeys.stn_optns_changed );
    				_purgepools();
    				return;
    			} else if( choice === 'YY_save' ) {			// button will be unselectable if no changes
    				saveChoices();
    				_purgepools();
    				return;
    			} else if( choice === 'XX_reset' ) {
    				confirmReset();
    				return;
    			} else if( choice === 'WW_next_pg' ) {
    				currentChoice.option = null;
    				scroller.reset();
    				currentScreen = nextOptionsPage();
    				if( currentScreen !== null )
    					displayOptions( choice );
    				return;
    			} else if( choice === 'VV_more' || choice === 'ZZ_more' ) {
    				currentChoice.option = null;
    				displayOptions( choice );
    				return;
    			}
    			var changing = choice.match( keyFromChoice );
    			if( changing === null ) {
    				log( so.name, 'processChoice, ERROR: failed to decode "' + choice + '" using: ' + keyFromChoice );
    				return;
    			}
    			var option = changing[ 2 ];					// get option from choice
    			// var option = currentChoice.option = changing[ 2 ];// get option from choice
    			freeArray( changing );
    			var options = optionPages[ currentScreen ];
    			if( options.indexOf( option ) < 0 ) {
    				log( so.name, 'processChoice, ERROR: cannot find option: ' + option + ' in ' + options );
    			} else {
    				optionInfoToObj( option, currentChoice );
    				missionKeys.stn_optns_option = option;
    				scroller.start();
    				let type = currentChoice.type;
    				if( type === 'toggle' )		   chooseBoolean();
    				else if( type === 'number' )   chooseNumber();
    				else if( type === 'vector' )   chooseVector();
    				else if( type === 'decimal' )  chooseNumber(); // function handles both int & float
    				else if( type === 'bitflags' ) chooseBits();
    				else if( type === 'choice' )   chooseChoice();
    				else /* assume text */		   chooseText();
    			}
    		} catch( err ) {
    			log( so.name, so._reportError( err, processChoice, choice, 1 ) );
    			throw err;
    		}
    	}
    
    	// parsing of the brief option specifier is done in stages, subsequent stages using the rest
    	var decodeType = /\(\s*(text|toggle|number|decimal|vector|bitflags|choice)\s*(,?)\s*(.*?\))/;
    		/*
    			[1] the type
    			[2] a comma, maybe
    			[3] the rest, including closing parenthesis
    		*/
    	var decodeDefault = /\s*(null|true|false|\{.*?\}|'([^'\\]*(?:\\.[^'\\]*)*)'|[-+.\w]*)\s*(,?)\s*(.*?\))/;
    		/*
    			[1] default value
    			[2] unquoted version of [1] if quoted
    			[3] a comma, maybe
    			[4] the rest, including closing parenthesis
    		*/
    	var decodeRange = /\s*(\{.*?\}|([-+.\w]*)\s*(:)\s*([-+.\w]*))\s*\)/;
    		/*
    					[1] selection enclosed in braces {} if [3] is not a colon
    					[2] minimum if [3] is a colon
    					[3] a colon, maybe
    					[4] maximum if [3] is a colon
    		*/
    
    /// - decodeOption needs a 'do not check' arg to suppress calls to checkValue, as only needs doing
    ///   when option is first decoded  *OR*
    ///   build a map of maps of decoded options (ie. cache) to use for rest of game <- easier
    	function decodeOption( option, destination ) {		// for option definitions
    		var matchStr, unquoted, type, comma, rest, defawlt,
    			descr = expandText( keyPrefix + option ),
    			items = descr ? descr.match( decodeType ) : null;
    
    		if( !items ) {
    			let error = 'Error: for option ' + (keyPrefix + option)
    						+ ', failed to decode (bad type) "' + descr + '"';
    			log( so.name, 'decodeOption, ' + error	);
    			destination.error = error;
    			return;
    		}
    		[matchStr, type, comma, rest] = items;
    		destination.option = option;
    		destination.type = type;
    		// set selection if applicable (done here as any default is optional)
    		if( type === 'bitflags' ) {
    			let flags = getBitFlagStrs( option ) || getArray();
    			destination.selection = flags;
    			let numBits = flags.length, allBits = 0;
    			for( let x = 0; x < numBits; x++ )
    				allBits |= 1 << x;
    			destination.numBits = numBits;
    			destination.allBits = allBits;
    		} else if( type === 'toggle' ) {
    			destination.selection = getArray( expandText( 'stn_optns_boolean_false' ),
    												expandText( 'stn_optns_boolean_true' ) );
    		}
    		if( !comma ) {									// no more, ie. no default, range
    			if( type === 'text' ) {						// no comma => text only page
    				destination.type = 'display';
    			} else if( type === 'bitflags' || type === 'decimal' || type === 'number' ) {
    				destination.defawlt = 0;				// allowed to skip both default & range
    				destination.min = destination.max = null;
    			} else if( type === 'choice' ) {
    				destination.defawlt = 0;				// default to 1st in list if no default specified
    			} else if( type === 'toggle' ) {
    				destination.defawlt = false;
    			} else if( type === 'vector' ) {
    				destination.defawlt = getArray( 0, 0, 0 );
    			} else {
    				let error = 'Error: for option ' + (keyPrefix + option)
    						+ ', incomplete type spec. (expecting comma) "' + descr + '"';
    				log( so.name, 'decodeOption, ' + error	);
    			}
    			return;
    		}
    
    		items = rest.match( decodeDefault );			// no error checking as always succeeds
    		[matchStr, defawlt, unquoted, comma, rest] = items;
    		defawlt = defawlt.replace( /\\'/g, "'" );		// unescaped imbedded quotes
    		if( comma && (type === 'text' || type === 'toggle') ) {
    			let error = 'Error: for option ' + (keyPrefix + option) + ', ' + type
    						+ ' cannot have a range "' + descr + '"';
    			log( so.name, 'decodeOption, ' + error );
    			destination.error = error;
    			return;
    		}
    		destination.defawlt = defawlt = defawlt ? checkValue( defawlt, destination, option ) : null;
    		destination.min = destination.max = null;
    		if( !comma ) {
    			return;	  									// no comma => no more to parse
    		}
    
    		items = rest.match( decodeRange );
    		if( !items ) {
    			let error = 'Error: for option ' + (keyPrefix + option)
    						+ ', missing range or too many commas in "'+ descr + '"';
    			log( so.name, 'decodeOption, ' + error );
    			destination.error = error;
    			return;
    		}
    		comma = items[ 3 ];
    		if( comma === ':' ) {							// we have a range
    			getRange( option, items[ 2 ], items[ 4 ], destination );
    		} else if( type === 'choice' ) {
    			destination.selection = parseArray( items[ 1 ], type, option );
    			if( typeof defawlt === 'number' &&
    					(defawlt < 0 || defawlt >= destination.selection.length) ) {
    				let error = 'Error: for option ' + (keyPrefix + option)
    							+ ', default index (' + defawlt + ') not in list "'
    							+ descr + '"';
    				log( so.name, 'decodeOption, ' + error );
    				destination.error = error;
    			} else if( typeof defawlt === 'string' ) {
    				let index = destination.selection.indexOf( defawlt );
    				if( index < 0 ) {
    					let error = 'Error: for option ' + (keyPrefix + option)
    								+ ', default (' + defawlt + ') not in list "'
    								+ descr + '"';
    					log( so.name, 'decodeOption, ' + error );
    					destination.error = error;
    				} else {								// convert one of choices into an index
    					destination.defawlt = index;
    				}
    			}
    		} else {
    			let error = 'Error: for option ' + (keyPrefix + option)
    						+ ', invalid type (' + type + ') for choice list "'
    						+ descr + '"';
    			log( so.name, 'decodeOption, ' + error );
    			destination.error = error;
    		}
    	}
    
    	function getBitFlagStrs( option ) {
    		var flags = null,
    			bitflagStrs = expandText( keyPrefix + option + '_bitflagStrs' );
    		if( bitflagStrs === null ) {
    			log( so.name, 'getBitFlagStrs, ERROR: missing or invalid _bitflagStrs key for "' + option + '"' );
    		} else {
    			flags = bitflagStrs.match( matchListOfStr );
    			if( !flags || flags.length === 0 ) {
    				flags = null;
    			}
    		}
    		return flags;
    	}
    
    	var parseNumber = /^\s*([+-]?[0-9]+)\s*$/;
    	var parseDecimal = /^\s*([+-]?([0-9]+[.]?[0-9]*|[.][0-9]+))\s*$/;
    	var quoteStripper = /'([^'\\]*(?:\\.[^'\\]*)*)'/;
    	var dblQuoteStripper = /"([^"\\]*(?:\\.[^"\\]*)*)"/;
    	var quoteTrimmer = /(^')|('$)/g;
    
    	function checkValue( checking, result, option, orType ) {
    		// used for both validating missiontext.plist & user input
    		//  !now also for validating _condition terms
    		//	- errors go to log file, setChoice mk's its own msgs
    		//	- 'orType' overrides soType for recursive type checking (choice)
    		var value, soType = orType || result.type,
    			type = typeof checking;
    // log('checkValue, option: ' + option + ', checking: ' + checking + ', soType: ' + soType + ', type: ' + type );
    
    		try {
    			if( checking === undefined
    				|| (Array.isArray(checking) && checking.length === 0) ) {
    				value = undefined;
    			} else if( checking === 'null' || checking === null ) {
    				value = null;
    			} else if( soType === 'choice' ) {			// 1 level recursion for typing
    				// must precede next block else may be treated as text (if checking is text)
    				if( type === 'string' ) {
    					let match = checking.match( parseNumber );
    					if( match === null ) {
    						value = checkValue( checking, element, option, 'text' );
    					} else {
    						value = checkValue( checking, element, option, 'number' );
    					}
    				} else if( type === 'number' ) {
    					value = checking;
    				} else {
    					throw( expandText( 'stn_optns_bad_entry' ) + ' for choice: ' + value + ', typeof: ' + typeof value );
    				}
    			} else if( soType === 'text' ) { /// || type === 'string' - catches vectors
    				if( checking[ 0 ] === "'" ) { 			// quotes optional, verify they match
    					let match = checking.match( quoteStripper );
    					if( match === null ) {
    						throw expandText( 'stn_optns_bad_quotes' );
    					}
    					value = match[ 1 ];
    				} else {
    					value = checking;
    				}
    			} else if( soType === 'toggle' || type === 'boolean' )	{
    				value = checking;
    				if( typeof value === 'string' ) {
    					value = checking.replace( quoteTrimmer, '' ); 	// strip quotes (allow user leeway)
    					value = value === 'true'  ? true :
    							value === 'false' ? false : 'error';
    					if( value === 'error' ) {
    						throw expandText( 'stn_optns_bad_bool' );
    					}
    				}
    			} else if( soType === 'bitflags' ) {		// must precede number, as bitflag is a number!
    				value = parseInt( checking, 10 );
    				if( value !== value ) {
    					throw expandText( 'stn_optns_bad_num' );// test for NaN which is never equal to itself
    				}
    				if( value < 0 && value !== -1 ) {
    					throw expandText( 'stn_optns_bad_bits' );
    				}
    			} else if( soType === 'number' || soType === 'decimal' || type === 'number' ) {
    				if( typeof checking === 'string' ) {
    					let match = checking.match( soType === 'number' ? parseNumber
    																	: parseDecimal );
    					if( match === null )
    						throw expandText( 'stn_optns_parse_err' );
    					value = soType === 'number' ? parseInt( match[ 1 ], 10 )
    											    : parseFloat( match[ 1 ] );
    				} else {
    					value = checking;
    				}
    				if( value !== value ) {
    					throw expandText( 'stn_optns_bad_num' ); 					// test for NaN which is never equal to itself
    				}
    			} else if( soType === 'vector' || Array.isArray( checking ) ) {
    				if( soType === 'vector' && type === 'string' ) {
    					value = parseArray( checking, soType, option );
    				} else {
    					value = checking;
    				}
    				if( !value )
    					throw expandText( 'stn_optns_bad_entry' );
    				let ball = '';
    				for( let idx = 0, len = value.length; idx < len; idx++ ) {
    					if( value[ idx ] !== value[ idx ] )	// test for NaN which is never equal to itself
    						ball += (ball.length > 0 ? ', ' : '') + expandText( 'stn_optns_bad_array' ) + idx;
    				}
    				if( ball.length > 0 ) {
    					freeArray( value );
    					throw ball;
    				}
    			} else {
    				throw( expandText( 'stn_optns_bad_type' ) + ' ' + soType );
    			}
    		} catch( err ) {
    			if( err instanceof Error ) { 				// not thrown by me
    				log( so.name, so._reportError( err, checkValue, [checking, result, orType], 1 ) );
    				throw err;
    			}
    			let error = 'Error: ' + (err ? err : expandText( 'stn_optns_bad_type' ) + ' ' + soType)
    								  + ' for ' + (keyPrefix + option) + ', checking: ' + checking;
    			result.error = error;
    			log( so.name, 'checkValue, ' + error	 );
    			return soType;								// returning type indicates to caller an error occured
    		}
    		if( result.error ) {
    			result.error = '';
    		}
    // log('checkValue, exit, returning value: ' + value + ', typeof value: ' + (Array.isArray(value) ? 'array' : typeof value) );
    		return value;
    	}
    
    	var replaceBraces = /^\s*\{\s*|\s*\}\s*$/g;
    	var choiceSplitter = /\s*,\s*/;
    	var element = {};									// tmp obj for results of checkValue
    
    	function parseArray( str, type, option ) {
    		if( !str || str.length === 0 )
    			return null;
    		var stripped = str.replace( replaceBraces, '' );
    		if( !stripped )
    			return null;
    		var array, result;
    		if( type === 'choice' ) {
    			array = stripped.split( choiceSplitter );
    		} else {
    			array = stripped.match( matchNumbers );
    		}
    		if( !array || array.length === 0 ) {
    			return null;
    		}
    		result = getArray();
    		if( type === 'choice' ) {
    			element.type = type;
    			for( let idx = 0, len = array.length; idx < len; idx++ ) {
    				let value = checkValue( array[ idx ], element, option );
    				if( element.error ) {
    					log( so.name, 'Error parsing choice = ' + array[ idx ] );
    					freeArray( result );
    					return null;
    				} else { 								// value could be zero
    					result.push( value );
    				}
    			}
    		} else {
    			for( let idx = 0, len = array.length; idx < len; idx++ ) {
    				result.push( parseFloat( array[ idx ] ) );
    			}
    		}
    		return result;
    	}
    
    	function getRange( option, min, max, result ) {
    		var	 type = result.type;
    
    		if( !min && !max ) { 							// are '' if regex found nothing
    			result.min = result.max = null;
    			return;
    		}
    		let isNumber = type === 'number' || type === 'bitflags';
    		result.min = min = !min || min.length === 0 ? lowestValue :	// lowestValue is null when turned off
    						   isNumber ? parseInt( min, 10 ) : parseFloat( min );
    		result.max = max = !max || max.length === 0 ? highestValue :// highestValue is null when turned off
    						   isNumber ? parseInt( max, 10 ) : parseFloat( max );
    		if( type === 'bitflags' ) { 					// check range is valid
    			if( min < 0 || max < 0 ) {
    				log( so.name, 'getRange, Error: found negative bitflag, using '
    					+ (min < 0 ? 'zero for min, ' : '')
    					+ (max < 0 ? -max + ' for max, ' :'')
    					+ ' for ' + (keyPrefix + option) + ', range: ' + min + ':' + max );
    				if( min < 0 ) {
    					result.min = min = 0;
    				}
    				if( max < 0 ) {
    					result.max = max = -max;
    				}
    			}
    			if( max !== null ) {
    				let numBits = result.numBits = max.toString( 2 ).length;
    				let boundary = pow( 2, numBits ) - 1; // 2^N - 1
    				if( max !== boundary ) {
    					result.max = max = boundary;
    					log( so.name, 'getRange, Error: incorrect bitflag boundary, using max = '
    									 + max + ', for ' + (keyPrefix+ option) + ', range: ' + min + ':' + max );
    				}
    			}
    		}
    	}
    
    	function formatRange( withTabs ) {
    		var min = currentChoice.min,
    			max = currentChoice.max;
    		return min === null && max === null ? null :
    				(min !== null ? min : '') + (withTabs ? '\t\t' :'') + ':'
    										  + (withTabs ? '\t\t' :'') + (max !== null ? max : '');
    	}
    
    	function mkChoicePage( rtnobj, screen, choices, btn_count, initialChoice, errmsg ) {
    		var that = mkChoicePage;
    		var depends = (that.depends = that.depends || []);
    		depends.length = 0;
    
    		var option = currentChoice.option,
    			type = currentChoice.type,
    			dstr, defawlt = currentChoice.defawlt,
    			cstr, currently = currentChoice.currently,
    			range = formatRange( true ),
    			isSuppressed = optionInfo[ option ].isSuppressed;
    
    		if( type === 'vector' ) {
    			cstr = currently && currently.join( ',\t\t' );
    			dstr =	 defawlt &&	  defawlt.join( ',\t\t' );
    		} else if( type === 'toggle' ) {
    			cstr = currently === true ? expandText( 'stn_optns_boolean_true' )
    										: currently === false ? expandText( 'stn_optns_boolean_false' ) : currently;
    			dstr =	 defawlt === true ? expandText( 'stn_optns_boolean_true' )
    										: defawlt === false ? expandText( 'stn_optns_boolean_false' ) : defawlt;
    		} else if( type === 'choice' ) {
    			let selection = currentChoice.selection;
    			cstr = typeof currently === 'number' ? selection[ currently ] : currently;
    			dstr = typeof defawlt	=== 'number' ? selection[ defawlt ]	  : defawlt;
    		} else {
    			cstr = currently;
    			dstr = defawlt;
    		}
    		let src = expandText( keyPrefix + option + '_long' );
    		let textEntry = !isSuppressed && type !== 'toggle' && type !== 'bitflags' && type !== 'choice';
    		if( textEntry ) {
    			if( defawlt !== undefined ) src += '\n\n' + expandText( 'stn_optns_enter_plus' );
    			src += '\n\n' + expandText( 'stn_optns_enter_abort' );
    			if( type === 'text' ) src += '\n\n' + expandText( 'stn_optns_enter_empty_str' );
    		}
    		let status = '\n\n' + expandText( 'stn_optns_setting_str' ) + ': ' + cstr
    					 + (range ? '\t\t\t\t' + expandText( 'stn_optns_range_str' ) + ':\t\t' + range : '')
    					 + (defawlt === undefined ? '' : '\t\t\t\t' + expandText( 'stn_optns_default_long' ) + ':\t\t'
    													 + (defawlt === null ? 'null' : dstr));
    		if( measurePhrase( status ) > MAXSCREENWIDTH ) { // break up into separate lines
    			status = '\n\n' + expandText( 'stn_optns_setting_str' ) + ': ' + cstr
    					 + (range ? '\n' + expandText( 'stn_optns_range_str' ) + ':\t\t' + range : '')
    					 + (defawlt === undefined ? '' : '\n' + expandText( 'stn_optns_default_str' ) + ':\t\t'
    													 + (defawlt === null ? 'null' : dstr));
    		}
    
    		let controlStr = '', dependStr = '', info = optionInfo[ option ];
    		if( info.controls.length > 0 ) {
    			controlStr = 'This option restricts ' + info.controls.join( ', ' ) + '.';
    		}
    		if( info.relyOnOptions.length > 0 ) {
    			depends.push.apply( depends, info.relyOnOptions );
    			// - like extend in Python, this adds each element
    			//   of 'relyOnOptions' array to the 'depends' array, vs
    			//   depends = depends.concat( info.relyOnOptions );
    			//   - this returns a NEW array (more garbage)
    		}
    		if( info.relyOnHostVars.length > 0 ) {
    			depends.push.apply( depends, info.relyOnHostVars );
    		}
    		if( depends.length ) {
    			dependStr += 'This option depends on ' + depends.join( ', ' ) + '.';
    		}
    		if( controlStr.length || dependStr.length ) {
    			controlStr = '\n\n(' + controlStr + (controlStr ? '  ': '');
    			controlStr += dependStr + ')';
    		}
    
    		src += status + controlStr + (errmsg ? '\n\n' + errmsg : '');
    		scrollText( rtnobj, src, option,				// input screens have 1 button; either 'more' button or input line
    					btn_count + 1,						// + 1 for button below
    					!!errmsg );							// errmsg makes endOfPage true, keeping user @ bottom of page
    		screen.message = rtnobj.message;
    		let rtn_key = rtnobj.choice_key;
    		choices[ rtn_key ] = rtnobj.choice_value;		// choices are ignored when textEntry is true
    		screen.textEntry = textEntry && rtn_key !== 'ZZ_more';
    
    		if( isSuppressed ) {
    			// top level/externally dependent options will still be listed
    			// - make all but the exit button unselectable
    			screen.initialChoicesKey = rtn_key;
    			for( let choice in choices ) {
    				if( choices.hasOwnProperty( choice ) ) {
    					if( choice !== rtn_key ) {
    						choices[ choice ].color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    						choices[ choice ].unselectable = true;
    					}
    				}
    			}
    		} else {
    			screen.initialChoicesKey = initialChoice || rtn_key;
    		}
    	}
    // : ' +  + '
    
    	// object for return values of scrollText, used by all the _choose* fns via mkChoicePage()
    	var rtnobj = {};
    
    	function resetOptionChoices( choices ) {
    		initButtons();									// update button text for oxp override
    		var defaultColor = expandText( 'stn_optns_default_color' ) || 'yellowColor';
    		for( var x in choices ) {						// re-use choices object
    			if( choices.hasOwnProperty( x ) ) {
    				choices[ x ].unselectable = false;
    				choices[ x ].color = defaultColor;
    				if( isCommonButton( x ) ) {				// are common buttons, always present
    					continue;
    				}
    				if( x !== 'ZZ_more' ) {					// is set after scrollText
    					freeChoice( choices[ x ] );
    				}
    				delete choices[ x ];
    			}
    		}
    	}
    
    	function chooseText( errmsg ) {
    		var that = chooseText;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		var option = currentChoice.option,
    			type = currentChoice.type;
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) {	// first time, create static part
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		screen.screenID = 'stn_optns_chooseText_' + keyPrefix + option;
    		if( type === 'display' ) {
    			let title = expandText( keyPrefix + option );
    			screen.title = title.slice( title.indexOf( ')' ) + 1 ).trim(); // skip over option type parameters
    			let src = expandText( keyPrefix + option + '_long' );
    			scrollText( rtnobj, src, option, 1 );			// 1 for either 'more' or return button
    			let choice_key = rtnobj.choice_key;
    			choices[ choice_key ] = rtnobj.choice_value;
    			screen.message = rtnobj.message;
    			screen.initialChoicesKey = choice_key;
    			screen.textEntry = false;
    			screen.allowInterrupt = changesMade.cag$stnOptsPending === 0;
    		} else {
    			expandKeys.stn_optns_option_key = option;
    			screen.title = expandText( 'stn_optns_choose_text', expandKeys );
    			mkChoicePage( rtnobj, screen, choices, 1, null, errmsg ); // initialChoice is null
    			screen.allowInterrupt = false;
    		}
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function chooseBoolean() {
    		var that = chooseBoolean;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		var option = currentChoice.option,
    			lastUsedColor = expandText( 'stn_optns_lastUsed_color' ) || 'greenColor';
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) { 	// first time, create static stuff
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = false;
    			screen.textEntry = false;
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		screen.screenID = 'stn_optns_chooseBoolean_' + keyPrefix + option;
    		// expand everytime to allow oxp override
    		choices[ 'AA_True' ]  = getChoice( expandText( 'stn_optns_boolean_true' ) );
    		choices[ 'BB_False' ] = getChoice( expandText( 'stn_optns_boolean_false' ) );
    		if( currentChoice.currently === true ) {
    			choices[ 'AA_True' ].color = lastUsedColor;
    		} else if( currentChoice.currently === false ) {
    			choices[ 'BB_False' ].color = lastUsedColor;
    		}
    		expandKeys.stn_optns_option_key = option;
    		screen.title = expandText( 'stn_optns_choose_boolean', expandKeys );
    		mkChoicePage( rtnobj, screen, choices, 2, null ); // initialChoice is null
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function chooseNumber( errmsg ) {
    		var that = chooseNumber;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		var option = currentChoice.option,
    			range = formatRange();
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) { 	// 1st time through; this part is static
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = false;
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		screen.screenID = 'stn_optns_chooseNumber_' + keyPrefix + option;
    		expandKeys.stn_optns_option_key = option;
    		expandKeys.stn_optns_number_range = range ? range : '';
    		screen.title = expandText( (range !== null ? 'stn_optns_choose_number_range'
    												   : 'stn_optns_choose_number'), expandKeys );
    		mkChoicePage( rtnobj, screen, choices, 1, null, errmsg );	// initialChoice is null
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function chooseVector( errmsg ) {
    		var that = chooseVector;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		var option = currentChoice.option;
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) { 	// 1st time through; this part is static
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = false;
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		screen.screenID = 'stn_optns_chooseVector_' + keyPrefix + option;
    		expandKeys.stn_optns_option_key = option;
    		screen.title = expandText( 'stn_optns_choose_vector', expandKeys );
    		mkChoicePage( rtnobj, screen, choices, 1, null, errmsg );	// initialChoice is null
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function chooseBits( value, current, statusMsg ) {
    		var that = chooseBits;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		var option = currentChoice.option,
    			defawlt = currentChoice.defawlt,
    			min = currentChoice.min,
    			max = currentChoice.max,
    			buttons, hasDefault = defawlt !== undefined,
    			currently = value === undefined ? currentChoice.currently : value;
    		var notInUseColor = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor',
    			invalidColor = expandText( 'stn_optns_invalid_color' ) || 'redColor',
    			lastUsedColor = expandText( 'stn_optns_lastUsed_color' ) || 'greenColor';
    		var clearAll = expandText( 'stn_optns_bitflag_clear_all' ),
    			setAll = expandText( 'stn_optns_bitflag_set_all' );
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) {	// first time, create static part
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = false;
    			screen.textEntry = false;
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		expandKeys.stn_optns_option_key = option;
    		var flags = currentChoice.selection;
    		var initialChoice = null;
    		if( !flags ) {									// author forgot bitflagStrs (error already reported)
    			screen.title = expandText( 'stn_optns_choose_number', expandKeys );
    			mkChoicePage( rtnobj, screen, choices, (hasDefault ? 4 : 3), initialChoice, statusMsg );
    			mission.runScreen( screen, setChoice.bind( so ) );
    			return;
    		}
    		var idx, len = flags.length,
    			allBits = currentChoice.allBits,
    			numBits = currentChoice.numBits;
    		choices[ 'WB_clear_all' ] = getChoice( clearAll );
    		if( currently === 0 ) {
    			choices[ 'WB_clear_all' ].color = notInUseColor;
    			choices[ 'WB_clear_all' ].unselectable = true;
    		}
    		choices[ 'WA_set_all' ] = getChoice( setAll );
    		if( currently === allBits ) {
    			choices[ 'WA_set_all' ].color = notInUseColor;
    			choices[ 'WA_set_all' ].unselectable = true;
    		}
    		screen.screenID = 'stn_optns_chooseBits_' + keyPrefix + option;
    
    		let changed = false;
    		if( value !== undefined ) {
    			let curr_val = getOptionValue( option );
    			changed = !equalValue( value, curr_val );
    			curr_val = freeValue( curr_val );
    		}
    		buttons = setCommonButtons( option, changed, hasDefault, choices, true );
    		screen.title = expandText( 'stn_optns_choose_bitflags', expandKeys );
    
    		if( numBits === undefined )	{					// needed for 'set all flags' cmd
    			currentChoice.numBits = numBits = len;		// - may be overridden by range
    		}
    		if( defawlt === -1 ) {
    			currentChoice.defawlt = allBits;
    		}
    		if( min === null ) {							// no min in range if supplied
    			currentChoice.min = min = 0;
    		}
    		if( max === null ) {							// no max in range if supplied
    			currentChoice.max = max = allBits;
    		}
    		var clearFlag = expandText( 'stn_optns_bitflag_clear' ),
    			setFlag = expandText( 'stn_optns_bitflag_set' );
    		for( idx = 0; idx < len; idx++ ) {
    			let flag = 1 << idx;
    			let choice = getChoice( flags[ idx ] + ': '
    									 + (currently & flag ? setFlag : clearFlag) );
    			let unselectable = flag < min || flag > max;
    			if( unselectable ) {
    				choice.color = invalidColor;
    			} else if( currently & flag ) {
    				choice.color = lastUsedColor;
    			} else {
    				choice.color = notInUseColor;
    			}
    			choice.unselectable = unselectable;
    			let choice_key = (idx < 10 ? '0' + idx : idx) + '_' + currently + '_' + flag;
    			choices[ choice_key ] = choice;
    			if( idx < numBits && current === flag ) {
    				initialChoice = choice_key;
    			}
    		}
    		if( typeof current === 'string' && current.indexOf( '_' ) >= 0 ) { // choice was not an individual bit flag
    			initialChoice = current === 'WA_set_all' ? 'WB_clear_all' :
    							current === 'WB_clear_all' ? 'WA_set_all' : current;
    		}
    		mkChoicePage( rtnobj, screen, choices, --idx + (hasDefault ? 4 : 3),  // idx + 3 or 4 buttons
    						initialChoice, statusMsg );
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function setCommonButtons( key, needsSave, hasDefault, choices, inclBlank ) {
    		// for buttons common to chooseBits & chooseChoice
    		var buttons = 0;
    		choices[ 'YY_save' ] = saveEditButton;
    		if( needsSave ) {
    			saveEditButton.color = expandText( 'stn_optns_default_color' ) || 'yellowColor';
    			saveEditButton.unselectable = false;
    		} else {
    			saveEditButton.color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    			saveEditButton.unselectable = true;
    		}
    		buttons++;
    		if( hasDefault ) {								// add reset to default button
    			choices[ 'XX_reset' ] = resetButton;
    			buttons++;
    		} else {
    			delete choices[ 'XX_reset' ];
    		}
    		if( inclBlank ) {
    			choices[ 'UU_blank' ] = blankLine;
    			buttons++;
    		} else {
    			delete choices[ 'UU_blank' ];
    		}
    		return buttons; // mkChoicePage will add 1 for the return/more button it adds
    	}
    
    	function chooseChoice( value, item, listNum, statusMsg ) {
    		var that = chooseChoice;
    		var choices = (that.choices = that.choices || {});	// re-used for runScreen; kept local to fn as static keys retained
    		var screen = (that.screen = that.screen || {});		//	 "
    		// args are all undefined when first enter page
    		var option = currentChoice.option,
    			defawlt = currentChoice.defawlt,
    			curr_btn = listNum === undefined ? currentChoice.currently : parseInt( listNum, 10 ),
    			buttons, hasDefault = defawlt !== undefined,
    			currently = value === undefined ? currentChoice.selection[ currentChoice.currently ] : value,
    			lastUsedColor = expandText( 'stn_optns_lastUsed_color' ) || 'greenColor';
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) {	// first time, create static part
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.allowInterrupt = false;
    			screen.textEntry = false;
    			screen.choices = choices;
    		} else {
    			resetOptionChoices( choices );
    		}
    		screen.screenID = 'stn_optns_chooseChoice_' + keyPrefix + option;
    
    		let changed = false;
    		if( value !== undefined ) {						// process user's selection
    			let curr_val = getOptionValue( option );
    			let index = currentChoice.selection.indexOf( value );
    			changed = !equalValue( index, curr_val );
    			curr_val = freeValue( curr_val );
    		}
    
    		buttons = setCommonButtons( option, changed, hasDefault, choices, true ); // true adds blank line after choices
    		expandKeys.stn_optns_option_key = option;
    		screen.title = expandText( 'stn_optns_choose_choice', expandKeys );
    		let items = currentChoice.selection,
    			idx, len = items.length;
    		let initialChoice = null;
    		for( idx = 0; idx < len; idx++ ) {
    			let selection = items[ idx ];
    			let choice = getChoice( typeof selection === 'string' && selection.length === 0 ? "''" :
    									selection === null ? 'null' : selection );
    			let choice_key = (idx < 10 ? '0' + idx : idx) + '_' + currently + '_' + selection + '_' + idx;
    			choices[ choice_key ] = choice;
    			if( idx === curr_btn ) {
    				choice.color = lastUsedColor;
    			}
    			if( listNum !== undefined && idx === curr_btn ) {
    				initialChoice = choice_key;
    			}
    		}
    		if( item === 'XX_reset' || item === 'YY_save' ) { // choice was not an item
    			initialChoice = item;
    		}
    		mkChoicePage( rtnobj, screen, choices, len + buttons, initialChoice, statusMsg );
    		mission.runScreen( screen, setChoice.bind( so ) );
    	}
    
    	function setChoice( choice ) {						// for selection made in an option's specific page
    		var that = setChoice;
    		var vector = (that.vector = that.vector || []);
    		var errmsg = (that.errmsg = that.errmsg || '');
    		vector.length = 0;
    
    		try {
    			var currValue = that.currValue;
    			var parse, value = null,
    				option = currentChoice.option,
    				type = currentChoice.type,
    				defawlt = currentChoice.defawlt,
    				currently = currentChoice.currently,
    				isSuppressed = optionInfo[ option ].isSuppressed;
    
    // log('setChoice, ===> ' + cd._showProps(choice, 'choice',0,1));
    			refreshSOKeys();
    			switch( true ) {
    				case choice === 'ZZ_more':
    						 if( type === 'text' || type === 'display' )   chooseText( errmsg );
    					else if( type === 'toggle' )					   chooseBoolean();
    					else if( type === 'number' || type === 'decimal' ) chooseNumber( errmsg );
    					else if( type === 'vector' )					   chooseVector( errmsg );
    					else if( type === 'bitflags' )					   chooseBits();
    					else if( type === 'choice' )					   chooseChoice();
    					else
    						log( so.name, 'setChoice, unknown type = ' + type );
    					return;
    
    				case isSuppressed:
    					break;
    
    				case choice === 'null':
    					setOptionValue( option, null );
    					break;
    
    				case !choice || choice.length === 0:
    					that.errmsg = errmsg = '';
    					scroller.stop();
    					displayOptions( 'ZZ_abort' );
    					return;
    
    				case choice === '+':
    				case type === 'text':
    					if( choice === '+' && defawlt !== undefined ) {
    						resetOptionValue( option );
    						break;
    					}
    					that.errmsg = errmsg = '';
    					if( choice === "''" ) {
    						setOptionValue( option, '' );
    						break;
    					}
    					if( choice.indexOf( "'" ) >= 0 ) { 	// ensure they match
    						let quotes = choice.match( quoteStripper );
    						if( quotes === null ) {
    							that.errmsg = errmsg = expandText( 'stn_optns_bad_match_quote' ) + " '";
    							chooseText( errmsg );
    							return;
    						}
    					}
    					if( choice.indexOf( '"' ) >= 0 ) { 	// ensure they match
    						let quotes = choice.match( dblQuoteStripper );
    						if( quotes === null ) {
    							that.errmsg = errmsg = expandText( 'stn_optns_bad_match_quote' ) + ' "';
    							chooseText( errmsg );
    							return;
    						}
    					}
    					setOptionValue( option, choice );
    					break;
    
    				case type === 'display':
    					scroller.stop();
    					displayOptions( 'ZZ_return' );
    					return;
    
    				case type === 'toggle':
    					if( choice === 'AA_True' && currently !== true ) {
    						setOptionValue( option, true );
    					} else if( choice === 'BB_False' && currently !== false ) {
    						setOptionValue( option, false );
    					}
    					break;
    
    				case type === 'number' || type === 'decimal':
    					that.errmsg = errmsg = '';
    					value = checkValue( choice, currentChoice, option );
    					if( currentChoice.error ) {
    						let reason;
    						if( currentChoice.error.indexOf( expandText( 'stn_optns_parse_err' ) ) >= 0 ) {
    							if( choice.match( parseDecimal ) ) {
    								reason = expandText( 'stn_optns_decimal_not_number' );
    							} else {
    								reason = expandText( 'stn_optns_bad_type' ) + ' ' + type;
    							}
    						}
    						that.errmsg = errmsg = expandText( 'stn_optns_bad_entry' ) 
    									+ ' "'  + choice + '", ' + reason;
    						chooseNumber( errmsg );
    						return;
    					}
    					value = valueInRange( value, currentChoice );
    					if( typeof value === 'string' )	 {
    						that.errmsg = errmsg = value;
    						chooseNumber( errmsg );
    						return;
    					}
    					setOptionValue( option, value );
    					break;
    
    				case type === 'vector':
    					that.errmsg = errmsg = '';
    					value = checkValue( choice, currentChoice, option );
    					if( currentChoice.error ) {
    						freeArray( value );
    						that.errmsg = errmsg = expandText( 'stn_optns_bad_entry' ) + ' "' + choice 
    									+ '", ' + expandText( 'stn_optns_bad_type' ) + ' ' + type;
    						chooseVector( errmsg );
    						return;
    					}
    					for( let idx = 0, len = value.length; idx < len; idx++ ) {
    						let test = valueInRange( value[ idx ], currentChoice );
    						if( typeof test === 'string' ) {
    							errmsg += (errmsg.length > 0 ? '\n' : '') + test;
    						} else {
    							vector.push( test );
    						}
    					}
    					freeArray( value );
    					if( errmsg.length > 0 ) {
    						that.errmsg = errmsg;
    						chooseVector( errmsg );
    						return;
    					}
    					setOptionValue( option, getArray( vector ) );
    					break;
    
    				case type === 'bitflags':
    					parse = choice.split( '_' ); 		// choices encoded as index_value_flag
    					let bits = parseInt( parse[ 1 ], 10 ),
    						flag = parseInt( parse[ 2 ], 10 ),
    						min = currentChoice.min,
    						max = currentChoice.max;
    					freeArray( parse );
    					if( choice === 'WA_set_all'
    							|| choice === 'WB_clear_all' ) { // can't use -1; only set known bits
    						flag = choice;
    						if( currValue === undefined ) {
    							currValue = getOptionValue( option );
    						}
    						let startBit = min ? min.toString(2).length - 1 : 0;
    						let stopBit	 = max ? max.toString(2).length
    										   : currentChoice.numBits.toString(2).length;
    						for( let idx = startBit; idx < stopBit; idx++ ) {
    							let bit = 1 << idx;
    							currValue = choice === 'WA_set_all' ? currValue | bit
    																: currValue & ~bit;
    						}
    					} else if( choice === 'YY_save' ) {
    						setOptionValue( option, currValue );
    						delete that.currValue;			// reset for next option's use
    						break;
    					} else if( choice === 'XX_reset' ) {
    						flag = choice;
    						currValue = defawlt;
    					} else if( choice === 'ZZ_return' ) {
    						delete that.currValue;			// reset for next option's use
    						break;
    					} else {
    						currValue = bits ^ flag;
    					}
    					that.currValue = currValue;
    					chooseBits( currValue, flag, expandText( 'stn_optns_curr_flags' ) + ': ' + currValue );
    					return;
    
    				case type === 'choice':
    					// choices encoded as index_value_item_btn# (value can have spaces!)
    					parse = choice.split( '_' );
    					let item = parse[ 2 ],
    						num = parse[ 3 ];
    					freeArray( parse );
    					if( choice === 'YY_save' ) {
    						// 'number' => assumed to be index into choices
    						if( typeof currValue === 'number' ) {
    							setOptionValue( option, currValue );
    						} else {
    							setOptionValue( option, currentChoice.selection.indexOf( currValue ) );
    						}
    						delete that.currValue;			// reset for next option's use
    						break;
    					} else if( choice === 'ZZ_return' ) {
    						delete that.currValue;			// reset for next option's use
    						break;
    					} else if( choice === 'XX_reset' ) {
    						item = choice;
    						if( typeof defawlt === 'number' ) { // defawlt can be either index or one of the choices
    							currValue = defawlt;
    						} else {
    							currValue = currentChoice.selection.indexOf( defawlt );
    						}
    					} else {
    						currValue = item;
    						// as choices can be defined as list of string or list of number,
    						// use currentChoice.selection to coerce value
    						if( typeof currentChoice.selection[0] === 'number' ) {
    							// value always arrives as string, as it's encoded in button name
    							currValue = currValue.indexOf('.') == -1
    										? parseInt( currValue, 10 ) : parseFloat( currValue );
    						}
    					}
    					that.currValue = (typeof currValue === 'string'
    									  && currValue.length === 0 ? "''" : currValue);
    					chooseChoice( currValue, item, num,
    								  expandText( 'stn_optns_curr_choice' ) + ': ' +
    									(typeof currValue === 'number' ? currentChoice.selection[ currValue ]
    																   : currValue) );
    					return;
    			}
    			that.errmsg = errmsg = '';
    			scroller.stop();
    			displayOptions();
    		} catch( err ) {
    			log( so.name, so._reportError( err, setChoice, choice ) );
    			throw err;
    		}
    	}
    
    	function valueInRange( value, choiceObj ) {
    		var min = choiceObj.min,
    			max = choiceObj.max;
    
    		var error = 'range';
    		do {
    			if( min !== null && value < min )
    				break;
    			if( max !== null && value > max )
    				break;
    			error = 'lowest';
    			if( min === null && lowestValue !== null && value < lowestValue )
    				break;
    			error = 'highest';
    			if( min === null && highestValue !== null && value > highestValue )
    				break;
    			return value;
    		} while( false );
    		let msg = expandText( 'stn_optns_bad_range_prefix' ) + ' "' + value + '" ';
    		if( error === 'range' ) {
    			msg += expandText( 'stn_optns_bad_range_suffix' ) + ' ' + (min || '') + ' : ' + (max || '');
    		} else if( error === 'lowest' ) {
    			msg += expandText( 'stn_optns_bad_range_lowest' ) + ' ' + lowestValue;
    		} else if( error === 'highest' ) {
    			msg += expandText( 'stn_optns_bad_range_highest' ) + ' ' + highestValue;
    		}
    		return msg;
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// option & page scrolling ////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    // : ' +  + '
    	var pageTemplate = { 'start': -1,  'count': -1, 'lastShown': false };
    
    	function Scroller() {
    		this.pageInfo = [];
    	}
    	Scroller.prototype.start = function() {
    		this.pageInfo.push( getObject( pageTemplate ) );
    // this._report('start');
    	}
    	Scroller.prototype.stop = function() {
    		freeObject( this.pageInfo.pop() );
    // this._report('stop');
    	}
    	Scroller.prototype.reset = function( count ) {
    		for( let idx = 0, len = this.pageInfo.length; idx < len; idx++ ) {
    			freeObject( this.pageInfo[ idx ] );
    		}
    		this.pageInfo.length = 0;
    		this.pageInfo.push( getObject( pageTemplate ) );
    		if( count !== undefined )
    			this.pageInfo[ 0 ].count = count;			// preserve # options as it's dynamic
    // this._report('reset');
    	}
    	Scroller.prototype._restart = function( count ) {
    		var level = this.pageInfo.length - 1;
    		this.pageInfo[ level ].start = 0;
    		this.pageInfo[ level ].count = count;			// preserve # options as it's dynamic
    		this.pageInfo[ level ].lastShown = false;
    // this._report('_restart');
    		return 0;										// convenience return
    	}
    	Scroller.prototype._bottomJustify = function( lines, count ) {
    		var level = this.pageInfo.length - 1;
    		var start = count <= lines ? 0 : count - lines;
    		this.pageInfo[ level ].start = start < 0 ? 0 : start;
    		this.pageInfo[ level ].count = count;			// preserve # options as it's dynamic
    		this.pageInfo[ level ].lastShown = true;
    // this._report('_bottomJustify');
    		return start;									// convenience return
    	}
    	Scroller.prototype.curr = function( count, lines, see ) {
    		var level = this.pageInfo.length - 1;
    		var start = this.pageInfo[ level ].start;
    		if( start < 0 ) {								// 1st time through
    			return this._restart( count );
    		}
    		if( start >= count ) {							// page shrunk
    			return this._bottomJustify( lines, count );
    		}
    		// set start so see is visible
    		var new_start = start;
    		if( see < start ) {								// make see the first line
    			new_start = see;
    		} else if( start + lines <= see ) {				// make see the final line
    			new_start = see - lines + 1;
    		}
    		if( new_start < 0 ) {
    			new_start = 0;
    // this._report('curr');
    		} else if( new_start + lines >= count ) {		// mixing 0-based indices and 1-based amounts
    // this._report('curr');
    			return this._bottomJustify( lines, count );
    		}
    		this.pageInfo[ level ].start = new_start;
    		this.pageInfo[ level ].count = count;
    		this.pageInfo[ level ].lastShown = new_start + lines > count - 1;
    // this._report('curr');
    		return new_start;
    	}
    	Scroller.prototype.incr = function() {
    		var level = this.pageInfo.length - 1;
    		var start = this.pageInfo[ level ].start;
    		this.pageInfo[ level ].start = start + 1;
    // this._report('incr');
    	}
    	Scroller.prototype.decr = function() {
    		var level = this.pageInfo.length - 1;
    		var start = this.pageInfo[ level ].start;
    		this.pageInfo[ level ].start = start > 0 ? start - 1 : 0;
    // this._report('decr');
    	}
    	Scroller.prototype.next = function( count, lines, endOfPage ) {
    		var level = this.pageInfo.length - 1;
    		var start = this.pageInfo[ level ].start;
    		var new_start;
    		if( start < 0 || count <= lines ) {				// 1st time on page OR fits w/o scrolling
    			new_start = 0;
    		} else if( endOfPage ) {					 	// stay at bottom
    			return this._bottomJustify( lines, count );
    		} else if( this.pageInfo[ level ].lastShown ) {	// displayed bottom, return to top
    			return this._restart( count );
    		} else {										// count > lines, must scroll
    			new_start = start + lines;					// next page
    			if( new_start + lines >= count ) {			// mixing 0-based indices and 1-based amounts
    				return this._bottomJustify( lines, count );
    			}
    		}
    		this.pageInfo[ level ].start = new_start;
    		this.pageInfo[ level ].count = count;
    		this.pageInfo[ level ].lastShown = new_start + lines > count - 1;
    // this._report('next');
    		return new_start;
    	}
    	Scroller.prototype._report = function( caller ) {
    		var stack = this.pageInfo.length;
    		log('    .' + mission.screenID + ', ' + caller + ', stack has ' + stack );
    		if( stack === 1 	|| true) {
    			// _showProps( obj, objName, newLine, show_deep, expand_arrays, show_type )
    			log( cd._showProps(this.pageInfo, 'stack', 1,2,1) );
    		}
    	}
    	Scroller.prototype.constructor = Scroller;
    	var scroller = new Scroller();
    
    	function scrollText( rtnobj, src, option, buttons, endOfPage ) {
    		var that = scrollText;
    		var pg_srclen = (that.pg_srclen = that.pg_srclen || {});  // cache srclen to detect if it changes
    
    		var lastPage, startLine = 0,
    			srclen = src.length,
    			choice_key, choice_value;					// for buttons & blank line
    		let cacheKey = keyPrefix + option;
    		if( !pg_srclen.hasOwnProperty( cacheKey )		// haven't measured
    				|| pg_srclen[ cacheKey ] !== srclen ) {	// or srclen changed, remeasure
    			measureText( option, src );
    			pg_srclen[ cacheKey ] = srclen;
    		}
    		var textInfo = textIndices[ keyPrefix ][ option ];
    		var totalLines = textInfo.length;
    		var lines = MAXSCREENLINES - buttons - 1;		// -1 for blank line above button
    		startLine = scroller.next( totalLines, lines - 1, endOfPage );// -1 to repeat last line of previous page
    		lastPage = endOfPage || ( totalLines - startLine <= lines );
    		let topIsBlank = true;
    		do {											// don't start page with a blank line
    			if( !textInfo || startLine >= textInfo.length )
    				break;
    			let sol = textInfo[ startLine ][ 0 ],
    				eol = textInfo[ startLine ][ 1 ];
    			for( let ti = sol; ti < eol; ti++ ) {
    				let chr = src[ ti ];
    				if( chr !== ' ' && chr !== '\t' ) {
    					topIsBlank = false;
    					break;
    				}
    			}
    			if( topIsBlank ) {
    				// scroll backwards so last line of previous page starts this page
    				if( totalLines - startLine > lines ) {
    					startLine--;
    					scroller.decr();
    				} else { // except when it's the last page (else loop forever!)
    					startLine++;
    					scroller.incr();
    				}
    			}
    		} while( topIsBlank && startLine < totalLines );
    
    		if( formatting ) {
    			formattedPage.length = 0;
    			formatEols( src, textInfo, startLine, lines );
    			rtnobj.message = formattedPage.join( '\n' );
    		} else {
    			let start = textInfo[ startLine ][ 0 ];
    			let lastln = startLine + lines - 1;
    			lastln = lastln > totalLines - 1 ? totalLines - 1 : lastln;
    			let end = textInfo[ lastln ][ 1 ];
    			rtnobj.message = src.slice( start, end );
    		}
    		if( startLine + lines < totalLines ) {			// more to show after this page
    			choice_key = 'ZZ_more';
    			choice_value = scrollButton;
    		} else { // it's less than a full page or we're at the bottom
    			choice_key = 'ZZ_return';
    			expandKeys.stn_optns_continue_key = expandText( keyPrefix + pageLabels[ currentScreenIdx ] + '_title' );
    			choice_value = getChoice( expandText( 'stn_optns_option_continue', expandKeys ) );
    		}
    		rtnobj.choice_key = choice_key;
    		rtnobj.choice_value = choice_value;
    		return totalLines;
    	}
    
    	function measureWord( word ) {
    		var that = measureWord;
    		var cache = (that.cache = that.cache || {});
    
    		if( !cache.hasOwnProperty( word ) ) {
    			cache[ word ] = strFontLen( word );
    		}
    		return cache[ word ];
    	}
    
    	function measurePhrase( str, start, end ) { 		// measure all char.s but '\n's
    														// called by measureText & mkChoicePage (for status line)
    		var that = measurePhrase;
    		var phrase = (that.phrase = that.phrase || []);
    		phrase.length = 0;
    
    		if( start >= end )
    			return 0;
    		var dst = 0, src = start || 0, len = str.length;
    		if( !end || end > len ) {
    			end = len;
    		}
    		while( src < end ) {
    			let chr = str[ src ];
    			if( chr !== '\n' )
    				phrase[ dst++ ] = chr;
    			src++;
    		}
    		// for arrays, defaultFont.measureString assumes spaces between each element
    		return strFontLen( phrase ) - (phrase.length - 1) * SpaceLen;
    	}
    
    	var textIndices = {}; 		// cache ends of each line so only measure once
    	var formattedPage = [];
    	var wordSplitter = /[ \t\n]+/;
    
    	function measureText( option, str ) { 				// measure str from start, store index of line ends
    		// the way the core source reads, if a '\n' is encountered before a line is forced to wrap,
    		// all its spaces are preserved.  But if it's long enough that it must be formatted, it gets
    		// tokenized (so all runs of space char.s are replaced with a single space char), up to the point
    		// where the remainder won't wrap, after which spaces are again preserved.
    		// see addLongText in GuiDisplayGen.m
    		var that = measureText;
    		var CRs = (that.CRs = that.CRs || []);
    		CRs.length = 0;
    
    		var lines = 0, eolCache, index,
    			strlen = str.length;
    		if( !textIndices.hasOwnProperty( keyPrefix ) ) {// create entry for oxp
    			textIndices[ keyPrefix ] = getObject();
    		}
    		let oxpCache = textIndices[ keyPrefix ];
    		if( !oxpCache.hasOwnProperty( option ) ) {		// create entry for this block of text
    			oxpCache[ option ] = getArray();
    		} else {										// we're re-measuring, toss previous entry
    			oxpCache[ option ].length = 0;
    		}
    		eolCache = oxpCache[ option ];
    		for( index = 0; index < strlen; index++ ) {		// find all newline chars
    			let idx = str.indexOf( '\n', index );
    			if( idx < 0 ) break;
    			CRs.push( idx );
    			index = idx;
    		}
    		if( index < strlen ) {							// append newline if necessary
    			CRs.push( strlen );
    		}
    		index = 0;
    		var end, chr, format, lineF, wordF, currCR,
    			tokenizing, lastCR = 0;
    		for( var ci = 0, len = CRs.length; ci < len; ci++ ) {
    			tokenizing = false;
    			end = currCR = CRs[ ci ];
    			chr = str[ end ];
    			while( end > lastCR && (chr === ' ' || chr === '\t' || chr === '\n') ) {
    				end--;									// strip trailing spaces
    				chr = str[ end ];
    			}
    			if( end !== CRs[ ci ] ) {
    				end++;									// always point at char after last
    			}
    			lineF = measurePhrase( str, index, end );
    			format = floor(lineF / MAXSCREENWIDTH) > 0;	// # line needing formatting
    			tokenizing = format;
    			let wi = 0, words = str.slice( index, end ).split( wordSplitter ),
    				wlen = words.length;
    			while( index <= end ) {						// <= vs < so we preserve imbedded \n char's
    				if( !format ) {							// output as is
    					eolCache.push( [index, currCR, lineF, (tokenizing ? null : false)] );
    					lines++;
    					index = currCR + 1;					// +1 to start of next line; currCR is a '\n' char
    					break;
    				} else {								// tokenized output until one line full
    					lineF = 0; wordF = 0;
    					let startIndex = index, fullLine = null;
    					for( ; wi < wlen; wi++ ) {
    						let word = words[ wi ];
    						wordF = measureWord( word );
    						if( wordF > MAXSCREENWIDTH ) {	// it happens, I did it my own self!
    							let wdIdx = str.indexOf( word ),
    								wdlen = word.length;
    							if( lineF > 0 ) {
    								eolCache.push( [startIndex, wdIdx, lineF, null] );
    								lines++;
    								lineF = 0;
    							}
    							eolCache.push( [wdIdx, wdIdx + wdlen, wordF, false] );
    							wdIdx += wdlen;
    							eolCache.push( [wdIdx, wdIdx, 0, 'stub'] );
    							lines += 2;					// core just outputs it and it'll create a blank line
    							startIndex = wdIdx;
    							continue;
    						}
    						if( lineF + SpaceLen + wordF > MAXSCREENWIDTH ) { // word won't fit
    							fullLine = true;
    							break;
    						}
    						if( lineF === 0 ) {
    							lineF = wordF;
    						} else {
    							lineF += SpaceLen + wordF;
    						}
    						if( wi < wlen - 1 ) {
    							index = str.indexOf( words[ wi + 1 ], index + word.length );
    						} else {						// no more words
    							index = currCR + 1;			// skip the return, break from while loop
    						}
    					} // end for line
    					eolCache.push( [startIndex, index - 1, lineF, fullLine] );
    					lineF = measurePhrase( str, index, end );
    				} // endif !format
    				lines++;
    				format = floor(lineF / MAXSCREENWIDTH) > 0;
    			} // end while( index <= end ), ie. an input line
    			lastCR = currCR;
    		} // end for loop over all CRs
    
    		return lines;
    	}
    
    	function removeDups( list1, list2 ) {				// remove dups so each member is unique; list2 bows to list1
    		if( !list1 )
    			return;
    		var list = list2 || list1;
    		var lstlen = list.length;
    		while( lstlen-- ) {
    			let item = list[ lstlen ];
    			let idx = list1.indexOf( item );
    			if( (!list2 && idx !== lstlen)
    					|| (list2 && idx >= 0) ) {			// splice creates garbage, so remove it manually
    				let started = list.length;
    				for( let li = lstlen; li < started; li++ ) {
    					list[ li ] = list [ li + 1 ];
    				}
    				list.length = started - 1;
    			}
    		}
    	}
    
    	function compareNumbers( a, b ) { return a - b; }	// sort function
    
    	const TRAILERS = ",.;:})?!";
    	function formatEols( str, strLines, start, lines ) {
    		var that = formatEols;
    		var fmtLn = (that.fmtLn = that.fmtLn || []);
    		var primeBreaks = (that.primeBreaks = that.primeBreaks || []);	// indices of prime spaces for expansion
    		var pettyBreaks = (that.pettyBreaks = that.pettyBreaks || []);	// indices of lesser spaces
    		var lnNum = 0,	strLinesLen = strLines.length,
    //			  strlen = str.length,	//cagfmt
    			currLine = start,
    			cutoff = start + lines < strLinesLen ? start + lines : strLinesLen;
    
    //log( so.name, 'formatEols, currLine = ' + currLine + ', cutoff = ' + cutoff + ', strLinesLen = ' + strLinesLen //cagfmt
    //	  + ', lines = ' + lines + ', SpaceLen = ' + SpaceLen.toFixed(3) ); //cagfmt
    //let debug = false, cag = ''; //cagfmt
    
    		var sol, eol, lineF, tokenized, fullStretch, lastChI, wdStart;
    		while( lnNum < lines && currLine < cutoff && currLine < strLinesLen ) {
    			primeBreaks.length = 0;
    			pettyBreaks.length = 0;
    
    			// each item in strLines has:
    			//	index of 1st char, index of eol char, font length from 1st to eol char, and
    			//	whether it was tokenized: false vs true, null, where null indicats a non-tokenized
    			//	line generated by line breaks (not str input), the difference being that false
    			//	leaves any leading whitespace while null strips it
    			[sol, eol, lineF, tokenized] = strLines[ currLine ];
    			if( tokenized === 'stub' ) {						// it's a stub for a wordF > MAXSCREENWIDTH
    				currLine++;										//	 to account for blank line generated when core
    				continue;										//	 outputs an overlong text line
    			}
    
    //debug = str.slice( sol, eol ).indexOf( '2013.03.31.' ) >= 0; //cagfmt
    ////debug = debug || str.slice( sol, eol ).indexOf( '(OXP Performance' ) >= 0; //cagfmt
    //cag = ''; //cagfmt
    
    //cag += 'sol = ' + sol + ', eol = ' + eol + ', lineF = ' + lineF.toFixed(3) + ', tokenized = ' + tokenized + '\n'; //cagfmt
    //cag += '"' + str.slice(sol,eol).replace(/\n/g, 'cR').replace(/\t/g, 'tB') + '"\n'; //cagfmt
    			let spaces = 0, doubleSpc = 0,
    				slack = floor((MAXSCREENWIDTH - lineF) / SpaceLen);// in spaces
    //cag += 'initial slack = ' + slack; //cagfmt
    
    			fullStretch = tokenized === true ? true :
    						  tokenized === null ? lineF > MAXSCREENWIDTH * 0.8 : false;
    			if( tokenized === null && !fullStretch ) {			// last line in a paragraph
    				slack = floor(slack * lineF / MAXSCREENWIDTH);	// don't over-stretch short lines
    			}
    			lastChI = eol - 1;									// eol always points at ' ', '\n' or = strllen
    			wdStart = -1;
    
    //if( eol < strlen && str[eol] !== '\n' && str[eol] !== ' ' )  //cagfmt
    //	  cag += '\neol is OFF, point @ "' + str[eol] + '", eol = ' + eol  //cagfmt
    //		  + '...' + str.slice(eol-20, eol).replace(/\n/g, 'cR') //cagfmt
    //		  + '|' + str[eol] + '|' //cagfmt
    //		  + str.slice(eol+1, eol+20).replace(/\n/g, 'cR') + '...' //cagfmt
    //		  ; //cagfmt
    
    			while( str[ lastChI ] === ' ' || str[ lastChI ] === '\t' ) {
    				lastChI--;			// index of last non-space char
    			}
    //cag += ', lastChI = ' + lastChI + ', slack: ' + slack + '\n'; //cagfmt
    			fmtLn.length = 0;
    			for( let si = sol, fl = 0; si <= lastChI; si++ ) {	// copy a line
    				let chr = str[ si ];
    				if( chr === ' ' || chr === '\t' ) {
    					if( tokenized !== false && fl === 0 )		// gobble leading whitespace
    						continue;
    					if( wdStart > 0 && fl - wdStart < 3 ) {		// short word, cannot be 1st one
    						pettyBreaks.unshift( wdStart - 1 );		// space before word
    						pettyBreaks.push( fl );					//	 unshift as prefer space before if can't have both
    					}
    					wdStart = -1;
    					spaces++;									// count breaks for doubling up on spaces
    					if( tokenized !== false ) {
    						fmtLn[ fl++ ] = ' ';
    						while( si + 1 < lastChI ) {
    							let nchr = str[ si + 1 ];
    							if( nchr !== ' ' && nchr !== '\t' )
    								break;
    							si++;								// discard extra whitespace if tokenized
    						}
    						continue;
    					}
    				}
    				if( wdStart < 0 ) {
    					wdStart = fl;
    				}
    				fmtLn[ fl ] = chr;
    				if( tokenized === false ) {						// leave line untouched
    					fl++;
    					continue;
    				}
    				let prev = si > sol ? str[ si - 1 ] : null;
    				let next = si < lastChI ? str[ si + 1 ] : null;
    				if( si + 1 < lastChI && TRAILERS.indexOf( chr ) >= 0 ) { // + 1 to avoid eol
    					if( next === ' ' || next === '\t' )
    						primeBreaks.unshift( fl + 1 );			// trailing punctuation
    				}
    				if( si - 1 > sol && (chr === '(' || chr === '{' )) { // - 1 to avoid sol
    					if( prev === ' ' || prev === '\t'  )
    						primeBreaks.push( fl - 1 );				// leading punctuation
    				}
    				if( chr === "'" ) {
    					if( next === ' ' || next === '\t'  ) {		// closing quote
    						if( si + 1 < lastChI ) pettyBreaks.push( fl + 1 );
    					} else if( prev === ' ' || prev === '\t' ) {// opening quote
    						if( si - 1 > sol ) pettyBreaks.unshift( fl - 1 );
    					}	//	else it's an apostrophe, do nothing
    				}
    				if( chr === '\\' && next === '"' ) {	// escaped quotes (? any others) %%
    					let next2 = si + 1 < lastChI ? str[ si + 2 ] : null;
    					if( next2 === ' ' || next2 === '\t'	 ) {	// closing quote
    						if( si + 2 < lastChI ) pettyBreaks.push( fl + 2 );
    					} else if( prev === ' ' || prev === '\t' ) {// opening quote
    						if( si - 1 > sol ) pettyBreaks.unshift( fl - 1 );
    					}
    				}
    				if( chr === '%' && si + 1 <= lastChI ) {		 // oolite plist support '%' escaping for brackets []
    					let nextChr = str[ si + 1 ];
    					if( nextChr === ']' ) pettyBreaks.push( fl + 1 );
    					if( nextChr === '[' ) pettyBreaks.unshift( fl - 1 );
    				}
    				fl++;
    			}
    
    //cag += '\nprimeBreaks: ' + primeBreaks.length + '\n'; //cagfmt
    //let cagsln = fmtLn.join(''); //cagfmt
    //for( let idx = 0, len = primeBreaks.length; idx < len; idx++ ) { //cagfmt
    //	  let prime = primeBreaks[idx]; //cagfmt
    //	  cag += ', ' + prime + ': "' + cagsln.slice(prime, prime + 10).replace(/\n/g, 'cR') + '"'; //cagfmt
    // } //cagfmt
    //cag += '\npettyBreaks: ' + pettyBreaks.length + '\n'; //cagfmt
    
    //for( let idx = 0, len = pettyBreaks.length; idx < len; idx++ ) { //cagfmt
    //	  let petty = pettyBreaks[idx]; //cagfmt
    //	  cag += ', ' + petty + ': "' + cagsln.slice(petty, petty + 10).replace(/\n/g, 'cR') + '"'; //cagfmt
    // } //cagfmt
    //cag += '\n'; //cagfmt
    
    			if( tokenized !== false ) {
    				removeDups( primeBreaks );
    				removeDups( pettyBreaks );
    				removeDups( primeBreaks, pettyBreaks );
    // _showProps( obj, objName, newLine, show_deep, expand_arrays, show_type )
    //cag += cd._showProps( primeBreaks, 'primeBreaks.dups', 1, 1, 1 ) + ' len: ' + primeBreaks.length + '\n'; //cagfmt
    //cag += cd._showProps( pettyBreaks, 'pettyBreaks.dups', 1, 1, 1 ) + ' len: ' + pettyBreaks.length + '\n'; //cagfmt
    
    				let len = primeBreaks.length;						// primeBreaks get 1st priority
    				if( len > 0 ) {
    					if( slack >= len ) {							// enough room for all
    						slack -= len;
    					} else {
    						primeBreaks.length = slack;
    						slack = 0;
    					}
    				}
    				len = pettyBreaks.length;							// pettyBreaks get 2nd priority
    				if( len > 0 ) {
    					if( slack >= len ) {							// enough room for all
    						slack -= len;
    					} else {
    						pettyBreaks.length = slack;
    						slack = 0;
    					}
    				}
    				len = pettyBreaks.length;
    				while( len-- ) {									// concat arrays
    					let brk = pettyBreaks.pop();
    					if( primeBreaks.indexOf( brk ) < 0 ) {			// no dup, else steal from doubleSpc
    						primeBreaks.push( brk );
    					}
    				}
    				primeBreaks.sort( compareNumbers );
    
    //cag += cd._showProps( primeBreaks, 'primeBreaks.concat.sort', 1, 1, 1 )  //cagfmt
    //	  + ' len: ' + primeBreaks.length + '\n'; //cagfmt
    
    				if( slack > 0 ) {									// use remaining slack to double up spaces
    					doubleSpc = slack <= spaces || fullStretch		// use all slack
    								? slack : spaces;					//	 or double as many as slack allows
    //cag +=  'fullStretch = ' + fullStretch + ', doubleSpc = ' + doubleSpc + ', slack = ' + slack + ', spaces = ' + spaces + '\n'; //cagfmt
    				}
    			}
    
    //let tmpF = strFontLen( fmtLn.join('') ); //cagfmt
    //if( tmpF > MAXSCREENWIDTH ) //cagfmt
    //	  cag += '\n,\t* fmtLn * over MAXSCREENWIDTH, tmpF = ' + tmpF.toFixed(3) + ': "' + fmtLn.join('') + '"';	 //cagfmt
    
    			let primeBrk = primeBreaks.shift(),
    				line = '';
    
    /// //////////////////////////////////////////////
    //cag += ' (' + spaces + ' spaces), slack -> ' + slack + '\n'; //cagfmt
    //if( primeBrk ) { //cagfmt
    //	  cag += 'primeBrk = ' + primeBrk + ': "' + cagsln.slice(primeBrk, primeBrk + 10).replace(/\n/g, 'cR'); //cagfmt
    //	  for( let idx = 0, len = primeBreaks.length; idx < len; idx++ ) { //cagfmt
    //		  let lead = primeBreaks[idx]; //cagfmt
    //		  cag += '", ' + lead + ': "' + cagsln.slice(lead, lead + 10).replace(/\n/g, 'cR'); //cagfmt
    //	  } //cagfmt
    //	  cag += '"'; //cagfmt
    // } //cagfmt
    //cag += '\ndoubleSpc = ' + doubleSpc + (fullStretch ? ', fullStretch = ' + fullStretch : '') + '\n'; //cagfmt
    //cag += '\n'; //cagfmt
    //if( debug ) log( so.name, cag ); //cagfmt
    //cag = ''; //cagfmt
    /// //////////////////////////////////////////////
    
    			for( let si = 0, flLen = fmtLn.length; si < flLen; si++ ) { // assemble new line
    				let chr = fmtLn[ si ];
    
    //if( chr === ' ' || chr === '\t' ) //cagfmt
    //	  cag += 'si = ' + (si<10?' ':'') + si + ',	 chr = "' + cagsln.slice(si, si + 10).replace(/\n/g, 'cR') + '"'; //cagfmt
    
    				if( chr === ' ' || chr === '\t' ) {
    					if( primeBrk && primeBrk === si ) {
    						if( lineF + SpaceLen > MAXSCREENWIDTH )
    							break;	// safety valve
    						line += ' ';
    						lineF += SpaceLen;
    						primeBrk = primeBreaks.shift();
    //cag +=  ', primeBrk = ' + (primeBrk ||''); //cagfmt
    //cag += ', lineF = ' + lineF.toFixed(3); //cagfmt
    					}
    					if( fullStretch && doubleSpc > 0 ) {
    						let extras = floor(doubleSpc/spaces + 0.5);
    						doubleSpc -= extras;
    						spaces--;
    //cag += ', adding extras = ' + extras + ', doubleSpc = ' + doubleSpc + '"';   //cagfmt
    						while( extras-- ) {
    							if( lineF + SpaceLen > MAXSCREENWIDTH )
    								break;// safety valve
    							line += ' ';
    							lineF += SpaceLen;
    						}
    //cag += ', lineF = ' + lineF.toFixed(3); //cagfmt
    					} else if( doubleSpc > 0 ) {
    						if( lineF + SpaceLen > MAXSCREENWIDTH )
    							break;	// safety valve
    						line += ' ';
    						lineF += SpaceLen;
    						doubleSpc--;
    //cag += ', doubleSpc = ' + doubleSpc; //cagfmt
    //cag += ', lineF = ' + lineF.toFixed(3); //cagfmt
    					}
    				}
    				line += fmtLn[ si ];
    //if( chr === ' ' || chr === '\t' ) cag += '\n'; //cagfmt
    			}
    			formattedPage.push( line );
    			currLine++;
    			lnNum++;
    
    //tmpF = strFontLen( line ); //cagfmt
    //cag += '\n.\t"' + line.replace(/\n/g, 'cR') + '"	 ' + tmpF.toFixed(3) + '\n'; //cagfmt
    //cag += '.\t 0....5....1....5....2....5....3....5....4....5....$....5....6....5....7....5....8....5....9....5....0\n'; //cagfmt
    //cag += '.\t"' + fmtLn.join('').replace(/\n/g, 'cR') + '"\n\n'; //cagfmt
    //if( tmpF > MAXSCREENWIDTH ) //cagfmt
    //	  cag +=  'line over MAXSCREENWIDTH, tmpF = ' + tmpF.toFixed(3) + ': "' + line + '"\n\n';	  //cagfmt
    //if( debug ) log( so.name, cag ); //cagfmt
    
    		}
    		return currLine < strLinesLen - 1; // is there more, ie. do we scroll
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// functions for oxp variables ////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    // : ' +  + '
    	function stripPrefix( term ) {
    		if( startsWith( term, hostVarPrefix ) ) {
    			return term.slice( hostVarPrefix.length );
    		}
    		return term;
    	}
    
    	function isSingleQuoted( term ) {
    		return startsWith( term, "'" ) && endsWith( term, "'" );
    	}
    
    	function stripQuotes( term ) {
    		if( isSingleQuoted( term ) ) {
    			return term.slice( 1, term.length - 2 );
    		} else if( startsWith( term, '"' ) && endsWith( term, '"' ) ) {
    ///should never happen
    			log( 'stripQuotes, * * *  how did term become double quoted: ' + term );
    			term = term.slice( 1, term.length - 2 );
    			return term.replace( '"', "'" );
    		}
    		return term;
    	}
    
    	function confirmReset() {
    		var that = confirmReset;
    		var choices = (that.choices = that.choices || {'01_yes': 'Yes', '02_no': 'No'});
    		var screen = (that.screen = that.screen || {});
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) { // static
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.screenID = 'stn_optns_confirmReset_' + keyPrefix;
    			screen.allowInterrupt = false;
    			screen.textEntry = false;
    			screen.choices = choices;
    			screen.initialChoicesKey = '02_no';
    		}
    
    		var currentScreen = pageLabels[ currentScreenIdx ],
    			page_key = keyPrefix + currentScreen + '_title';
    		expandKeys.stn_optns_reset_page = expandText( page_key );
    		screen.title = expandText( 'stn_optns_reset_title', expandKeys );
    		screen.message = expandText( 'stn_optns_reset_summary', expandKeys );
    		mission.runScreen( screen, performReset.bind( so ) );
    	}
    
    	function performReset( choice ) {
    		try {
    			if( choice === '01_yes' ) {
    				let currentScreen = pageLabels[ currentScreenIdx ],
    					options = optionPages[ currentScreen ];
    				for( let idx = 0, len = options.length; idx < len; idx++ ) {
    					resetOptionValue( options[ idx ] );
    				}
    			}
    			displayOptions( 'XX_reset' );
    		} catch( err ) {
    			log( so.name, so._reportError( err, performReset, choice ) );
    			throw err;
    		}
    	}
    
    	function equalValue( a, b ) {
    		if( Array.isArray( a ) ) {
    			if( !Array.isArray( b ) )
    				return false;
    			if( a.length !== b.length )
    				return false;
    			for( let idx = 0, len = a.length; idx < len; idx++ ) {
    				if( a[ idx ] !== b[ idx ] )
    					return false;
    			}
    			return true;
    		}
    		if( typeof a === 'object' ) {
    			if( typeof b !== 'object' ) return false;
    			let asprops = Object.getOwnPropertyNames( a );
    			let bsprops = Object.getOwnPropertyNames( b );
    			if( asprops.length !== bsprops.length ) return false;
    			for( let idx = 0, len = asprops.length; idx < len; idx++ ) {
    				if( asprops.indexOf( bsprops[ idx ] ) < 0 )
    					return false;
    			}
    			asprops = freeArray( asprops );
    			bsprops = freeArray( bsprops );
    			for( let x in a ) {
    				if( a.hasOwnProperty( x ) && b.hasOwnProperty( x )
    						&& a[ x ] === b[ x ] ) {
    					continue;
    				} else {
    					return false;
    				}
    			}
    			return true;
    		}
    		return a === b;
    	}
    
    	function getHostValue( option ) {
    		if( !hostOxp.hasOwnProperty( hostVarPrefix + option ) )
    			return; // undefined
    		return hostOxp[ hostVarPrefix + option ];
    	}
    
    	function setHostVar( option, value ) {
    		hostOxp[ hostVarPrefix + option ] = copyValue( value );
    	}
    	
    	function getOptionValue( option, fromHost ) {				// fromHost => try getting value from host first
    		if( !fromHost ) {
    			if( changesMade.hasOwnProperty( option ) ) {
    				return copyValue( changesMade[ option ] );
    			}
    		}
    		let hostVar = getHostValue( option );			// change not pending, return curr. value
    		return copyValue( hostVar );
    	}
    	
    	function setOptionValue( option, value ) {
    		
    		function setCurrently( option, value ) {
    			freeValue( info.currently )
    			info.currently = copyValue( value );
    		}
    		
    		var that = setOptionValue;
    		var soKeys = (that.soKeys = that.soKeys || {});
    		clearObject( soKeys );
    
    		var info = optionInfo[ option ];
    		var hostVal = getHostValue( option ), 
    			newVal = changesMade[ option ];
    		if( changesMade.hasOwnProperty( option ) ) {	// already been changed
    			if( equalValue( value, newVal ) ) {			// same as existing change, nothing to do
    				return;
    			} else if( equalValue( value, hostVal ) ) {	// changed back to original value
    				setCurrently( option, value );
    				removeChangeMade( option );
    				doAssignments( option );
    				doExecutes( option );
    				return;
    			}
    		} else if( equalValue( value, hostVal ) ) {		// entered original value
    			return;
    		}
    		setCurrently( option, value )
    
    		if( changesMade.hasOwnProperty( 'cag$stnOptsPending' ) ) {
    			changesMade[ 'cag$stnOptsPending' ] += 1;
    		} else {
    			changesMade[ 'cag$stnOptsPending' ] = 1;
    		}
    		missionKeys.stn_optns_changes_count = changesMade[ 'cag$stnOptsPending' ];
    		if( missionKeys.stn_optns_changed.hasOwnProperty( option ) ) {
    			freeValue( missionKeys.stn_optns_changed[ option ] );
    		}
    		if( Array.isArray( value ) ) {					// don't reference originals, use copy
    			changesMade[ option ] = getArray( value );
    			missionKeys.stn_optns_changed[ option ] = getArray( value );
    			if( missionKeys.hasOwnProperty( option ) ) {
    				missionKeys[ option ] = getArray( value );
    			}
    		} else if( typeof value === 'object' ) {
    			changesMade[ option ] = getObject( value );
    			missionKeys.stn_optns_changed[ option ] = getObject( value );
    			if( missionKeys.hasOwnProperty( option ) ) {
    				missionKeys[ option ] = getObject( value );
    			}
    		} else {
    			changesMade[ option ] = value;
    			missionKeys.stn_optns_changed[ option ] = value;
    			if( missionKeys.hasOwnProperty( option ) ) {
    				missionKeys[ option ] = value;
    			}
    		}
    		doAssignments( option );
    		doExecutes( option );
    	}
    
    	function resetOptionValue( option ) {
    		var info = optionInfo[ option ];
    		if( info.type === 'display' )
    			return;										// has no variable associated with option
    		if( info.option && info.defawlt !== undefined ) {
    			setOptionValue( option, info.defawlt );
    		}
    	}
    	
    	function removeChangeMade( option ) {
    		var that = removeChangeMade;
    		var soKeys = (that.soKeys = that.soKeys || {});
    		clearObject( soKeys );
    
    		if( changesMade.hasOwnProperty( option ) ) {
    			freeValue( changesMade[ option ] );
    			delete changesMade[ option ];
    			if( changesMade[ 'cag$stnOptsPending' ] <= 1 ) {
    				delete changesMade[ 'cag$stnOptsPending' ];
    				missionKeys.stn_optns_changes_count = 0;
    			} else {
    				changesMade[ 'cag$stnOptsPending' ] -= 1;
    				missionKeys.stn_optns_changes_count -= 1;
    			}
    		}
    		if( missionKeys.stn_optns_changed.hasOwnProperty( option ) ) {
    			freeValue( missionKeys.stn_optns_changed[ option ] );
    			delete missionKeys.stn_optns_changed[ option ];
    		}
    	}
    
    // : ' +  + '
    	var optionsChanged = [];
    	var pagesChanged = [];
    	var changesDetail = [];
    
    	function saveChoices() {							// commit changes to host oxp
    		optionsChanged.length = 0;						// re-use arrays
    		pagesChanged.length = 0;
    		changesDetail.length = 0;
    		var rpt = 'none', longestF = 0;
    		var rmdr = getRemindObj();
    		if( changesMade.hasOwnProperty( 'cag$stnOptsPending' ) ) {
    			for( let option in changesMade ) {
    				if( changesMade.hasOwnProperty( option ) ) {
    					if( option === 'cag$stnOptsPending' )
    						continue;
    					// if( !hostOxp.hasOwnProperty( hostVarPrefix + option ) ) continue;
    					// - no, this can be used to init non-existent properties
    					let hostVal = getOptionValue( option, true );
    					let newVal = changesMade[ option ];
    					setHostVar( option, newVal )
    					if( typeof newVal === 'object' ) {
    						// remove only reference so object will persist in hostOxp
    						// - later call to clearObject() would free any arrays/objects
    						delete changesMade[ option ];
    					}
    					if( rmdr.reportSummary ) {
    						let lenF = measureWord( option );
    						if( lenF > longestF ) {
    							longestF = lenF;
    						}
    						changesDetail.push( [option, hostVal, newVal] );
    					} else {
    						freeValue( hostVal );
    					}
    					optionsChanged.push( option ); 		// save variable name
    					let page;
    					if( optionInfo.hasOwnProperty( option ) ) {
    						page = optionInfo[ option ].page;
    					} else {
    						page = 'unknown!';
    					}
    					if( pagesChanged.indexOf( page ) < 0 )
    						pagesChanged.push( page );		// one of two arrays sent via notifyCallback 
    				}
    			}
    		}
    		if( notifyCallback )							// inform hostOxp what's been altered
    			notifyCallback( optionsChanged, pagesChanged );
    		if( callPWSG )									// write to missionVariables; user still req'd to save game
    			hostOxp.playerWillSaveGame();
    		if( rmdr.reportSummary ) {
    			rpt = '';
    			longestF += SpaceLen * 4;					// add small indent for list (don't touch window's edge)
    			let spacing = paddingText( '', SpaceLen * 2 ),
    				spacingLen = SpaceLen * 2;				// spacing between column of options & values
    			for( let ci = 0, clen = changesDetail.length; ci < clen; ci++ ) {
    				let [option, hostVal, newVal] = changesDetail[ ci ];
    				let heading = paddingText( option, longestF ) + option + COLON + spacing;
    				let prev = rptChange( option, hostVal ),
    					curr = rptChange( option, newVal ),
    					info = optionInfo[ option ];
    				if( info.type === 'bitflags' && prev === '' && curr === '' ) {
    					// rptChange will return the value if it cannot find the bitflagStrs
    					rpt += rptBitFlags( heading, longestF + measureWord( COLON ) + spacingLen );
    							// these add space between option & flags
    				} else {
    					rpt += wrapRight( heading + prev + ARROW + curr, -1, longestF, longestF );
    				}
    				freeValue( hostVal );
    			}
    			reportSavegame( rpt );
    		} else if( rmdr.remindAutosave ) {
    			reportSavegame( 'no_summary' );
    		} else {
    			clearObject( changesMade, true );			// true => keepObjects (ie. del prop only, not what it references)
    		}
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// functions for summary page /////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    	var fromCol = [], toCol = [];						// for formatting bit flag summary
    
    	function wrapRight( str, strLen, indent, shortenFirst ) {
    		shortenFirst = shortenFirst || 0;
    		if( !strLen || strLen <= 0 ) {					// caller doesn't have length on hand
    			strLen = measureWord( str );
    		}
    		var pad = indent <= shortenFirst ? 0 : indent - shortenFirst;
    		if( indent - shortenFirst + strLen < MAXSCREENWIDTH ) {	// fits on one line
    			return (pad ? paddingText( '', pad ) : '') + str + '\n';
    		}
    		var line = '', total = 0, wrapped = '', words = str.split( COMMA );
    		while( words.length > 0 ) {
    			let nextWord = words.shift();
    			let nextWordLen = measureWord( nextWord );
    			let commaLen = measureWord( COMMA );
    			if( total === 0 || total + commaLen + nextWordLen > MAXSCREENWIDTH ) {
    				// first line (may have a shortener) OR line is full
    				if( total > 0 ) {
    					wrapped += line + '\n';
    					line = '';
    					pad = indent;
    					total = 0;
    				}
    				if( pad + nextWordLen > MAXSCREENWIDTH ) {// don't go past edge
    					pad = MAXSCREENWIDTH - nextWordLen;
    				}
    				line += (pad > 0 ? paddingText( '', pad ) : '') + nextWord;
    				total += pad + nextWordLen;
    			} else {
    				line += COMMA + nextWord;
    				total += commaLen + nextWordLen;
    			}
    		}
    		if( line ) 
    			wrapped += line + '\n';
    		return wrapped;
    	}
    
    	function rptBitFlags( label, labelLen ) {
    		/*
    			OptionName: old_flag, second_flag -> new_flag, new_second
    
    			OR, if option & from fit
    
    			OptionName: old_flag, second_flag
    					   -> new_flag, new_second
    		*/
    		var fromFmt = '', fromLen = 0, toFmt = '', toLen = 0;
    		var commaLen = measureWord( COMMA ),
    			arrowLen = measureWord( ARROW );
    		if( fromCol.length ) {
    			// not measuring final formatted set because measureWord caches
    			for( let ci = 0, len = fromCol.length; ci < len; ci++ ) {
    				fromFmt += fromCol[ ci ];
    				fromLen += measureWord( fromCol[ ci ] );
    				if( len > 1 && ci < len - 1 ) {
    					fromFmt += COMMA;
    					fromLen += commaLen;
    				}
    			}
    		} else {
    			fromFmt = '<none>';
    			fromLen = measureWord( fromFmt );
    		}
    		if( toCol.length ) {
    			for( let ci = 0, len = toCol.length; ci < len; ci++ ) {
    				toFmt += toCol[ ci ];
    				toLen += measureWord( toCol[ ci ] );
    				if( len > 1 && ci < len - 1 ) {
    					toFmt += COMMA;
    					toLen += commaLen;
    				}
    			}
    		} else {
    			toFmt = '<none>';
    			toLen = measureWord( toFmt );
    		}
    
    		var rpt;
    		if( labelLen + fromLen + arrowLen + toLen <= MAXSCREENWIDTH ) {
    			// option and both sets of flags fit in one line
    			rpt = label + fromFmt + ARROW + toFmt + '\n';
    		} else {
    			rpt = wrapRight( label + fromFmt, labelLen + fromLen, labelLen, labelLen );
    			let indentLen = SpaceLen * 2; 				// shift 'to' flags so not inline w/ 'from'
    			rpt += wrapRight( ARROW + toFmt, arrowLen + toLen, indentLen + labelLen, arrowLen );
    		}
    		fromCol.length = 0;								// reset for next rpt
    		toCol.length = 0;
    		return rpt;
    	}
    
    	function noParens( str ) {
    		let spacePending = false, inParen = false, parsed = '';
    		for(let x = 0, len = str.length; x < len; x++) {
    			let ch = str[ x ];
    			if( ch === '(' ) {
    				inParen = true;
    			} else if( ch === ')' ) {
    				inParen = false;
    				spacePending = true;
    			} else if( ch === ' ' ) {
    				spacePending = true;
    			} else if( !inParen ) {
    				if( !spacePending ) {
    					parsed += ch;
    				} else if( PUNCTUATION.indexOf( ch ) >= 0 ) {
    					parsed += ch;
    					spacePending = false;
    				} else if( ch !== ' ' ){
    					parsed += ' ' + ch;
    					spacePending = false;
    				}
    			}
    		}
    		return parsed;
    	}
    
    	function rptChange( option, value ) {
    		var info = optionInfo[ option ];
    		var rpt = '', type = info.type;
    		if( type === 'text' ) {
    			rpt = "'" + value + "'";
    		} else if( type === 'toggle' ) {
    			rpt = expandText( ( value ? 'stn_optns_boolean_true' : 'stn_optns_boolean_false' ) );
    		} else if( type === 'vector' ) {
    			rpt = "[ " + value.join( ', ' ) + " ]" ;
    		} else if( type === 'choice' ) {
    			rpt = info.selection[ value ];
    		} else if( type === 'bitflags' ) {
    			let flags = info.selection;
    			if( !flags ) {
    				rpt = value;
    			} else {
    				let column = fromCol.length === 0 ? fromCol : toCol;
    				if( flags && flags.length > 0 ) {
    					for( let ci = 0, len = flags.length; ci < len; ci++ ) {
    						let flag = 1 << ci;
    						if( value & flag ) {
    							// flag is set, prep columns for rptBitFlags
    							column.push( noParens( flags[ ci ] ) );
    						}
    					}
    				}
    			}
    		} else {
    			rpt = value;
    		}
    		return rpt;
    	}
    
    	var NAG_COUNT = 3;
    	var remindScreenMsg = '';
    
    	function reportSavegame( rpt, choice ) {
    		var that = reportSavegame;
    		var choices = (that.choices = that.choices || {});
    		var notChoices = (that.notChoices = that.notChoices || {});
    		var screen = (that.screen = that.screen || {});
    		var remind = (that.remind = that.remind || {});
    
    		if( !screen.hasOwnProperty( 'exitScreen' ) ) {	// first time ever, create statics
    			screen.exitScreen = 'GUI_SCREEN_INTERFACES';
    			screen.screenID = 'stn_optns_reportSavegame_' + keyPrefix;
    			screen.allowInterrupt = false;
    			screen.textEntry = false;
    			choices[ '01_OK' ] = getChoice( expandText( 'stn_optns_remind_savegame_acknowledge' ) );
    			choices[ '02_NONAG' ] = getChoice( expandText( 'stn_optns_remind_savegame_quit_nagging' ) );
    			choices[ '03_NORPT' ] = getChoice( expandText( 'stn_optns_remind_savegame_quit_reporting' ) );
    			screen.initialChoicesKey = '01_OK';
    			screen.choices = choices;
    		}
    		var rmdr = getRemindObj();
    		let defaultColor = expandText( 'stn_optns_default_color' ) || 'yellowColor';
    		var returnToTop = false;
    		if( rpt ) {										// first time through this summary page
    			remind.reportSummary = rmdr.reportSummary;	// preserve state
    			remind.remindAutosave = rmdr.remindAutosave;
    			remind.autosaveStopRemind = rmdr.autosaveStopRemind;
    			if( choices.hasOwnProperty( 'ZZ_more' ) ) {	// don't know if it'll be needed
    				delete choices[ 'ZZ_more' ];			// don't freeChoice as it's a global button
    			}
    			for( let x in notChoices ) {				// restore dynamic buttons
    				if( notChoices.hasOwnProperty( x ) ) {	// move into choices
    					choices[ x ] = notChoices[ x ];
    					delete notChoices[ x ];
    				}
    			}
    			for( let x in choices ) {					// restore button color
    				if( choices.hasOwnProperty( x ) ) {
    					if( x === '02_NONAG' ) {
    						choices[ x ].color = defaultColor;
    						if( !rmdr.remindAutosave ) {	// store it for future use
    							notChoices[ x ] = choices[ x ];
    							delete choices[ x ];
    						}
    					} else if( x === '03_NORPT' ) {		// summary button remains as an 'undo' until nagging is done
    						if( rmdr.reportSummary ) {
    							choices[ x ].text = expandText( 'stn_optns_remind_savegame_quit_reporting' );
    						} else {
    							choices[ x ].text = expandText( 'stn_optns_remind_savegame_start_reporting' );
    							choices[ x ].color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    						}
    					} else {
    						choices[ x ].color = defaultColor;
    					}
    				}
    			}
    		} else {										// process reply
    			if( choice === 'ZZ_more' ) {				// preserve static more button
    				if( choices[ choice ] === scrollTopButton ) {
    					returnToTop = true;
    				}
    				choices[ choice ] = scrollButton;
    			} else if( choice === '02_NONAG' ) {
    				let prev = remind.autosaveStopRemind,
    					curr = rmdr.autosaveStopRemind;
    				if( prev === false || (prev === NAG_COUNT - 1 && curr >= NAG_COUNT) ) {
    					// disabled by hostOxp using suppressSummary OR player exceeding NAG_COUNT
    					rmdr.remindAutosave = false;		// disable the nagging
    					notChoices[ choice ] = choices[ choice ];// move into notChoices
    					delete choices[ choice ];
    				} else {								// choice remains as a toggle
    					if( abs(curr - prev) > 1 ) {		// only one decrement per summary page; 2nd one undoes 1st
    						rmdr.autosaveStopRemind = prev;
    					}
    					if( abs(curr - prev) % 2 > 0 ) {
    						choices[ choice ].color = expandText( 'stn_optns_optionChanged_color' ) || 'cyanColor';
    					}
    				}
    			} else if( choice === '03_NORPT' ) {		// choice remains as a toggle
    				let prev = remind.reportSummary,
    					curr = rmdr.reportSummary;
    				if( prev && curr ) {					// was toggled back on
    					choices[ choice ].color = defaultColor;
    				} else if( prev && !curr ) {			// was toggled off
    					choices[ choice ].color = expandText( 'stn_optns_optionChanged_color' ) || 'cyanColor';
    				} else if( !prev && curr ) {			// was restarted
    					choices[ choice ].color = expandText( 'stn_optns_optionChanged_color' ) || 'cyanColor';
    				} else { // !prev && !curr				// restart was undone
    					choices[ choice ].color = expandText( 'stn_optns_notInUse_color' ) || 'darkGrayColor';
    				}
    			}
    		}
    		var willReport = remind.reportSummary && rpt !== 'no_summary' && rpt !== 'none',
    			willRemind = remind.remindAutosave;			// rpt is null when we return to process buttons
    		if( willReport ) {
    			screen.title = expandText( 'stn_optns_remind_summary_title' );
    		} else if( willRemind ) {
    			screen.title = expandText( 'stn_optns_remind_savegame_title' );
    		} else {										// nothing to do
    			clearObject( changesMade, true );			// true => keepObjects (ie. del prop only, not what it references)
    			return;
    		}
    		if( rpt ) {										// first time through this summary page
    			let count = changesMade[ 'cag$stnOptsPending' ];
    			remindScreenMsg = willReport ? expandText( 'stn_optns_remind_list_header' ) + rpt : '';
    			expandKeys.stn_optns_changes_count = count;
    			expandKeys.stn_optns_changes_plurality = expandText( ( count > 1 ? 'stn_optns_remind_is_plural'
    																				  : 'stn_optns_remind_is_single' ) );
    			expandKeys.stn_optns_changes_verb = expandText( ( count > 1 ? 'stn_optns_remind_plural_verb'
    																			 : 'stn_optns_remind_single_verb' ) );
    			expandKeys.stn_optns_changes_pronoun = expandText( ( count > 1 ? 'stn_optns_remind_plural_pronoun'
    																				: 'stn_optns_remind_single_pronoun' ) );
    			if( willRemind && willReport ) {
    				remindScreenMsg += expandText( 'stn_optns_remind_savegame_summary_autosave', expandKeys );
    			} else if( willReport ) {
    				remindScreenMsg += expandText( 'stn_optns_remind_savegame_summary', expandKeys );
    			} else {	// willRemind, as the !willReport && !willRemind case returns above
    				remindScreenMsg += expandText( 'stn_optns_remind_savegame_autosave'
    														+ remind.autosaveStopRemind, expandKeys );
    			}
    			scroller.start();
    		} // else we're scrolling or toggling buttons
    		if( returnToTop ) {
    			scroller.reset();
    		}
    		scrollText( rtnobj, remindScreenMsg, 'reportSavegame',
    					Object.keys( choices ).length + 1 ); // +1 for blank line
    		let choice_key = rtnobj.choice_key;
    		choices[ choice_key ] = rtnobj.choice_value;
    		if( choice_key === 'ZZ_more' && !choices.hasOwnProperty( 'ZZ_more' ) ) {
    			choices[ 'ZZ_more' ] = scrollButton;
    		} else if( choice_key === 'ZZ_return' && choices.hasOwnProperty( 'ZZ_more' ) ) {
    			// repurpose for returning to top of list
    			choices[ 'ZZ_more' ] = scrollTopButton;
    		}
    		screen.initialChoicesKey = choice_key === 'ZZ_more' ? choice_key : '01_OK';
    		screen.message = rtnobj.message;
    		mission.runScreen( screen, remindAcknowledged.bind( so ) );
    	}
    
    	function getRemindObj( prefix ) {					// may be called 'locally' (prefix is undefined) or for a
    														// specific hostOxp (prefix is its keyPrefix) - see _getReminder4Oxp
    		var rmdr = so.$Remind2Savegame;
    		var forOxp = prefix || keyPrefix;
    		if( !rmdr || !rmdr.hasOwnProperty( forOxp ) ) {	// may have replies to other hostOxp's
    			let oldStyle = null;
    			if( !rmdr ) {
    				// re-check missionVariables in case $Remind2Savegame was clobbered by different hostOxp
    				rmdr = JSON.parse( missionVariables.$StationOptionsRemind2Savegame );
    				if( rmdr === null || typeof rmdr === 'number' ) {	// not present OR old format
    					if( typeof rmdr === 'number' ) {
    						oldStyle = rmdr;
    					}
    					rmdr = getObject();
    				}
    				so.$Remind2Savegame = rmdr;
    			}
    			rmdr[ forOxp ] = getObject();				// init
    			rmdr[ forOxp ].reportSummary = true;
    			rmdr[ forOxp ].remindAutosave = true;
    			rmdr[ forOxp ].autosaveStopRemind = oldStyle === null ? 0 : oldStyle; // could be zero
    			rmdr[ forOxp ].suppressSummary = false;
    		}
    		let curr = rmdr[ forOxp ];
    		if( !prefix ) {									// called 'locally', otherwise suppressSummary is undefined
    														//   or for a (possibly) different oxp
    			// check here (vs init) to be dynamic (detect when player changes, allow hostOxp freedom to act)
    			if( curr.suppressSummary !== suppressSummary ) {
    				curr.reportSummary = !suppressSummary || suppressSummary !== 'summary';
    				curr.remindAutosave = (!suppressSummary || suppressSummary !== 'autosave')
    										&& curr.autosaveStopRemind < NAG_COUNT;
    				curr.suppressSummary = suppressSummary;
    			}
    		}
    		if( gameSettings.autosave )
    			curr.remindAutosave = false;
    		return curr;
    	}
    
    	function remindAcknowledged( choice ) {
    		try {
    			if( choice === '02_NONAG' ) {
    				getRemindObj().autosaveStopRemind++;
    				reportSavegame( null, '02_NONAG' );
    			} else if( choice === '03_NORPT' ) {
    				let rmdr = getRemindObj();
    				rmdr.reportSummary = !rmdr.reportSummary;
    				reportSavegame( null, '03_NORPT' );
    			} else if( choice === 'ZZ_more' ) {
    				reportSavegame( null, 'ZZ_more' );
    			} else if( choice === 'ZZ_return' ) {
    				scroller.stop();
    				clearObject( changesMade, true );		// true => keepObjects (ie. del prop only, not what it references)
    				displayOptions( 'WW_next_pg' );
    			} else { // choice === '01_OK'
    				remindScreenMsg = '';
    				clearObject( changesMade, true );		// true => keepObjects (ie. del prop only, not what it references)
    			}
    		} catch( err ) {
    			log( so.name, so._reportError( err, remindAcknowledged, choice ) );
    			throw err;
    		}
    	}
    
    	///////////////////////////////////////////////////////////////////////////////
    	// utility functions //////////////////////////////////////////////////////////
    	///////////////////////////////////////////////////////////////////////////////
    
    	function freeValue( value ) {
    		if( Array.isArray( value ) ) {
    			freeArray( value );
    		} else if( typeof value === 'object' ) {
    			freeObject( value );
    		}
    		return null;									// convenience return
    	}
    
    	function copyValue( value )  {
    		if( value === null ) {							// null is an object!
    			return value;
    		} else if( Array.isArray( value ) ) {
    			return getArray( value );					// don't ref originals, use copy
    		} else if( typeof value === 'object' ) {
    			return getObject( value );					// don't ref originals, use copy
    		}
    		return value;
    	}
    
    	var object_pool = [];
    
    	function clearObject( obj, keepObjects ) {
    		for( var x in obj ) { // re-use object
    			if( obj.hasOwnProperty( x ) ) {
    				if( !keepObjects ) {
    					freeValue( obj[ x ] );
    				}
    				// not setting to null as hasOwnProperty is frequently relied upon
    				delete obj[ x ];
    			}
    		}
    		return null;									// convenience return
    	}
    
    	function freeObject( obj, keepObjects ) {
    		if( !obj || typeof obj !== 'object' )
    			return;
    		clearObject( obj, keepObjects );				// scrub old data
    		object_pool.push( obj );						// toss into recycle bin
    		return null;									// convenience return
    	}
    
    	function getObject( elements ) {
    		var obj = object_pool.length > 0 ? object_pool.pop() : {};
    		if( elements && typeof elements === 'object' ) {
    			for( var x in elements ) {
    				if( elements.hasOwnProperty( x ) ) {
    					obj[ x ] = elements[ x ];
    				}
    			}
    		}
    		return obj;
    	}
    
    	var array_pool = [];
    
    	function freeArray( array ) {
    		if( !array || !Array.isArray( array ) ) 
    			return;
    		for( var idx = 0, len = array.length; idx < len; idx++ ) {
    			freeValue( array[ idx ] );
    		}
    		array.length = 0;								// scrub old data
    		array_pool.push( array );						// toss into recycle bin
    		return null;									// convenience return
    	}
    
    // : ' +  + '
    	function getArray( elements ) {
    		var array = array_pool.length > 0 ? array_pool.pop() : [];
    
    		// as we're in strict mode, we cannot access 'caller', 'callee', and 'arguments' properties
    		// but we can make a copy? whatever
    
    		var args = Array.prototype.slice.call( arguments );
    		var aCount = args.length;
    		if( aCount > 1 ) {
    			while( aCount-- ) {
    				array[ aCount ] = args[ aCount ];
    			}
    		} else if( elements && Array.isArray( elements ) ) {
    			let x = elements.length;
    			while( x-- ) {
    				array[ x ] = elements[ x ];
    			}
    		}
    		return array;
    	}
    
    	var choice_pool = [];
    
    	function getChoice( text ) {
    		var choice = choice_pool.length > 0 ? choice_pool.pop() : {};
    		choice.text = text;
    		choice.color = expandText( 'stn_optns_default_color' ) ||'yellowColor';
    		choice.alignment = 'CENTER';
    		choice.unselectable = false;
    		return choice;
    	}
    
    	function freeChoice( choice ) {
    		if( !choice || typeof choice !== 'object' )
    			return;
    		clearObject( choice );
    		choice_pool.push( choice );
    	}
    
    	function poolReport( msg ) {
    		log('poolReport, ' + msg );
    		log('poolReport, choice_pool: ' + choice_pool.length + ', array_pool: ' + array_pool.length
    			+ ', object_pool: ' + object_pool.length );
    	}
    
    	function _purgepools( report ) {					// called upon page change & exit, free up any abandoned changes & all memory pools
    		if( report ) {
    			poolReport( (typeof report === 'string' ? report : '_purgepools') );
    		}
    		choice_pool.length = 0;							// empty pools for garbage collection
    		array_pool.length = 0;
    		object_pool.length = 0;
    	}
    
    /* 
    	function chrCount( ch, str ) {						// return number of 'ch' characters in 'str'
    		// fastest of 5, no garbage, 2x faster than as a String.prototype
    		for( var count = -1, index = -2; index !== -1;
    			count++, index = str.indexOf( ch, index + 1 ) );
    		return count;
    	}
     */
     
    /*
    	this is duplicated from my revised version of _paddingText in
    	 ..\Resouces\Scripts\oolite-contracts-helpers.js
    	- remove (and update ref's) when/if it's incorporated into the core
    */
    	function paddingText( currentText, desiredLength ) {
    		var that = paddingText;
    		var strFontLen = (that.strFontLen = that.strFontLen || defaultFont.measureString);
    		var hairSpace = (that.hairSpace = that.hairSpace || String.fromCharCode( 0x200a ));
    		// 1/8 width space; while there are also 1/4 & 1/2 width unicode chars, MacOS only supports this one!
    		var hairSpaceLength = (that.hairSpaceLength = that.hairSpaceLength || strFontLen( hairSpace ));
    		var spaceLength	= (that.spaceLength	= that.spaceLength || strFontLen( ' ' ));
    		var padding = (that.padding = that.padding || []);
    
    		if( desiredLength <= 0 ) return '';
    		var num, padString = '';
    		var currentLength = strFontLen( currentText );
    		var lengthNeeded = desiredLength - currentLength;
    		if( lengthNeeded <= 0 ) return '';
    
    		num = floor( lengthNeeded / spaceLength ) + 1;
    		if( num > 1 ) {
    			padding.length = num;
    			padString += padding.join( ' ' );
    		}
    		lengthNeeded -= num <= 0 ? 0 : ( num - 1 ) * spaceLength;
    
    		num = floor( lengthNeeded / hairSpaceLength ) + 1;
    		if( num > 1 ) {
    			padding.length = num;
    			padString += padding.join( hairSpace );
    		}
    
    		return padString;
    	}
    
    	return {  _setInterfaces: _setInterfaces,
    			_registerHostOxp: _registerHostOxp,
    				 _purgepools: _purgepools,
    			_getReminder4Oxp: _getReminder4Oxp,
    
    		 _getAllowedCallback: _getAllowedCallback,
    				_getCallPWSG: _getCallPWSG,
    		  _getNotifyCallback: _getNotifyCallback,
    		 _getSuppressSummary: _getSuppressSummary,
    			 _getMissionKeys: _getMissionKeys,
    
    		 _setAllowedCallback: _setAllowedCallback,
    				_setCallPWSG: _setCallPWSG,
    		  _setNotifyCallback: _setNotifyCallback,
    		 _setSuppressSummary: _setSuppressSummary,
    			 _setMissionKeys: _setMissionKeys,
    
    		  _updateMissionKeys: _updateMissionKeys,
    		     _resetLocalVars: _resetLocalVars,
    		   };
    
    // };				// end of closure
    }.bind(this); // get [native code] in debugger rather than entire function
    
    
    
    
    }).call(this);
    // }).call(worldScripts.station_options);
    
    
    /*		(function() { //  run BEFORE reloading entire script
    	var so = worldScripts.station_options;
    	for( let x in so ) {
    		if( so.hasOwnProperty( x ) )
    			delete so[ x ];
    	}
    
    })()	// */
    
    
    /*		(function() { //  run AFTER reloading entire script
    	var so = worldScripts.station_options;
    	so.startUp();
    
    	var ws = worldScripts.telescope;
    	so._initStationOptions( ws, 'telescope_', ws._stnOptionsAllowed, true, ws._reloadFromStn );
    
    	so._setStationInterfaceEntries();
    
    })()	// */