Back to Index Page generated: May 8, 2024, 6:16:03 AM

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
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

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();

})()	// */