public interface ByteReference {
	public long length();

	public InputStream getInputStream() throws IOException;
} public class Proto4xUHSParser {

	private final Logger logger = LoggerFactory.getLogger( Proto4xUHSParser.class );

	private Pattern startPtn = Pattern.compile( "START \\Q***********\\E ([0-9]+)(?: ([Z|A]))?$" );
	private Pattern endPtn = Pattern.compile( "END \\Q***********\\E ([0-9]+)$" );


	/**
	 * Creates a Proto4xUHSParser.
	 */
	public Proto4xUHSParser() {
	}


	/**
	 * Reads a Proto4xUHS file into a List of text lines.
	 * Then calls an appropriate parser to construct a UHSRootNode and a tree of UHSNodes.
	 * <p>
	 * This is likely the only method you'll need.
	 *
	 * @param f  a file to read
	 * @return the root of a tree of nodes representing the hint file
	 * @see #parse4xFormat(Proto4xUHSParseContext)
	 */
	public UHSRootNode parseFile( File f ) throws IOException, UHSParseException {
		List<String> allLines = new ArrayList<String>();

		FileInputStream is = null;
		BufferedReader br = null;
		try {
			CharsetDecoder asciiDecoder = Charset.forName( "US-ASCII" ).newDecoder();
			asciiDecoder.onMalformedInput( CodingErrorAction.REPORT );
			asciiDecoder.onUnmappableCharacter( CodingErrorAction.REPORT );

			is = new FileInputStream( f );
			br = new BufferedReader( new InputStreamReader( is, asciiDecoder ) );

			String line;
			if ( (line=br.readLine()) != null || !"proto_UHS".equals( line ) ) {
				UHSParseException pe = new UHSParseException( "Not a UHS file! (First bytes were not 'proto_UHS')" );
				throw pe;
			}
			allLines.add( line );

			while ( (line=br.readLine()) != null ) {
				allLines.add( line );
			}
		}
		finally {
			try {if ( br != null ) br.close();} catch ( IOException e ) {}
			try {if ( is != null ) is.close();} catch ( IOException e ) {}
		}

		Proto4xUHSParseContext context = new Proto4xUHSParseContext();
		context.setFile( f );
		context.setWorkingDir( f.getParentFile() );
		context.setAllLines( allLines );
		context.setLineFudge( 0 );

		UHSRootNode rootNode = parse4xFormat( context );
		return rootNode;
	}


	/**
	 * Generates a tree of UHSNodes from Proto UHS 4.x.
	 * <p>
	 * These have not been seen in the wild.
	 * <p>
	 * They're unencrypted and intended to be submitted by authors directly
	 * to the publisher. Oddly, UHS Source Editor 4.x (and presumably this
	 * format) doesn't fully support the features in the UHS 9x formats.
	 * <p>
	 * Each node is given an arbitrary id on its START
	 * line, and it encloses everything until a matching END line.
	 *
	 * <blockquote><pre>
	 * {@code
	 * proto_UHS
	 * 0 proto_subject
	 * START *********** #
	 * title
	 * ...
	 * =
	 * ...
	 * END *********** #
	 * 0 proto_info
	 * START *********** #
	 * title File Information
	 * ...
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 * <p>
	 * The root node would normally contain up to two children.
	 * <ul>
	 * <li>A 'proto_subject', containing all the subjects, hints, etc., that
	 * users care about. Official readers will begin inside this node.</li>
	 * <li>A 'proto_info', mentioning the author, publisher, etc.</li>
	 * </ul>
	 * <p>
	 * Official readers merge the content of the auxiliary nodes to appear
	 * together as "Credits and File Information".
	 *
	 * @param context  the parse context
	 * @return the root of a tree of nodes
	 * @see #buildNodes(Proto4xUHSParseContext, UHSNode, int)
	 */
	public UHSRootNode parse4xFormat( Proto4xUHSParseContext context ) throws UHSParseException {
		try {
			UHSRootNode rootNode = new UHSRootNode();
				rootNode.setRawStringContent( "Root" );
				context.setRootNode( rootNode );

			int index = 1;
			// Build the master proto_subject node.
			index += buildNodes( context, rootNode, index );

			// Build auxiliary nodes: proto_credit, proto_info.
			while ( context.hasLine( index ) ) {
				index += buildNodes( context, rootNode, index );
			}

			return rootNode;
		}
		catch ( NumberFormatException e ) {
			UHSParseException pe = new UHSParseException( String.format( "Unable to parse nodes (last parsed line: %d)", context.getLastParsedLineNumber() ), e );
			throw pe;
		}
		catch ( ArrayIndexOutOfBoundsException e ) {
			UHSParseException pe = new UHSParseException( String.format( "Unable to parse nodes (last parsed line: %d)", context.getLastParsedLineNumber() ), e );
			throw pe;
		}
	}


	/**
	 * Recursively parses Proto UHS.
	 * <p>
	 * This recognizes various types of hints, and runs specialized methods to decode them.
	 * Unrecognized hints are harmlessly omitted.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int buildNodes( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;

		String tmp = context.getLine( index );
		if ( tmp.matches( "[0-9]+ [A-Za-z_]+$" ) ) {
			if ( tmp.endsWith( "proto_subject" ) ) {
				index += parseSubjectNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_hint" ) ) {
				index += parseHintNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_text" ) ) {
				index += parseTextNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_comment" ) ) {
				index += parseCommentNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_credit" ) ) {
				index += parseCreditNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_link" ) ) {
				index += parseLinkNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_hyperpng" ) ) {
				index += parseHyperImageNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( "proto_info" ) ) {
				index += parseInfoNode( context, currentNode, index );
			}
			else {
				index += parseUnknownNode( context, currentNode, index );
			}
		}
		else {
			index++;
		}

		return index-startIndex;
	}

	/**
	 * Replaces UHS escaped characters in a hint.
	 * <p>
	 * Escapes have existed from version 88a onwards in most nodes' content and titles.
	 * <p>
	 * The # character is the main escape char and is written <b>##</b>.
	 * <br>
	 * <ul>
	 * <li><b>#</b> a '#' character.</li>
	 * <li><b>#a+</b>[AaEeIiOoUu][:'`^]<b>#a-</b> accent enclosed letter; :=diaeresis,'=acute,`=grave,^=circumflex.</li>
	 * <li><b>#a+</b>[Nn]~<b>#a-</b> accent enclosed letter with a tilde.</li>
	 * <li><b>#a+</b>ae<b>#a-</b> an ash character.</li>
	 * <li><b>#a+</b>TM<b>#a-</b> a trademark character.</li>
	 * <li><b>#w.</b> raw newlines are spaces.</li>
	 * <li><b>#w+</b> raw newlines are spaces (default).</li>
	 * <li><b>#w-</b> raw newlines are newlines.</li>
	 * </ul>
	 * <p>
	 * The following are left for display code to handle (e.g., UHSTextArea).
	 * <ul>
	 * <li><b>#p+</b> proportional font (default).</li>
	 * <li><b>#p-</b> non-proportional font.</li>
	 * </ul>
	 * <p>
	 * This is displayed, but not a clickable hyperlink.
	 * <ul>
	 * <li><b>#h+</b> through <b>#h-</b> is a hyperlink (http or email).</li>
	 * </ul>
	 * <p>
	 * TODO: This needs to be delegated to node content decorators.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Portal: Achievements</i> (hyperlink)</li>
	 * </ul>
	 *
	 * @param currentNode  the node whose content needs replacing
	 */
	public void parseTextEscapes( UHSNode currentNode ) {

		char[] linebreak = new char[] {'^','b','r','e','a','k','^'};
		char[] accentPrefix = new char[] {'#','a','+'};
		char[] accentSuffix = new char[] {'#','a','-'};
		char[] wspcA = new char[] {'#','w','+'};
		char[] wspcB = new char[] {'#','w','.'};
		char[] wnlin = new char[] {'#','w','-'};

		StringBuffer buf = new StringBuffer();
		char[] tmp = currentNode.getRawStringContent().toCharArray();
		String breakStr = " ";
		char[] chunkA = null;
		char[] chunkB = null;
		boolean fudgedPos = false;

		for ( int c=0; c < tmp.length; c++ ) {
			if ( c+1 < tmp.length ) {
				if ( tmp[c] == '#' && tmp[c+1] == '#' ) {buf.append( '#' ); c+=1; continue;}
			}
			if ( tmp[c] == '#' ) {
				if ( c+7 < tmp.length ) {
					chunkA = new char[] {tmp[c],tmp[c+1],tmp[c+2]};
					chunkB = new char[] {tmp[c+5],tmp[c+6],tmp[c+7]};
					if ( Arrays.equals( chunkA, accentPrefix ) && Arrays.equals( chunkB, accentSuffix ) ) {
						if ( tmp[c+4] == ':' ) {
							if ( tmp[c+3] == 'A' ) {buf.append( 'Ä' ); c+=7; continue;}
							if ( tmp[c+3] == 'E' ) {buf.append( 'Ë' ); c+=7; continue;}
							if ( tmp[c+3] == 'I' ) {buf.append( 'Ï' ); c+=7; continue;}
							if ( tmp[c+3] == 'O' ) {buf.append( 'Ö' ); c+=7; continue;}
							if ( tmp[c+3] == 'U' ) {buf.append( 'Ü' ); c+=7; continue;}
							if ( tmp[c+3] == 'a' ) {buf.append( 'ä' ); c+=7; continue;}
							if ( tmp[c+3] == 'e' ) {buf.append( 'ë' ); c+=7; continue;}
							if ( tmp[c+3] == 'i' ) {buf.append( 'ï' ); c+=7; continue;}
							if ( tmp[c+3] == 'o' ) {buf.append( 'ö' ); c+=7; continue;}
							if ( tmp[c+3] == 'u' ) {buf.append( 'ü' ); c+=7; continue;}
						}
						else if ( tmp[c+4] == '\'' ) {
							if ( tmp[c+3] == 'A' ) {buf.append( 'Á' ); c+=7; continue;}
							if ( tmp[c+3] == 'E' ) {buf.append( 'É' ); c+=7; continue;}
							if ( tmp[c+3] == 'I' ) {buf.append( 'Í' ); c+=7; continue;}
							if ( tmp[c+3] == 'O' ) {buf.append( 'Ó' ); c+=7; continue;}
							if ( tmp[c+3] == 'U' ) {buf.append( 'Ú' ); c+=7; continue;}
							if ( tmp[c+3] == 'a' ) {buf.append( 'á' ); c+=7; continue;}
							if ( tmp[c+3] == 'e' ) {buf.append( 'é' ); c+=7; continue;}
							if ( tmp[c+3] == 'i' ) {buf.append( 'í' ); c+=7; continue;}
							if ( tmp[c+3] == 'o' ) {buf.append( 'ó' ); c+=7; continue;}
							if ( tmp[c+3] == 'u' ) {buf.append( 'ú' ); c+=7; continue;}
						}
						else if ( tmp[c+4] == '`' ) {
							if ( tmp[c+3] == 'A' ) {buf.append( 'À' ); c+=7; continue;}
							if ( tmp[c+3] == 'E' ) {buf.append( 'È' ); c+=7; continue;}
							if ( tmp[c+3] == 'I' ) {buf.append( 'Ì' ); c+=7; continue;}
							if ( tmp[c+3] == 'O' ) {buf.append( 'Ò' ); c+=7; continue;}
							if ( tmp[c+3] == 'U' ) {buf.append( 'Ù' ); c+=7; continue;}
							if ( tmp[c+3] == 'a' ) {buf.append( 'à' ); c+=7; continue;}
							if ( tmp[c+3] == 'e' ) {buf.append( 'è' ); c+=7; continue;}
							if ( tmp[c+3] == 'i' ) {buf.append( 'ì' ); c+=7; continue;}
							if ( tmp[c+3] == 'o' ) {buf.append( 'ò' ); c+=7; continue;}
							if ( tmp[c+3] == 'u' ) {buf.append( 'ù' ); c+=7; continue;}
						}
						else if ( tmp[c+4] == '^' ) {
							if ( tmp[c+3] == 'A' ) {buf.append( 'Â' ); c+=7; continue;}
							if ( tmp[c+3] == 'E' ) {buf.append( 'Ê' ); c+=7; continue;}
							if ( tmp[c+3] == 'I' ) {buf.append( 'Î' ); c+=7; continue;}
							if ( tmp[c+3] == 'O' ) {buf.append( 'Ô' ); c+=7; continue;}
							if ( tmp[c+3] == 'U' ) {buf.append( 'Û' ); c+=7; continue;}
							if ( tmp[c+3] == 'a' ) {buf.append( 'â' ); c+=7; continue;}
							if ( tmp[c+3] == 'e' ) {buf.append( 'ê' ); c+=7; continue;}
							if ( tmp[c+3] == 'i' ) {buf.append( 'î' ); c+=7; continue;}
							if ( tmp[c+3] == 'o' ) {buf.append( 'ô' ); c+=7; continue;}
							if ( tmp[c+3] == 'u' ) {buf.append( 'û' ); c+=7; continue;}
						}
						else if ( tmp[c+4] == '~' ) {
							if ( tmp[c+3] == 'N' ) {buf.append( 'Ñ' ); c+=7; continue;}
							if ( tmp[c+3] == 'n' ) {buf.append( 'ñ' ); c+=7; continue;}
						}
						else if ( tmp[c+3] == 'a' && tmp[c+4] == 'e' ) {
							buf.append( 'æ' ); c+=7; continue;
						}
						else if ( tmp[c+3] == 'T' && tmp[c+4] == 'M' ) {
							buf.append( '™' ); c+=7; continue;
						}
						else {
							logger.warn( "Parser encountered an unexpected accent ({}) in this line: {}", (tmp[c+3] + tmp[c+4]), currentNode.getRawStringContent() );
						}
					}
				}
				if ( c+2 < tmp.length ) {
					chunkA = new char[] {tmp[c],tmp[c+1],tmp[c+2]};
					if ( Arrays.equals( chunkA, wspcA ) || Arrays.equals( chunkA, wspcB ) ) {
						breakStr = " "; c+=2; continue;
					}
					else if ( Arrays.equals( chunkA, wnlin ) ) {
						breakStr = "\n"; c+=2; continue;
					}
				}
			}
			if ( c+6 < tmp.length ) {
				chunkA = new char[] {tmp[c],tmp[c+1],tmp[c+2],tmp[c+3],tmp[c+4],tmp[c+5],tmp[c+6]};
				if ( Arrays.equals( chunkA, linebreak ) ) {
					buf.append( breakStr ); c += 6; continue;
				}
			}
			buf.append( tmp[c] );
		}

		currentNode.setRawStringContent( buf.toString() );
	}

	/**
	 * Generates a subject UHSNode and its contents.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_subject
	 * START *********** #
	 * title
	 * =
	 * embedded hunk
	 * =
	 * embedded hunk
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseSubjectNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int subjectId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode subjectNode = new UHSNode( "Subject" );
			subjectNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( subjectNode );
			subjectNode.setId( subjectId );
			subjectNode.setRestriction( restriction );
			currentNode.addChild( subjectNode );
			context.getRootNode().addLink( subjectNode );
		index++;

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( subjectId +"" ) ) {
				break;
			}
			else if ( tmp.equals( "=" ) ) {
				index += buildNodes( context, subjectNode, index );
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates a normal hint UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_hint
	 * START *********** #
	 * Question
	 * hint (not encrypted)
	 * -
	 * hint (not encrypted)
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseHintNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int hintId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode hintNode = new UHSNode( "Hint" );
			hintNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( hintNode );
			hintNode.setId( hintId );
			hintNode.setRestriction( restriction );
			currentNode.addChild( hintNode );
			context.getRootNode().addLink( hintNode );
		index++;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "Hint" );

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( hintId +"" ) ) {
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					parseTextEscapes( newNode );
					hintNode.addChild( newNode );
				}
				break;
			}
			else if ( tmp.equals( "-" ) ) {
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					parseTextEscapes( newNode );
					hintNode.addChild( newNode );
					newNode = new UHSNode( "Hint" );
					tmpContent.delete( 0, tmpContent.length() );
				}
			}
			else {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );

				if ( tmp.equals( " " ) ) tmpContent.append( "\n \n" );
				else tmpContent.append( tmp );
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates a text UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_text
	 * START *********** #
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Rather than assuming "\n" everywhere, in-file linebreaks
	 * are preserved (for context-sensitive escaping later).
	 * Markup will be inserted to begin with that as the default.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseTextNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int textId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode textNode = new UHSNode( "Text" );
			textNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( textNode );
			textNode.setId( textId );
			textNode.setRestriction( restriction );
			currentNode.addChild( textNode );
			context.getRootNode().addLink( textNode );
		index++;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "TextData" );

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( textId +"" ) ) {
				if ( tmpContent.length() > 0 ) {
					if ( tmpContent.length() < 3 || !tmpContent.substring( 0, 3 ).startsWith( "#w-" ) ) {
						tmpContent.insert( 0, "#w-" );  // Text nodes default to honoring newlines.
					}
					newNode.setRawStringContent( tmpContent.toString() );
					parseTextEscapes( newNode );
					textNode.addChild( newNode );
				}
				break;
			}
			else {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );

				if ( tmp.equals( " " ) ) tmpContent.append( "\n \n" );
				else tmpContent.append( tmp );
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates a comment UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_comment
	 * START *********** #
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Rather than assuming " " everywhere, in-file linebreaks
	 * are preserved (for context-sensitive escaping later).
	 * Markup will be inserted to begin with that as the default.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseCommentNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int commentId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode commentNode = new UHSNode( "Comment" );
			commentNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( commentNode );
			commentNode.setId( commentId );
			commentNode.setRestriction( restriction );
			currentNode.addChild( commentNode );
			context.getRootNode().addLink( commentNode );
		index++;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "CommentData" );

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( commentId +"" ) ) {
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					parseTextEscapes( newNode );
					commentNode.addChild( newNode );
				}
				break;
			}
			else {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );

				if ( tmp.equals( " " ) ) tmpContent.append( "\n \n" );
				else tmpContent.append( tmp );
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates a credit UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_credit
	 * START *********** #
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Rather than assuming " " everywhere, in-file linebreaks
	 * are preserved (for context-sensitive escaping later).
	 * Markup will be inserted to begin with that as the default.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseCreditNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int creditId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode creditNode = new UHSNode( "Credit" );
			creditNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( creditNode );
			creditNode.setId( creditId );
			creditNode.setRestriction( restriction );
			currentNode.addChild( creditNode );
			context.getRootNode().addLink( creditNode );
		index++;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "CreditData" );

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( creditId +"" ) ) {
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					parseTextEscapes( newNode );
					creditNode.addChild( newNode );
				}
				break;
			}
			else {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );

				if ( tmp.equals(" ") ) tmpContent.append( "\n \n" );
				else tmpContent.append( tmp );
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates a link UHSNode.
	 * <p>
	 * Nodes like this that have link targets behave like conventional hyperlinks instead of containing child nodes.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_link
	 * START *********** #
	 * title
	 * index
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseLinkNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int linkId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		UHSNode linkNode = new UHSNode( "Link" );
			linkNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( linkNode );
			linkNode.setId( linkId );
			linkNode.setRestriction( restriction );
			currentNode.addChild( linkNode );
			context.getRootNode().addLink( linkNode );
		index++;

		int targetIndex = Integer.parseInt( context.getLine( index ) );
			linkNode.setLinkTarget( targetIndex );
		index++;

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( linkId +"" ) ) {
				break;
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates an image-filled UHSNode.
	 * <p>
	 * The UHS format allows for regions that trigger links or reveal overlaid subimages.
	 * <p>
	 * UHSHotSpotNode was written to handle regions.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_hyperpng
	 * START *********** #
	 * title
	 * file-path 0 0
	 * --not-a-gap--
	 * x y x+w y+h
	 * 0 proto_link
	 * START *********** #
	 * title
	 * index
	 * END *********** #
	 * --not-a-gap--
	 * x y x+w y+h
	 * 0 proto_link
	 * START *********** #
	 * title
	 * index
	 * END *********** #
	 * --not-a-gap--
	 * x y x+w y+h
	 * 0 proto_overlay
	 * START *********** #
	 * title
	 * file-path 0 0 x y
	 * END *********** #
	 * --not-a-gap--
	 * x y x+w y+h
	 * 0 proto_overlay
	 * START *********** #
	 * title
	 * file-path 0 0 x y
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Paths point to png images (spaces are replaced with "?").
	 * <p>
	 * There is no comparable gifa structure.
	 * <p>
	 * To create this in the UHS Source Editor...
	 * <ul>
	 * <li>Add_Menu-HyperPNG, pick a title and image.</li>
	 * <li> - Add_Menu-Link, pick a target.</li>
	 * <li> - - Highlight the link in the list, then drag a region on the image.</li>
	 * <li> - Add_Menu-Overlay, pick a title and image.</li>
	 * <li> - - Click somewhere in the overlay popup's image to set the top-left.</li>
	 * <li> - - Close the overlay's popup.</li>
	 * <li> - - Highlight the overlay in the list, then drag a region on the image.</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see net.vhati.openuhs.core.UHSHotSpotNode
	 */
	public int parseHyperImageNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		byte[] tmpBytes = null;
		int x = 0;
		int y = 0;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int hyperId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		if ( "Z".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_NAG;
		}
		else if ( "A".equals( startMatcher.group( 2 ) ) ) {
			restriction = UHSNode.RESTRICT_REGONLY;
		}

		String mainTitle = context.getLine( index );
		index++;

		tokens = context.getLine( index ).split( " " );
		index++;
		if ( tokens.length != 3 ) {
			logger.error( "Unable to parse HyperImage's main image path (last parsed line: {})", context.getLastParsedLineNumber() );

			index += skipToEnd( context, hyperId, index );
			return index-startIndex;
		}
		String mainImagePath = tokens[0].replaceAll( "[?]", " " ).replace( '\\', '/' );
		File mainImageFile = null;
		if ( mainImagePath.startsWith( "." ) ) {
			mainImageFile = new File( context.getWorkingDir(), mainImagePath );
		}
		else {
			mainImageFile = new File( mainImagePath );
		}

		ByteReference mainImageRef = null;
		try {
			mainImageRef = context.readBinaryFile( mainImageFile );
		}
		catch ( IOException e ) {
			logger.error( "Could not read referenced file: {} (last parsed line: {})", mainImageFile.getAbsolutePath(), context.getLastParsedLineNumber(), e );
		}
		// Ignore dummy zeroes after the path.

		UHSHotSpotNode hotspotNode = new UHSHotSpotNode( "Hyperpng" );
			hotspotNode.setRawStringContent( mainTitle );
			hotspotNode.setRawImageContent( mainImageRef );
			hotspotNode.setId( hyperId );
			hotspotNode.setRestriction( restriction );
			currentNode.addChild( hotspotNode );
			context.getRootNode().addLink( hotspotNode );

		Pattern zonePtn = Pattern.compile( "([0-9]+) ([0-9]+) ([0-9]+) ([0-9]+)\\s*$" );
		Pattern overlayPathPosPtn = Pattern.compile( "\\S+ (0) (0) ([0-9]+) ([0-9]+)\\s*$" );
		Matcher zoneMatcher = null;
		Matcher overlayPathPosMatcher = null;

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			if ( (endMatcher = endPtn.matcher( tmp )).matches() && endMatcher.group( 1 ).equals( hyperId +"" ) ) {
				break;
			}
			else if ( (zoneMatcher = zonePtn.matcher( tmp )).matches() ) {
				int zoneX1 = Integer.parseInt( zoneMatcher.group( 1 ) );
				int zoneY1 = Integer.parseInt( zoneMatcher.group( 2 ) );
				int zoneX2 = Integer.parseInt( zoneMatcher.group( 3 ) );
				int zoneY2 = Integer.parseInt( zoneMatcher.group( 4 ) );

				if ( zoneX1 > zoneX2 || zoneY1 > zoneY2 ) {
					logger.error( "Invalid HyperImage zone coordinates (last parsed line: {})", context.getLastParsedLineNumber() );

					index += skipToEnd( context, hyperId, index );
					return index-startIndex;
				}

				tmp = context.getLine( index );
				index++;
				if ( tmp.matches( "[0-9]+ [A-Za-z_]+$" ) ) {
					if ( tmp.endsWith( "proto_overlay" ) ) {
						startMatcher = startPtn.matcher( context.getLine( index ) );
						index++;
						startMatcher.matches();
						int overlayId = Integer.parseInt( startMatcher.group( 1 ) );

						String overlayTitle = context.getLine( index );
						index++;

						overlayPathPosMatcher = overlayPathPosPtn.matcher( context.getLine( index ) );
						index++;
						overlayPathPosMatcher.matches();

						String overlayImagePath = overlayPathPosMatcher.group( 1 ).replaceAll( "[?]", " " ).replace( '\\', '/' );

						File overlayImageFile = null;
						if ( overlayImagePath.startsWith( "." ) ) {
							overlayImageFile = new File( context.getWorkingDir(), overlayImagePath );
						} else {
							overlayImageFile = new File( overlayImagePath );
						}
						// Skip dummy zeroes after the path.
						int posX = Integer.parseInt( overlayPathPosMatcher.group( 4 ) );
						int posY = Integer.parseInt( overlayPathPosMatcher.group( 5 ) );

						ByteReference overlayImageRef = null;
						try {
							overlayImageRef = context.readBinaryFile( overlayImageFile );
						}
						catch ( IOException e ) {
							logger.error( "Could not read referenced file: {} (last parsed line: {})", overlayImageFile.getAbsolutePath(), context.getLastParsedLineNumber(), e );
							// TODO: Throw an error.
						}

						UHSImageNode overlayNode = new UHSImageNode( "Overlay" );
							overlayNode.setRawStringContent( overlayTitle );
							overlayNode.setRawImageContent( overlayImageRef );
							overlayNode.setId( overlayId );
							context.getRootNode().addLink( overlayNode );
							hotspotNode.addChild( overlayNode );
							hotspotNode.setSpot( overlayNode, new HotSpot( zoneX1, zoneY1, zoneX2-zoneX1, zoneY2-zoneY1, posX, posY ) );

						index++;  // This should skip the overlay's END line.
					}
					else if ( tmp.endsWith( "proto_link" ) ) {
						startMatcher = startPtn.matcher( context.getLine( index ) );
						index++;
						startMatcher.matches();
						int linkId = Integer.parseInt( startMatcher.group( 1 ) );

						String linkTitle = context.getLine( index );
						index++;

						int targetIndex = Integer.parseInt( context.getLine( index ) );
						index++;

						UHSNode newNode = new UHSNode( "Link" );
							newNode.setRawStringContent( linkTitle );
							parseTextEscapes( newNode );
							newNode.setId( linkId );
							context.getRootNode().addLink( newNode );
							hotspotNode.addChild( newNode );
							hotspotNode.setSpot( newNode, new HotSpot( zoneX1, zoneY1, zoneX2-zoneX1, zoneY2-zoneY1, -1, -1 ) );
							newNode.setLinkTarget( targetIndex );

						index++;  // This should skip the link's END line.
					}
					else {
						logger.error( "Unexpected {} child of HyperImage (last parsed line: {})", tmp, context.getLastParsedLineNumber() );

						index += skipToEnd( context, hyperId, index );
						return index-startIndex;
					}
				}
				else {
					logger.error( "Unexpected line in HyperImage: {} (last parsed line: {})", tmp, context.getLastParsedLineNumber() );

					index += skipToEnd( context, hyperId, index );
					return index-startIndex;
				}
			}
			else {
				logger.error( "Unexpected line in HyperImage: {} (last parsed line: {})", tmp, context.getLastParsedLineNumber() );

				index += skipToEnd( context, hyperId, index );
				return index-startIndex;
			}
		}

		return index-startIndex;
	}

	/**
	 * Generates an info UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 0 proto_info
	 * START *********** #
	 * title
	 * author=name
	 * publisher=name
	 * author-note=sentence
	 * game-note=sentence
	 * copyright=sentence
	 * comments=sentence
	 * END *********** #
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseInfoNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = " ";

		int index = startIndex;
		Matcher startMatcher = null;
		Matcher endMatcher = null;
		String[] tokens = null;
		String tmp = context.getLine( index );  // Not interesting.
		index++;

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int infoId = Integer.parseInt( startMatcher.group( 1 ) );

		int restriction = UHSNode.RESTRICT_NONE;
		// Don't bother with restriction.

		UHSNode infoNode = new UHSNode( "Info" );
			infoNode.setRawStringContent( context.getLine( index ) );
			parseTextEscapes( infoNode );
			infoNode.setId( infoId );
			currentNode.addChild( infoNode );
			context.getRootNode().addLink( infoNode );
		index++;

		StringBuffer authorBuf = new StringBuffer();
		StringBuffer publisherBuf = new StringBuffer();
		StringBuffer authorNoteBuf = new StringBuffer();
		StringBuffer gameNoteBuf = new StringBuffer();
		StringBuffer copyrightBuf = new StringBuffer();
		StringBuffer noticeBuf = new StringBuffer();  // "comments="

		StringBuffer unknownBuf = new StringBuffer();
		StringBuffer[] buffers = new StringBuffer[] {authorBuf, publisherBuf, authorNoteBuf, gameNoteBuf, copyrightBuf, noticeBuf, unknownBuf};

		StringBuffer tmpContent = new StringBuffer();
		StringBuffer currentBuffer = null;
		UHSNode newNode = new UHSNode( "InfoData" );

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;

			if ( tmp.startsWith( "copyright" ) || tmp.startsWith( "author-note" ) || tmp.startsWith( "game-note" ) || tmp.startsWith( "comments" ) ) {
				breakChar = " ";
			}
			else {
				breakChar = "\n";  // The official editor doesn't seem capable of linebreaks
			}

			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( infoId +"" ) ) {
				for ( int i=0; i < buffers.length; i++ ) {
					if ( buffers[i].length() == 0 ) continue;
					if ( tmpContent.length() > 0 ) {
						tmpContent.append( "\n" );
						if ( buffers[i] == authorNoteBuf ||
								buffers[i] == gameNoteBuf ||
								buffers[i] == copyrightBuf ||
								buffers[i] == noticeBuf ) {
							tmpContent.append( "\n" );
						}
					}
					tmpContent.append( buffers[i] );
				}

				newNode.setRawStringContent( tmpContent.toString() );
				parseTextEscapes( newNode );
				infoNode.addChild( newNode );
				break;
			}
			else if ( tmp.startsWith( "author=" ) ) {
				currentBuffer = authorBuf;
				//tmp = tmp.substring( 7 );
			}
			else if ( tmp.startsWith( "publisher=" ) ) {
				currentBuffer = publisherBuf;
				//tmp = tmp.substring( 10 );
			}
			else if ( tmp.startsWith( "author-note=" ) ) {
				currentBuffer = authorNoteBuf;
				if ( currentBuffer.length() == 0 ) {currentBuffer.append( "author-note=" ); breakChar = "";}
				tmp = tmp.substring( 12 );
			}
			else if ( tmp.startsWith( "game-note=" ) ) {
				currentBuffer = gameNoteBuf;
				if ( currentBuffer.length() == 0 ) {currentBuffer.append( "game-note=" ); breakChar = "";}
				tmp = tmp.substring( 10 );
			}
			else if ( tmp.startsWith( "copyright=" ) ) {
				currentBuffer = copyrightBuf;
				if ( currentBuffer.length() == 0 ) {currentBuffer.append( "copyright=" ); breakChar = "";}
				tmp = tmp.substring( 10 );
			}
			else if ( tmp.startsWith("comments=") ) {
				currentBuffer = noticeBuf;
				tmp = tmp.substring( 9 );
			}
			else {
				currentBuffer = unknownBuf;
				logger.warn( "Unknown proto_info hunk line: {} (last parsed line: {})", tmp, context.getLastParsedLineNumber() );
			}

			if ( currentBuffer.length() > 0 ) currentBuffer.append( breakChar );
			currentBuffer.append( tmp );
		}

		return index-startIndex;
	}

	/**
	 * Generates a stand-in UHSNode for an unknown hunk.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseUnknownNode( Proto4xUHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		Matcher startMatcher = null;

		String tmp = context.getLine( index );  // Now it's interesting.
		index++;
		logger.warn( "Unknown hunk: {} (last parsed line: {})", tmp, context.getLastParsedLineNumber() );

		startMatcher = startPtn.matcher( context.getLine( index ) );
		index++;
		startMatcher.matches();
		int unknownId = Integer.parseInt( startMatcher.group( 1 ) );

		UHSNode newNode = new UHSNode( "Unknown" );
			newNode.setRawStringContent( "^UNKNOWN HUNK^" );
			newNode.setId( unknownId );
			currentNode.addChild( newNode );
			context.getRootNode().addLink( newNode );

		index += skipToEnd( context, unknownId, index );
		return index-startIndex;
	}

	/**
	 * Consumes lines until an END line is reached.
	 *
	 * @param context  the parse context
	 * @param id  an id to expect, as declared by the corresponding START line
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file
	 */
	public int skipToEnd( Proto4xUHSParseContext context, int id, int startIndex ) {
		int index = startIndex;
		Matcher endMatcher = null;
		String tmp = null;

		while ( context.hasLine( index ) ) {
			tmp = context.getLine( index );
			index++;
			endMatcher = endPtn.matcher( tmp );
			if ( endMatcher.matches() && endMatcher.group( 1 ).equals( id +"" ) ) {
				break;
			}
		}

		return index-startIndex;
	}
} public class UHSAudioNode extends UHSNode {
	protected ByteReference rawAudioContent = null;


	public UHSAudioNode( String type ) {
		super( type );
	}


	/**
	 * Sets this node's audio content, or null.
	 */
	public void setRawAudioContent( ByteReference rawAudioContent ) {
		this.rawAudioContent = rawAudioContent;
	}

	public ByteReference getRawAudioContent() {
		return rawAudioContent;
	}


	@Override
	public String getPrintableContent() {
		return "^Audio^";
	}
} public class UHSImageNode extends UHSNode {
	protected ByteReference rawImageContent = null;


	public UHSImageNode( String type ) {
		super( type );
	}


	/**
	 * Sets this node's image content, or null.
	 */
	public void setRawImageContent( ByteReference rawImageContent ) {
		this.rawImageContent = rawImageContent;
	}

	public ByteReference getRawImageContent() {
		return rawImageContent;
	}


	@Override
	public String getPrintableContent() {
		return "^Image^";
	}
} public class UHSNode {
	/** Any reader can see children or visit the link target. */
	public static final int RESTRICT_NONE = 0;

	/** This node is a nag message that should be hidden from registered readers. */
	public static final int RESTRICT_NAG = 1;

	/** Only registered readers can see this node's children or link target. */
	public static final int RESTRICT_REGONLY = 2;

	protected String type = "";
	protected int id = -1;
	protected int linkIndex = -1;                                // Either Link or group, not both
	protected int restriction = RESTRICT_NONE;
	protected List<UHSNode> children = null;
	protected int revealValue = 0;

	protected String rawStringContent = "";
	protected StringDecorator decorator = null;


	/**
	 * Constructs a node.
	 *
	 * @param type  an arbitrary string used to inform structure and decide special handling
	 */
	public UHSNode( String type ) {
		setType( type );
	}


	public void setType( String type ) {
		this.type = type;
	}

	public String getType() {
		return type;
	}


	/**
	 * Sets this node's id.
	 * <p>
	 * If altering an existing node, remember to
	 * call the rootNode's removeLink() before,
	 * and addLink() after.
	 *
	 * @param n  a new id, or -1
	 * @see UHSRootNode#removeLink(UHSNode)
	 * @see UHSRootNode#addLink(UHSNode)
	 */
	public void setId( int n ) {
		if ( n < -1 ) n = -1;
		id = n;
	}

	/**
	 * Returns this node's id, or -1 if one is not set.
	 */
	public int getId() {
		return id;
	}

	/**
	 * Sets this node's id, offsetting the original by a relative amount.
	 * <p>
	 * This calls the rootNode's removeLink()/addLink()
	 * before and after changes. If the offset would
	 * result in a negative id, the id becomes -1. This
	 * does not affect the ids of children.
	 *
	 * @param offset  an amount to add/subtract
	 * @param rootNode  an existing root node
	 * @see UHSRootNode#removeLink(UHSNode)
	 * @see UHSRootNode#addLink(UHSNode)
	 */
	public void shiftId( int offset, UHSRootNode rootNode ) {
		if ( id >= 0 && id + offset >= 0 ) {
			rootNode.removeLink( this );
			id = id + offset;
			rootNode.addLink( this );
		}
		else if ( id != -1 ){
			rootNode.removeLink( this );
			id = -1;
		}
	}


	/**
	 * Sets the id of another node to visit when this one is clicked, or -1 for none.
	 */
	public void setLinkTarget( int n ) {
		if ( n < 0 ) return;
		removeAllChildren();
		linkIndex = n;
	}

	public int getLinkTarget() {
		return linkIndex;
	}

	public boolean isLink() {
		if ( linkIndex != -1 ) {
			return true;
		}
		else {
			return false;
		}
	}


	/**
	 * Sets this node's viewing restriction.
	 *
	 * @param n  one of: UHSNode.RESTRICT_NONE, UHSNode.RESTRICT_NAG, or UHSNode.RESTRICT_REGONLY
	 */
	public void setRestriction( int n ) {
		if ( n != RESTRICT_NONE && n != RESTRICT_NAG && n != RESTRICT_REGONLY ) {
			throw new IllegalArgumentException( "Restriction must be RESTRICT_NONE, RESTRICT_NAG, or RESTRICT_REGONLY" );
		}
		restriction = n;
	}

	public int getRestriction() {
		return restriction;
	}


	public void setChildren( List<UHSNode> newChildren ) {
		if ( newChildren == null ) {
			this.removeAllChildren();
		}
		else {
			children = newChildren;
			linkIndex = -1;
			revealValue = 0;
		}
	}

	/**
	 * Returns this node's child nodes.
	 *
	 * @return a List of UHSNodes, or null
	 */
	public List<UHSNode> getChildren() {
		return children;
	}

	/**
	 * Returns this node's child nodes of a given type.
	 *
	 * @param type  a string to match node.getType() against
	 * @param c  a class to expect
	 * @return a List of UHSNodes, never null
	 */
	public <T extends UHSNode> List<T> getChildren( String type, Class<T> c ) {
		List<T> result = new ArrayList<T>();
		if ( children == null ) return result;

		for (int i=0; i < children.size(); i++) {
			UHSNode tmpNode = children.get( i );

			if ( tmpNode.getType().equals( type ) && c.isInstance( tmpNode ) ) {
				@SuppressWarnings("unchecked")
				T castNode = c.cast( tmpNode );
				result.add( castNode );
			}
		}
		return result;
	}

	/**
	 * Returns true if this node contains nested child nodes.
	 */
	public boolean isGroup() {
		if ( children != null ) {
			return true;
		} else {
			return false;
		}
	}


	/**
	 * Adds a child node to this one.
	 */
	public void addChild( UHSNode newChild ) {
		if ( children == null ) {
			linkIndex = -1;
			children = new ArrayList<UHSNode>();
		}
		if ( newChild != null ) {
			children.add( newChild );
		}
	}

	public void removeChild( UHSNode doomedNode ) {
		if ( children == null || !children.contains( doomedNode ) ) return;
		children.remove( doomedNode );
		revealValue = Math.min( revealValue, getMaximumReveal() );

		if ( children.size() == 0 ) removeAllChildren();
	}

	public void removeChild( int n ) {
		if ( children == null || getChildCount()-1 < n ) return;
		children.remove( n );
		revealValue = Math.min( revealValue, getMaximumReveal() );

		if ( children.size() == 0 ) removeAllChildren();
	}

	public void removeAllChildren() {
		if ( children == null ) return;
		children.clear();
		children = null;
		revealValue = 0;
	}

	/**
	 * Returns this node's first child node of a given type.
	 *
	 * @param type  a string to match node.getType() against
	 * @param c  a class to expect
	 * @return a UHSNode, or null
	 */
	public <T extends UHSNode> T getFirstChild( String type, Class<T> c ) {
		if ( children == null ) return null;

		for ( UHSNode tmpNode : children ) {
			if ( tmpNode.getType().equals( type ) && c.isInstance( tmpNode ) ) {
				@SuppressWarnings("unchecked")
				T result = c.cast( tmpNode );
				return result;
			}
		}
		return null;
	}

	/**
	 * Returns this node's nth child node.
	 *
	 * @param n  an index among children of this node
	 * @return a UHSNode, or null (if out of range)
	 */
	public UHSNode getChild( int n ) {
		if ( children == null || getChildCount()-1 < n ) return null;
		return children.get( n );
	}

	public int indexOfChild( UHSNode childNode ) {
		return children.indexOf( childNode );
	}

	public int getChildCount() {
		if ( children == null ) return 0;
		return children.size();
	}


	/**
	 * Returns the highest value that might be returned by getCurrentReveal().
	 *
	 * @return total child count, if any, or 0
	 * @see #setCurrentReveal(int)
	 * @see #getCurrentReveal()
	 */
	public int getMaximumReveal() {
		return getChildCount();
	}

	/**
	 * Sets the current reveal progress.
	 *
	 * @param n  a non-negative number less than or equal to the maximum reveal
	 * @see #getCurrentReveal()
	 * @see #getMaximumReveal()
	 */
	public void setCurrentReveal( int n ) {
		n = Math.min( Math.max( 0, n ), getMaximumReveal() );

		revealValue = n;
	}

	/**
	 * Returns the current reveal progress.
	 *
	 * @see #setCurrentReveal(int)
	 * @see #getMaximumReveal()
	 */
	public int getCurrentReveal() {
		return revealValue;
	}


	/**
	 * Sets this node's content, may not be null.
	 */
	public void setRawStringContent( String rawStringContent ) {
		if ( rawStringContent == null ) throw new IllegalArgumentException( "String content may not be null" );

		this.rawStringContent = rawStringContent;
	}

	public String getRawStringContent() {
		return rawStringContent;
	}


	public void setStringContentDecorator( StringDecorator d ) {
		decorator = d;
	}

	public StringDecorator getStringContentDecorator() {
		return decorator;
	}

	/**
	 * Returns content with markup parsed away.
	 *
	 * @return an array of DecoratedFragments, or null if no decorator is set
	 * @see #getDecoratedStringContent()
	 */
	public DecoratedFragment[] getDecoratedStringFragments() {
		if ( decorator != null ) {
			return decorator.getDecoratedString( rawStringContent );
		} else {
			return null;
		}
	}

	/**
	 * Returns content, with markup parsed if a decorator is set, raw otherwise.
	 *
	 * @return a non-null string
	 * @see #getDecoratedStringFragments()
	 */
	public String getDecoratedStringContent() {
		if ( decorator != null ) {
			DecoratedFragment[] frags = getDecoratedStringFragments();

			// Use raw length to hint at buffer capacity.
			StringBuilder buf = new StringBuilder( getRawStringContent().length() );
			for ( DecoratedFragment frag : frags ) {
				buf.append( frag.fragment );
			}
			return buf.toString();
		}
		else {
			return getRawStringContent();
		}
	}


	/**
	 * Recursively prints the indented contents of this node and its children.
	 *
	 * @param indent  indention prefix
	 * @param spacer  indention padding with each level
	 * @param outStream  a stream to print to
	 * @see #getPrintableContent()
	 */
	public void printNode( String indent, String spacer, PrintStream outStream ) {
		int id = getId();
		String idStr = (( id == -1 ) ? "" : "^"+ id +"^ " );
		String linkStr = ((!isLink()) ? "" : " (^Link to "+ getLinkTarget() +"^)");

		outStream.println( indent + idStr + getType() +": "+ getPrintableContent() + linkStr );

		for ( int i=0; i < getChildCount(); i++ ) {
			getChild( i ).printNode( indent+spacer, spacer, outStream );
		}
	}

	/**
	 * Returns a string representation of this node's content for printing.
	 * <p>
	 * This will be spliced into a message generated by printNode().
	 *
	 * @see #printNode(String, String, PrintStream)
	 */
	public String getPrintableContent() {
		return rawStringContent;
	}
} public class UHSParser {

	private final Logger logger = LoggerFactory.getLogger( UHSParser.class );

	private boolean binaryDeferred = false;
	private boolean force88a = false;


	public UHSParser() {
	}


	/**
	 * Sets whether to preload binary hunk segments into arrays or defer reads until needed.
	 * <p>
	 * This will be passed along to UHSParseContexts as they're created during parsing.
	 *
	 * @param b  true to defer, false to preload (default is false)
	 * @see net.vhati.openuhs.core.UHSParseContext#setBinaryDeferred(boolean)
	 */
	public void setBinaryDeferred( boolean b ) {
		binaryDeferred = b;
	}

	/**
	 * Toggles parsing 9x files as an 88a reader.
	 * <p>
	 * Old readers attempting to read a 9x file will see a
	 * deprecation notice.
	 */
	public void setForce88a( boolean b ) {
		force88a = b;
	}


	/**
	 * Generates a decryption key for various hunks' text in the 9x format.
	 *
	 * @param title  the name of the master subject node of the UHS document (not the filename)
	 * @return the key
	 * @see #decryptNestString(CharSequence, int[])
	 * @see #decryptTextHunk(CharSequence, int[])
	 */
	public int[] generate9xKey( CharSequence title ) {
		if ( title == null || !Charset.forName( "US-ASCII" ).newEncoder().canEncode( title ) ) {
			throw new IllegalArgumentException( "Version 9x requires an ASCII title for its encryption key" );
		}

		int[] key = new int[title.length()];
		int[] k = {'k', 'e', 'y'};
		for ( int i=0; i < title.length(); i++ ) {
			key[i] = (int)title.charAt( i ) + ( k[i%3] ^ (i + 40) );
			while ( key[i] > 127 ) {
				key[i] -= 96;
			}
		}
		return key;
	}

	/**
	 * Decrypts the content of standalone 'hint' hunks, and all 88a blocks.
	 * <p>
	 * This is only necessary when initially parsing a file.
	 *
	 * @param input  ciphertext
	 * @return the decrypted text
	 */
	public String decryptString( CharSequence input ) {
		StringBuffer tmp = new StringBuffer(input.length());

		for (int i=0; i < input.length(); i++) {
			int mychar = (int)input.charAt( i );
			if ( mychar < 32 ) {
				// NOP
			}
			else if ( mychar < 80 ) {
				mychar = mychar*2 - 32;
			}
			else {
				mychar = mychar*2 - 127;
			}

			tmp.append( (char)mychar );
		}

		return tmp.toString();
	}


	/**
	 * Decrypts the content of 'nesthint' and 'incentive' hunks.
	 * <p>
	 * This is only necessary when initially parsing a file.
	 *
	 * @param input  ciphertext
	 * @param key  this file's hint decryption key
	 * @return the decrypted text
	 */
	public String decryptNestString( CharSequence input, int[] key ) {
		StringBuffer tmp = new StringBuffer( input.length() );
		int tmpChar = 0;

		for ( int i=0; i < input.length(); i++ ) {
			int codeoffset = i % key.length;
			tmpChar = input.charAt( i ) - ( key[codeoffset] ^ (i + 40) );
			while ( tmpChar < 32 ) {
				tmpChar += 96;
			}
			tmp.append( (char)tmpChar );
		}

		return tmp.toString();
	}


	/**
	 * Decrypts the content of 'text' hunks.
	 * <p>
	 * This is only necessary when initially parsing a file.
	 *
	 * @param input  ciphertext
	 * @param key  this file's hint decryption key
	 * @return the decrypted text
	 */
	public String decryptTextHunk( CharSequence input, int[] key ) {
		StringBuffer tmp = new StringBuffer(input.length());
		int tmpChar = 0;

		for ( int i=0; i < input.length(); i++ ) {
			int codeoffset = i % key.length;
			tmpChar = input.charAt( i ) - ( key[codeoffset] ^ (codeoffset + 40) );
			while ( tmpChar < 32 ) {
				tmpChar += 96;
			}
			tmp.append( (char)tmpChar );
		}

		return tmp.toString();
	}


	/**
	 * Reads bytes into a parse context using a RandomAccessFile.
	 * <p>
	 * This method is unbuffered and thus inefficient. It is also somewhat
	 * reckless in decoding bytes into characters. On the plus side, the code
	 * is uncomplicated.
	 *
	 * @param context  the parse context
	 * @see #readBytesUsingStream(File)
	 */
	private void readBytesUsingRAF( UHSParseContext context ) throws IOException, UHSParseException {
		int index = -1;  // Increment before reads, and this will be the last read 0-based index.
		String tmp = "";

		List<String> allLines = new ArrayList<String>();
		long binHunkOffset = -1;
		byte[] binHunk = new byte[0];

		RandomAccessFile raf = null;
		try {
			raf = new RandomAccessFile( context.getFile(), "r" );

			index++;
			tmp = raf.readLine();
			if ( !tmp.equals( "UHS" ) ) {
				UHSParseException pe = new UHSParseException( "Not a UHS file! (First bytes were not 'UHS')" );
				throw pe;
			}
			allLines.add( tmp );

			// There's a hunk of binary referenced by offset at the end of 91a and newer files
			// One can skip to it by searching for 0x1Ah.
			long skippedNulls = 0;
			byte tmpByte = -1;
			while ( (tmpByte = (byte)raf.read()) != -1 && tmpByte != 0x1a ) {
				// There should be no nulls prior to the binary hunk.
				if ( tmpByte == 0x00 ) {
					skippedNulls++;
					continue;  // A couple malformed 88a's have nulls at the end, skip.
				}

				raf.getChannel().position( raf.getChannel().position()-1 );  // Unread that byte.
				index++;
				tmp = raf.readLine();                 // RandomAccessFile sort of reads as ASCII/UTF-8?
				if ( tmp.indexOf( "\0" ) >= 0 ) {
					logger.warn( "Pruned extraneous null bytes from text" );
					tmp = tmp.replaceAll( "\0", "" );
				}
				allLines.add( tmp );
			}
			if ( skippedNulls > 0 ) logger.warn( "Skipped {} extraneous null bytes", skippedNulls );

			binHunkOffset = raf.getChannel().position();  // Either after 0x1a, or EOF.
			long binSize = raf.length() - binHunkOffset;
			if ( binSize > 0 && binSize <= Integer.MAX_VALUE ) {
				binHunk = new byte[(int)binSize];
				raf.readFully( binHunk );
			}
			else {
				binHunkOffset = -1;
			}
		}
		finally {
			try {if ( raf != null ) raf.close();} catch ( IOException e ) {}
		}

		context.setAllLines( allLines );
		context.setBinaryHunk( binHunk );
		context.setBinaryHunkOffset( binHunkOffset );
		if ( binHunkOffset >= 0 ) context.setBinaryHunkLength( context.getFile().length() - binHunkOffset );
	}

	/**
	 * Cuts CRLF terminated strings out of a StringBuilder.
	 * <p>
	 * Afterward, the StringBuilder will contain only the remaining
	 * unterminated characters.
	 *
	 * @param buf  a source buffer to remove substrings from
	 * @param results  a list to add collected strings into
	 */
	private void carveLines( StringBuilder buf, List<String> results ) {
		String sep = "\r\n";
		int sepLen = sep.length();
		int lastBreak = -sep.length();
		int nextBreak = -sep.length();
		while ( (nextBreak=buf.indexOf( sep, (lastBreak + sepLen) )) >= 0 ) {
			results.add( buf.substring( (lastBreak + sepLen), nextBreak ) );
			lastBreak = nextBreak;
		}
		if ( lastBreak > 0 ) buf.delete( 0, (lastBreak + sepLen) );
	}

	/**
	 * Reads bytes into a parse context using an InputStream, a CharsetDecoder, and buffers.
	 * <p>
	 * Bytes are read from the file a little at a time. Any bytes prior to
	 * the 9x format binary indicator byte (0x1a), if present, are decoded to
	 * ascii characters. Everything after that is collected as bytes.
	 * <p>
	 * This method is elborate, but in an informal benchmark, it was over
	 * six times faster than RandomAccessFile at parsing a directory of every
	 * UHS file.
	 *
	 * @param context  the parse context
	 * @see #readBytesUsingRAF(File)
	 */
	private void readBytesUsingStream( UHSParseContext context ) throws IOException, UHSParseException {
		CharsetDecoder decoder = Charset.forName( "US-ASCII" ).newDecoder();
		decoder.onMalformedInput( CodingErrorAction.REPORT );
		decoder.onUnmappableCharacter( CodingErrorAction.REPORT );

		boolean binWanted = !context.isBinaryDeferred();
		ByteBuffer bb = ByteBuffer.allocate( 8192 );
		CharBuffer cb = CharBuffer.allocate( 8192 );
		StringBuilder builder = new StringBuilder();
		List<String> carvedLines = new ArrayList<String>();
		ByteArrayOutputStream binStream = new ByteArrayOutputStream();
		boolean binFound = false;
		boolean eofFound = false;
		CoderResult decodeResult = null;
		int binPos = -1;  // Intra-buffer position of binary hunk.
		long binHunkOffset = -1;  // Offset from beginning of file to the byte after 0x1a.
		boolean prunedNulls = false;

		FileInputStream fin = null;
		try {
			fin = new FileInputStream( context.getFile() );
			FileChannel fChan = fin.getChannel();
			int count;
			while ( !binFound && !eofFound ) {
				int readStartedAt = bb.position();  // Possibly appending to leftover bytes from last loop.
				count = fChan.read( bb );
				eofFound = ( count == -1 );
				bb.flip();  // Set limit at current pos, set new pos to 0.

				// Peek ahead for binary.
				while ( !binFound && bb.hasRemaining() ) {
					if ( bb.get() == 0x1a ) {
						binPos = bb.position();
						binHunkOffset = (fChan.position() - count) + (binPos - readStartedAt);
						binFound = true;

						//logger.debug( "Binary hunk found: {}", binHunkOffset );

						while ( binWanted && bb.hasRemaining() ) {  // Dump the binary part.
							binStream.write( bb.get() );
						}
						bb.limit( binPos );            // Prepare to decode just the text part.
					}
				}
				bb.rewind();  // Back to 0, where we were after flipping.

				cb.clear();
				decodeResult = decoder.decode( bb, cb, (binFound || eofFound) );

				if ( decodeResult.isError() ) decodeResult.throwException();  // Bad byte.
				cb.flip();
				builder.append( cb );
				for ( int n; (n=builder.lastIndexOf( "\0" )) >= 0; ) {
					prunedNulls = true;
					builder.deleteCharAt( n );
				}
				carveLines( builder, carvedLines );

				while ( decodeResult.isOverflow() ) {  // cb filled.
					cb.clear();
					decodeResult = decoder.decode( bb, cb, (binFound || eofFound) );

					if ( decodeResult.isError() ) decodeResult.throwException();
					cb.flip();
					builder.append( cb );
					for ( int n; (n=builder.lastIndexOf( "\0" )) >= 0; ) {
						prunedNulls = true;
						builder.deleteCharAt( n );
					}
					carveLines( builder, carvedLines );
				}

				// Slide any leftover bytes to the beginning,
				// with position set to just after them, so the next read appends.
				if ( !binFound ) bb.compact();
			}

			//logger.debug( "File channel finished reading text" );

			// Collect any characters lingering in the decoder.
			cb.clear();
			while ( (decodeResult = decoder.flush( cb )).isOverflow() ) {
				cb.flip();
				builder.append( cb );
				for ( int n; (n=builder.lastIndexOf( "\0" )) >= 0; ) {
					prunedNulls = true;
					builder.deleteCharAt( n );
				}
				carveLines( builder, carvedLines );

				cb.clear();
			}

			// Collect the final unterminated line, if any.
			if ( builder.length() > 0 ) carvedLines.add( builder.toString() );

			if ( prunedNulls ) logger.warn( "Pruned extraneous null bytes from text" );

			//logger.debug( "Decoder flushed, lines: {}, final line: {}", carvedLines.size(), carvedLines.get(carvedLines.size()-1) );

			// Collect the rest of the binary lingering in the file.
			if ( binWanted && binFound ) {
				while ( !eofFound ) {
					bb.clear();
					eofFound = ( (count=fChan.read( bb )) == -1 );
					bb.flip();

					while ( bb.hasRemaining() ) {
						binStream.write( bb.get() );
					}
				}
			}

			//logger.debug( "File channel finished reading binary, binHunk length: {}", binStream.size() );

			//for ( int i=0; i < 20; i++ ) logger.debug( "Line {}: {}", i, carvedLines.get( i ) );

			context.setAllLines( carvedLines );
			if ( binWanted ) context.setBinaryHunk( binStream.toByteArray() );
			context.setBinaryHunkOffset( binHunkOffset );
			if ( binHunkOffset >= 0 ) context.setBinaryHunkLength( context.getFile().length() - binHunkOffset );
		}
		finally {
			try {if ( fin != null ) fin.close();} catch ( IOException e ) {}
		}
	}


	/**
	 * Reads a UHS file into a List of text lines and an array of bytes (for binary content).
	 * Then calls an appropriate parser to construct a UHSRootNode and a tree of UHSNodes.
	 * <p>
	 * This is likely the only method you'll need.
	 *
	 * @param f  a file to read
	 * @return the root of a tree of nodes representing the hint file
	 * @see #parse88Format(UHSParseContext)
	 * @see #parse9xFormat(UHSParseContext)
	 */
	public UHSRootNode parseFile( File f ) throws IOException, UHSParseException {
		int index = 0;  // Increment after getting lines, and this will be the pending 0-based index.

		UHSParseContext context = new UHSParseContext();
		context.setBinaryDeferred( binaryDeferred );
		context.setFile( f );
		readBytesUsingStream( context );

		UHSRootNode rootNode = null;
		try {
			index += parse88Format( context );
			rootNode = context.getRootNode();

			// In 88a files, this would e the end.
			// In 9x files, index should be right after "** END OF 88A FORMAT **".

			if ( !force88a && rootNode.isLegacy() ) {
				// That was a fake 88a format message, now comes the 9x format.
				UHSRootNode legacyRootNode = rootNode;

				context.setLineFudge( index-1 );  // Ignore all lines so far. Treat that END line as 0.

				rootNode = parse9xFormat( context );
				rootNode.setLegacyRootNode( legacyRootNode );

				int storedSum = context.readStoredChecksumValue();
				int calcSum = calcChecksum( f );

				if ( storedSum != calcSum ) {
					logger.warn( "Calculated CRC differs from CRC stored in file: {} vs {} (off by: {})", calcSum, storedSum, (storedSum - calcSum) );
				}
			}
		}
		catch ( ArrayIndexOutOfBoundsException e ) {
			throw new UHSParseException( String.format( "Parsing failed: %s", e.getMessage() ), e );
		}

		return rootNode;
	}


	/**
	 * Generates a tree of UHSNodes from UHS 88a.
	 *
	 * <blockquote><pre>
	 * {@code
	 * UHS
	 * document title
	 * # (index of first hint)
	 * # (index of last hint)
	 * subject
	 * # (index of first question)
	 * subject
	 * # (index of first question)
	 * subject
	 * # (index of first question)
	 * question (without a '?')
	 * # (index of first hint)
	 * question (without a '?')
	 * # (index of first hint)
	 * question (without a '?')
	 * # (index of first hint)
	 * hint (encrypted)
	 * hint (encrypted)
	 * hint (encrypted)
	 * hint (encrypted)
	 * credits sentences (several lines)
	 * }
	 * </pre></blockquote>
	 * <p>
	 * There are no hunk labels. The line index denotes meaning. Text of
	 * subjects, questions, and hints are encrypted.
	 * <p>
	 * The file's 1-based line references begin at the first subject.
	 * This method will adjust the parse context's line fudge to ignore the
	 * first four lines after it reads them.
	 * <p>
	 * A Version node will be added, since that was not natively reported
	 * in 88a.
	 * <p>
	 * The official reader only honors line breaks in credit for lines with
	 * fewer than 20 characters. Otherwise, they're displayed as a space. No
	 * authors ever wrote with that in mind, so it's not worth enforcing.
	 * <p>
	 * The generated root node will be added to the parse context. Its
	 * legacy flag will be set if "** END OF 88A FORMAT **" was found at the
	 * end.
	 * <p>
	 * Some 88a format files have titles with one or more leading spaces.
	 * It is unclear why, but official readers honor them.
	 * <p>
	 * Two 88a format files have extraneous bytes at the end. This was
	 * apparently a bug in the "Atari ST UHS writer". Official readers ignore
	 * those bytes.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Castle Master</i> (Extraneous bytes at EOF: 0x00 x 38)</li>
	 * <li>Illustrative UHS: <i>Spellcaster 101</i> (22 leading spaces in title)</li>
	 * <li>Illustrative UHS: <i>The Fool's Errand</i> (Extraneous bytes at EOF: 0x00 x 71)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @see #decryptString(CharSequence)
	 * @return  the number of lines consumed from the file in parsing children
	 */
	public int parse88Format( UHSParseContext context ) throws UHSParseException {
		try {
			int fudged = 0;
			int index = 0;

			String magicLine = context.getLine( index++ );
			if ( !"UHS".equals( magicLine ) ) {
				throw new UHSParseException( String.format( "The 88a parser encountered an unexpected first line (not 'UHS'): %s", magicLine ) );
			}

			String title = context.getLine( index++ );
			int headerFirstHint = Integer.parseInt( context.getLine( index++ ) );
			int headerLastHint = Integer.parseInt( context.getLine( index++ ) );

			UHSRootNode rootNode = new UHSRootNode();
				rootNode.setRawStringContent( "Root" );
			context.setRootNode( rootNode );

			UHSNode masterSubjectNode = new UHSNode( "Subject" );
				masterSubjectNode.setRawStringContent( title );
				rootNode.addChild( masterSubjectNode );

			// Reset, ignoring 3 of those 4 lines, to make the next line (first subject) be 1.
			fudged = index-1;
			context.setLineFudge( context.getLineFudge() + fudged );
			index = 1;

			boolean firstSubject = true;
			int questionSectionStart = -1;

			List<UHSNode> subjectNodes = new ArrayList<UHSNode>();
			List<Integer> subjectsFirstQuestion = new ArrayList<Integer>();

			// Collect all subjects at once, while adding them to the master subject node along the way.
			while ( firstSubject || index < questionSectionStart ) {
				UHSNode currentSubject = new UHSNode( "Subject" );
					currentSubject.setRawStringContent( decryptString( context.getLine( index++ ) ) );
					masterSubjectNode.addChild( currentSubject );
					subjectNodes.add( currentSubject );

				int firstQuestion = Integer.parseInt( context.getLine( index++ ) );
				subjectsFirstQuestion.add( new Integer( firstQuestion ) );

				if ( firstSubject ) {
					questionSectionStart = firstQuestion;
					firstSubject = false;
				}
			}

			int s = 0;
			List<UHSNode> questionNodes = new ArrayList<UHSNode>();
			List<Integer> questionsFirstHint = new ArrayList<Integer>();

			// Collect all questions, while adding them to subjects along the way.
			while ( index < headerFirstHint ) {
				if ( s+1 < subjectNodes.size() && index == subjectsFirstQuestion.get( s+1 ) ) {
					s++;  // Reached the next subject's questions.
				}
				UHSNode currentSubject = subjectNodes.get( s );

				UHSNode currentQuestion = new UHSNode( "Question" );
					currentQuestion.setRawStringContent( decryptString( context.getLine( index++ ) ) +"?" );
					currentSubject.addChild( currentQuestion );
					questionNodes.add( currentQuestion );  // Keep a flat list as well, for counting.

				int firstHint = Integer.parseInt( context.getLine( index++ ) );
				questionsFirstHint.add( new Integer( firstHint ) );
			}

			int q = 0;

			// Collect all the hints, adding them to each question.
			while ( index <= headerLastHint ) {
				if ( q+1 < questionNodes.size() && index == questionsFirstHint.get( q+1 ) ) {
					q++;  // Reached the next question's hints.
				}
				UHSNode currentQuestion = questionNodes.get( q );

				UHSNode currentHint = new UHSNode( "Hint" );
					currentHint.setRawStringContent( decryptString( context.getLine( index++ ) ) );
					currentQuestion.addChild( currentHint );
			}
			// All subjects, questions, and hints have been collected.

			// Index should be at the end credit lines now.

			// Build auxiliary nodes.
			UHSNode fauxVersionNode = new UHSNode( "Version" );
				fauxVersionNode.setRawStringContent( "88a" );
				rootNode.addChild( fauxVersionNode );

			UHSNode fauxVersionDataNode = new UHSNode( "VersionData" );
				fauxVersionDataNode.setRawStringContent( "This version info was added by OpenUHS during parsing because the 88a format does not report it." );
				fauxVersionNode.addChild( fauxVersionDataNode );

			UHSNode creditsNode = new UHSNode( "Credits" );
				creditsNode.setRawStringContent( "-" );
				rootNode.addChild( creditsNode );

			String breakChar = "^break^";
			StringBuilder creditsDataBuf = new StringBuilder();
			while ( context.hasLine( index ) ) {
				String tmp = context.getLine( index++ );

				if ( "** END OF 88A FORMAT **".equals( tmp ) ) {
					rootNode.setLegacy( true );
					break;
				}

				if ( creditsDataBuf.length() > 0 ) creditsDataBuf.append( breakChar );
				creditsDataBuf.append( tmp );
			}
			UHSNode creditsDataNode = new UHSNode( "CreditsData" );
				creditsDataNode.setRawStringContent( creditsDataBuf.toString() );
				creditsDataNode.setStringContentDecorator( new Version88CreditsDecorator() );
				creditsNode.addChild( creditsDataNode );

			return ( fudged + index );  // Index was reset after the header lines.
		}
		catch ( NumberFormatException e ) {
			UHSParseException pe = new UHSParseException( String.format( "Unable to parse nodes (last parsed line: %d)", context.getLastParsedLineNumber() ), e );
			throw pe;
		}
	}


	/**
	 * Generates a tree of UHSNodes from UHS 91a format onwards.
	 * <p>
	 * Versions 91a, 95a, and 96a have been seen in the wild.
	 * These UHS files are prepended with an 88a section containing an
	 * "upgrade your reader" notice. Additionally, they exploit the 88a format
	 * to include ciphertext that will become human-readable when encrypted,
	 * with a message for anyone opening the file in text editors.
	 * <p>
	 * As shown below, the file's 1-based line references begin after
	 * "** END OF 88A FORMAT **". Before calling, set the context's line fudge
	 * to ignore all lines prior, so that context.getLine(0) will yield the
	 * END line.
	 *
	 * <blockquote><pre>
	 * {@code
	 * ** END OF 88A FORMAT **
	 * # Subject
	 * title
	 * ...
	 * # version
	 * title
	 * content
	 * # incentive
	 * title
	 * content
	 * # info
	 * title
	 * content
	 * 0x1Ah character
	 * {binary hunk}
	 * {2-byte CRC16 of the entire file's bytes, excluding these two}
	 * }
	 * </pre></blockquote>
	 * <p>
	 * The root node will normally contain up to four children.
	 * <ul>
	 * <li>A 'subject', containing all the subjects, hints, etc., that users
	 * care about. Official readers will begin inside this node.</li>
	 * <li>A 'version', mentioning the UHS compiler that made the file.</li>
	 * <li>An 'info', mentioning the author, publisher, etc.</li>
	 * <li>And an 'incentive', listing nodes to show/block if the reader is
	 * unregistered.</li>
	 * </ul>
	 * <p>
	 * Official readers merge the content of the auxiliary nodes to appear
	 * together as "Credits and File Information". Older readers buried it in
	 * the file menu, "Information".
	 * <p>
	 * Two 9x format files have extraneous bytes at the end. Official
	 * readers ignore those bytes.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Return of the Phantom</i> (Extraneous bytes at EOF: 0x00 x 744)</li>
	 * <li>Illustrative UHS: <i>Team Fortress Classic</i> (Extraneous bytes at EOF: 0x0d 0x0a)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @return the root of a tree of nodes
	 * @see #parseNode(UHSParseContext, UHSNode, int)
	 * @see #calcChecksum(File)
	 */
	public UHSRootNode parse9xFormat( UHSParseContext context ) throws UHSParseException {
		try {
			UHSRootNode rootNode = new UHSRootNode();
				rootNode.setRawStringContent( "Root" );
			context.setRootNode( rootNode );

			String title = context.getLine( 2 ); // This is the title of the master subject node
			int[] key = generate9xKey( title );
			context.setEncryptionKey( key );

			int index = 1;
			// Build the master subject node.
			index += parseNode( context, rootNode, index );

			if ( rootNode.getChildCount() == 0 ) {
				throw new UHSParseException( String.format( "No nodes were parsed!? Started from this line: %s", context.getLine( 1 ) ) );
			}

			// Build auxiliary nodes: version, info, incentive.
			while ( context.hasLine( index ) ) {
				index += parseNode( context, rootNode, index );
			}

			return rootNode;
		}
		catch ( NumberFormatException e ) {
			UHSParseException pe = new UHSParseException( String.format( "Unable to parse nodes (last parsed line: %d)", context.getLastParsedLineNumber() ), e );
			throw pe;
		}
	}


	/**
	 * Recursively parses UHS newer than 88a.
	 * <p>
	 * This recognizes various types of hints, and runs specialized methods to decode them.
	 * Unrecognized hints are harmlessly omitted.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;

		String tmp = context.getLine( index );
		if ( tmp.matches( "[0-9]+ [A-Za-z]+$" ) ) {
			if ( tmp.endsWith( " comment" ) ) {
				index += parseCommentNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " credit" ) ) {
				index += parseCreditNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " hint" ) ) {
				index += parseHintNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " nesthint" ) ) {
				index += parseNestHintNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " subject" ) ) {
				index += parseSubjectNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " link" ) ) {
				index += parseLinkNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " text" ) ) {
				index += parseTextNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " hyperpng" ) ) {
				index += parseHyperImageNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " gifa" ) ) {
				index += parseHyperImageNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " sound" ) ) {
				index += parseSoundNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " blank" ) ) {
				index += parseBlankNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " version" ) ) {
				index += parseVersionNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " info" ) ) {
				index += parseInfoNode( context, currentNode, index );
			}
			else if ( tmp.endsWith( " incentive" ) ) {
				index += parseIncentiveNode( context, currentNode, index );
			}
			else {
				index += parseUnknownNode( context, currentNode, index );
			}
		}
		else {
			index++;
		}

		return index-startIndex;
	}


	/**
	 * Generates a subject UHSNode and its contents.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # subject
	 * title
	 * embedded hunk
	 * embedded hunk
	 * embedded hunk
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseSubjectNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode newNode = new UHSNode( "Subject" );
			newNode.setRawStringContent( context.getLine( index ) );
			newNode.setStringContentDecorator( new Version9xTitleDecorator() );
			newNode.setId( startIndex );
			currentNode.addChild( newNode );
			context.getRootNode().addLink( newNode );
		index++;
		innerCount--;

		for ( int j=0; j < innerCount; ) {
			j += parseNode( context, newNode, index+j );
		}

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Generates an irregularly revealed UHSNode and its contents.
	 * <p>
	 * UHSBatchNode was written to handle children that reveal in batches.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # nesthint
	 * Question
	 * hint (encrypted)
	 * -
	 * partial hint (encrypted)
	 * =
	 * embedded hunk
	 * rest of hint (encrypted)
	 * -
	 * hint (encrypted)
	 * }
	 * </pre></blockquote>
	 * <p>
	 * A hyphen divider indicates the next batch, beginning with encrypted
	 * hint text. The encrypted text may span multiple lines.
	 * <p>
	 * An equals divider indicates a nested node of any type (as if this
	 * were a Subject node), immediately followed by more encrypted hint text.
	 * <p>
	 * The official reader reveals "partial hint", "embedded hunk", and
	 * "rest of hint" together as if they were one - each on a new line but
	 * displayed within a shared border.
	 * <p>
	 * The hints surrounding an embedded hunk are optional.
	 * That is: a 'standalone' embedded hunk is preceeded by a hyphen and an
	 * equals sign. It is immediately followed by the hyphen indicating the
	 * next sepatate hint.
	 * <p>
	 * Hint text can span multiple lines, but none may be empty. By
	 * convention, a 'blank' line contains single space. Files have been seen
	 * with 'nesthint' lines exceeding the recommended 78 character word wrap.
	 * <p>
	 * Multiple embedded hunks can appear in a row, each preceeded by an
	 * equals.
	 * <p>
	 * TODO: See if multiple embedded hunks can alternate with intervening
	 * text, not just appearing in clumps.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>The Longest Journey</i>: Chapter 1, What should I do with all the stuff outside my window?</li>
	 * <li>Illustrative UHS: <i>The Elder Scrolls IV: Oblivion</i>, Faction Quests, Thieves Guild, Selling Stolen Goods (longest nesthint line in a file, 107 chars, dashes in a table)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see #decryptNestString(CharSequence, int[])
	 * @see net.vhati.openuhs.core.UHSBatchNode
	 */
	public int parseNestHintNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSBatchNode hintNode = new UHSBatchNode( "NestHint" );
			hintNode.setRawStringContent( context.getLine( index ) );
			hintNode.setStringContentDecorator( new Version9xTitleDecorator() );
			hintNode.setId( startIndex );
			currentNode.addChild( hintNode );
			context.getRootNode().addLink( hintNode );
		index++;
		innerCount--;

		boolean firstInBatch = true;
		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "HintData" );

		for ( int j=0; j < innerCount; j++ ) {
			tmp = context.getLine( index+j );
			if ( tmp.equals( "-" ) ) {
				// A hint, add pending content
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					newNode.setStringContentDecorator( new Version9xHintDecorator() );
					hintNode.addChild( newNode );
					hintNode.setAddon( newNode, !firstInBatch );

					newNode = new UHSNode( "HintData" );
					tmpContent.delete( 0, tmpContent.length() );
				}
				firstInBatch = true;
			}
			else if ( tmp.equals( "=" ) ) {
				// Nested hunk, add pending content
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					newNode.setStringContentDecorator( new Version9xHintDecorator() );
					hintNode.addChild( newNode );
					hintNode.setAddon( newNode, !firstInBatch );
					firstInBatch = false;  // There was content ahead of the equals sign.
				}

				int childrenBefore = hintNode.getChildCount();

				j += parseNode( context, hintNode, index+j+1 );

				if ( hintNode.getChildCount() == childrenBefore+1 ) {
					UHSNode thatNode = hintNode.getChild( hintNode.getChildCount()-1 );
					hintNode.setAddon( thatNode, !firstInBatch );
					firstInBatch = false;  // Added a node just now.
				}

				if ( tmpContent.length() > 0 ) {
					newNode = new UHSNode( "HintData" );
					tmpContent.delete( 0, tmpContent.length() );
				}
			}
			else {
				// Accumulate hint content.
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
				tmpContent.append( decryptNestString( context.getLine( index+j ), context.getEncryptionKey() ) );
			}

			if ( j == innerCount-1 && tmpContent.length() > 0 ) {
				newNode.setRawStringContent( tmpContent.toString() );
				newNode.setStringContentDecorator( new Version9xHintDecorator() );
				hintNode.addChild( newNode );
				hintNode.setAddon( newNode, !firstInBatch );
				firstInBatch = false;
			}
		}

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Generates a normal hint UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # hint
	 * Question
	 * hint (encrypted)
	 * -
	 * hint (encrypted)
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see #decryptString(CharSequence)
	 */
	public int parseHintNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1 - 1;

		UHSNode hintNode = new UHSNode( "Hint" );
			hintNode.setRawStringContent( context.getLine( index ) );
			hintNode.setStringContentDecorator( new Version9xTitleDecorator() );
			hintNode.setId( startIndex );
			currentNode.addChild( hintNode );
			context.getRootNode().addLink( hintNode );
		index++;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "HintData" );

		for ( int j=0; j < innerCount; j++ ) {
			tmp = context.getLine( index+j );
			if ( tmp.equals( "-" ) ) {
				if ( tmpContent.length() > 0 ) {
					newNode.setRawStringContent( tmpContent.toString() );
					newNode.setStringContentDecorator( new Version9xHintDecorator() );
					hintNode.addChild( newNode );
					newNode = new UHSNode( "HintData" );
					tmpContent.delete( 0, tmpContent.length() );
				}
			}
			else {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );

				tmp = context.getLine( index+j );
				tmpContent.append( decryptString( tmp ) );
			}

			if ( j == innerCount-1 && tmpContent.length() > 0 ) {
				newNode.setRawStringContent( tmpContent.toString() );
				newNode.setStringContentDecorator( new Version9xHintDecorator() );
				hintNode.addChild( newNode );
			}
		}

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Generates a comment UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # comment
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseCommentNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode commentNode = new UHSNode( "Comment" );
			commentNode.setRawStringContent( context.getLine( index ) );
			commentNode.setStringContentDecorator( new Version9xTitleDecorator() );
			commentNode.setId( startIndex );
			currentNode.addChild( commentNode );
			context.getRootNode().addLink( commentNode );
		index++;
		innerCount--;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "CommentData" );

		for ( int j=0; j < innerCount; j++ ) {
			if (tmpContent.length() > 0) tmpContent.append( breakChar );
			tmpContent.append( context.getLine( index+j ) );
		}
		newNode.setRawStringContent( tmpContent.toString() );
		newNode.setStringContentDecorator( new Version9xCommentDecorator() );
		commentNode.addChild( newNode );

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Generates a credit UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # credit
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Unlike the 88a format's credits, 9x format credit hunks appear under
	 * a Subject. They are not auxiliary nodes.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Alone in the Dark</i>: About this UHS File, Who wrote this file?</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseCreditNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode creditNode = new UHSNode( "Credit" );
			creditNode.setRawStringContent( context.getLine( index ) );
			creditNode.setStringContentDecorator( new Version9xTitleDecorator() );
			creditNode.setId( startIndex );
			currentNode.addChild( creditNode );
			context.getRootNode().addLink( creditNode );
		index++;
		innerCount--;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "CreditData" );

		for ( int j=0; j < innerCount; j++ ) {
			if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
			tmpContent.append( context.getLine( index+j ) );
		}
		newNode.setRawStringContent( tmpContent.toString() );
		newNode.setStringContentDecorator( new Version9xCreditDecorator() );
		creditNode.addChild( newNode );

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Generates a text UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # text
	 * title
	 * 000000 0 offset length
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Offset is counted from the beginning of the file.
	 * <p>
	 * Offset and length are zero-padded to 6 or 7 digits.
	 * <p>
	 * The binary content is encrypted.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see #decryptTextHunk(CharSequence, int[])
	 */
	public int parseTextNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		tmp ="";
		UHSNode textNode = new UHSNode( "Text" );
			textNode.setRawStringContent( context.getLine( index ) );
			textNode.setStringContentDecorator( new Version9xTitleDecorator() );
			textNode.setId( startIndex );
			currentNode.addChild( textNode );
			context.getRootNode().addLink( textNode );
		index++;

		tmp = context.getLine( index );
		index++;
		long offset = Long.parseLong( tmp.substring( 9, tmp.lastIndexOf( " " ) ) ) - context.getBinaryHunkOffset();
		int length = Integer.parseInt( tmp.substring( tmp.lastIndexOf( " " )+1, tmp.length() ) );

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "TextData" );

		ByteReference textRef = context.readBinaryHunk( offset, length );
		try {
			List<String> decodedLines = decodeByteReference( textRef );
			for ( String line : decodedLines ) {
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
				tmpContent.append( decryptTextHunk( line, context.getEncryptionKey() ) );
			}
		}
		catch ( IOException e ) {
			// This error would be at index-1, if not for context.getLine()'s memory.
			logger.error( "Could not read referenced raw bytes (last parsed line: {}): e", context.getLastParsedLineNumber(), e );
		}

		newNode.setRawStringContent( tmpContent.toString() );
		newNode.setStringContentDecorator( new Version9xTextDecorator() );
		textNode.addChild( newNode );

		return index-startIndex;
	}

	/**
	 * Generates a link UHSNode.
	 * <p>
	 * Nodes like this that have link targets behave like conventional hyperlinks instead of containing child nodes.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # link
	 * title
	 * index
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseLinkNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode newNode = new UHSNode( "Link" );
			newNode.setRawStringContent( context.getLine( index ) );
			newNode.setStringContentDecorator( new Version9xTitleDecorator() );
			newNode.setId( startIndex );
			currentNode.addChild( newNode );
			context.getRootNode().addLink( newNode );
		index++;

		int targetIndex = Integer.parseInt( context.getLine( index ) );
			newNode.setLinkTarget( targetIndex );
		index++;

		// Removed since it ran endlessly when nodes link in both directions.
		// parseNode( context, newNode, targetIndex );

		return index-startIndex;
	}

	/**
	 * Generates an image-filled UHSNode.
	 * <p>
	 * The UHS format allows for regions that trigger links or reveal overlaid subimages.
	 * Regions may also trigger a move into nested hyperpng, text, or hint nodes.
	 * <p>
	 * UHSHotSpotNode was written to handle regions.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # hyperpng (or gifa)
	 * title
	 * 000000 offset length (the main image, gets id at this line)
	 * --not-a-gap--
	 * x y x+w y+h (id line is here)
	 * # link
	 * title
	 * index
	 * --not-a-gap--
	 * x y x+w y+h (id line is here)
	 * # link
	 * title
	 * index
	 * --not-a-gap--
	 * x y x+w y+h (id line is here)
	 * # overlay
	 * title
	 * 000000 offset length x y
	 * --not-a-gap--
	 * x y x+w y+h (id line is here)
	 * # overlay
	 * title
	 * 000000 offset length x y
	 * --not-a-gap--
	 * x y x+w y+h (id line is here)
	 * 3 hyperpng
	 * title
	 * 000000 offset length
	 * }
	 * </pre></blockquote>
	 * <p>
	 * The "--not-a-gap--" lines are inserted here for visual clarity.
	 * <p>
	 * Offset is counted from the beginning of the file.
	 * <p>
	 * Offset and length are zero-padded to 6 or 7 digits.
	 * <p>
	 * The zone (x, y, x+w, y+h) and overlay (x, y) numbers are zero-padded
	 * to 4 digits. Both x's and y's seem to be 0-based.
	 * <p>
	 * A gifa has the same structure, but might not officially contain
	 * regions.
	 * <p>
	 * The HyperImage itself gets an id, as usual. The main image also gets
	 * an id, which may be referenced by an Incentive chunk. There are no
	 * examples in the wild of a restricted main image inside an unrestricted
	 * HyperImage chunk. Testing with an edited file indicated that official
	 * readers ignore main image restrictions, and that this is likely an
	 * error the authors made.
	 * <p>
	 * Line ids of nodes nested within a HyperImage are skewed because
	 * their initial line is the region coords, and the node type comes
	 * second.
	 * <p>
	 * HyperImages can contain additional HyperImage nodes that have
	 * their own children. Shifting a nested HyperImage should not recurse
	 * into its children.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Arcania: Gothic 4</i>: Jungle and Mountains, Jungle Map</li>
	 * <li>Illustrative UHS: <i>Deja Vu I</i>: Sewer, The Map</li>
	 * <li>Illustrative UHS: <i>Dungeon Siege</i>: Chapter 6, Maps, Fire Village (Incentive'd HyperImage and main image)</li>
	 * <li>Illustrative UHS: <i>Elder Scrolls III: Tribunal</i>: Maps, Norenen-Dur, Details</li>
	 * <li>Illustrative UHS: <i>Nancy Drew 4: TitRT</i>: Shed, How can I solve, Finished Leaf Puzzle (Incentive'd HyperImage and main image)</li>
	 * <li>Illustrative UHS: <i>The Longest Journey</i>: Chapter 7, the Stone Altar, Can you give me a picture of the solution? (Overlay and Link)</li>
	 * <li>Illustrative UHS: <i>Trinity</i>: Trinity Site, Map of the Trinity Site, Map Key (Link targeting a nested HyperImage erroneously points to the node type line, instead of shifting to the zone line)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see net.vhati.openuhs.core.UHSHotSpotNode
	 */
	public int parseHyperImageNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String[] tokens = null;
		long offset = 0;
		int length = 0;
		byte[] tmpBytes = null;
		int x = 0;
		int y = 0;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		String mainType = null;  // It has to be one of these or the method wouldn't have been called.
		if ( tmp.indexOf( "hyperpng" ) != -1 ) {
			mainType = "Hyperpng";
		}
		else if ( tmp.indexOf( "gifa" ) != -1 ) {
			mainType = "Hypergif";
		}

		String mainTitle = context.getLine( index );
		index++;
		innerCount--;

		int mainImageIndex = index;  // Yeah, the number triplet gets an id. Weird.
		tokens = context.getLine( index ).split( " " );
		index++;
		innerCount--;
		if ( tokens.length != 3 ) {
			logger.error( "Unable to parse HyperImage's offset/length (last parsed line: {})", context.getLastParsedLineNumber() );
			return innerCount+3;
		}
		// Skip dummy zeroes.
		offset = Long.parseLong( tokens[1] ) - context.getBinaryHunkOffset();
		length = Integer.parseInt( tokens[2] );

		ByteReference mainImageRef = context.readBinaryHunk( offset, length );

		UHSHotSpotNode hotspotNode = new UHSHotSpotNode( mainType );
			hotspotNode.setRawStringContent( mainTitle );
			hotspotNode.setRawImageContent( mainImageRef );
			hotspotNode.setId( startIndex );
			currentNode.addChild( hotspotNode );
			context.getRootNode().addLink( hotspotNode );
			context.registerExtraId( new UHSHotSpotNode.HotSpotMainImageId( mainImageIndex ) );  // Ignore any main image restriction.

		for ( int j=0; j < innerCount; ) {
			// Nested ids in HyperImage point to the zone line. Node type is at (zone line)+1.
			int nestedIndex = index+j;

			tokens = context.getLine( index+j ).split( " " );
			j++;
			if ( tokens.length != 4 ) {
				logger.error( "Unable to parse HyperImage's zone coordinates (last parsed line: {})", context.getLastParsedLineNumber() );
				return innerCount+3;
			}
			int zoneX1 = Integer.parseInt( tokens[0] );
			int zoneY1 = Integer.parseInt( tokens[1] );
			int zoneX2 = Integer.parseInt( tokens[2] );
			int zoneY2 = Integer.parseInt( tokens[3] );

			tmp = context.getLine( index+j );
			j++;
			if ( tmp.matches( "[0-9]+ [A-Za-z]+$" ) ) {
				int innerInnerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;
				if ( tmp.endsWith( " overlay" ) ) {
					String overlayTitle = context.getLine( index+j );
					j++;
					tokens = ( context.getLine( index+j ) ).split( " " );
					j++;

					if ( tokens.length != 5 ) {
						logger.error( "Unable to parse Overlay's offset/length/x/y (last parsed line: {})", context.getLastParsedLineNumber() );
						return innerCount+3;
					}
					// Skip dummy zeroes.
					offset = Long.parseLong( tokens[1] ) - context.getBinaryHunkOffset();
					length = Integer.parseInt( tokens[2] );
					int posX = Integer.parseInt( tokens[3] );
					int posY = Integer.parseInt( tokens[4] );

					ByteReference overlayImageRef = context.readBinaryHunk( offset, length );

					UHSImageNode overlayNode = new UHSImageNode( "Overlay" );
						overlayNode.setRawStringContent( overlayTitle );
						overlayNode.setRawImageContent( overlayImageRef );
						overlayNode.setId( nestedIndex );
						hotspotNode.addChild( overlayNode );
						context.getRootNode().addLink( overlayNode );
						hotspotNode.setSpot( overlayNode, new HotSpot( zoneX1, zoneY1, zoneX2-zoneX1, zoneY2-zoneY1, posX, posY ) );
				}
				else if ( tmp.endsWith( " link" ) || tmp.endsWith( " hyperpng" ) || tmp.endsWith( " text" ) || tmp.endsWith( " hint" ) ) {
					int childrenBefore = hotspotNode.getChildCount();
					j--;  // Back up to the hunk type line.
					j += parseNode( context, hotspotNode, index+j );
					if ( hotspotNode.getChildCount() == childrenBefore+1 ) {
						UHSNode newNode = hotspotNode.getChild( hotspotNode.getChildCount()-1 );
						newNode.shiftId( -1, context.getRootNode() );
						// Do not recurse HyperImage id shifts.

						hotspotNode.setSpot( newNode, new HotSpot( zoneX1, zoneY1, zoneX2-zoneX1, zoneY2-zoneY1, -1, -1 ) );
					}
					else {
						logger.error( "Failed to add nested hunk (last parsed line: {})", context.getLastParsedLineNumber() );
					}
				}
				else {
					logger.error( "Unexpected hunk in HyperImage: {} (last parsed line {})", tmp, context.getLastParsedLineNumber() );
					j += innerInnerCount-1;
				}
			} else {j++;}
		}
		index += innerCount;
		return index-startIndex;
	}


	/**
	 * Generates a sound UHSNode.
	 * <p>
	 * This seems to be limited to PCM WAV audio.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # sound
	 * title
	 * 000000 offset length
	 * }
	 * </pre></blockquote>
	 * <p>
	 * Offset is counted from the beginning of the file.
	 * <p>
	 * Offset and length are zero-padded to 6 or 7 digits.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Tex Murphy: Overseer</i>: Day Two, Bosworth Clark's Lab, How do I operate that keypad?</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 * @see #decryptTextHunk(CharSequence, int[])
	 */
	public int parseSoundNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		tmp ="";
		UHSAudioNode soundNode = new UHSAudioNode( "Sound" );
			soundNode.setRawStringContent( context.getLine( index ) );
			soundNode.setStringContentDecorator( new Version9xTitleDecorator() );
			soundNode.setId( startIndex );
			currentNode.addChild( soundNode );
			context.getRootNode().addLink( soundNode );
		index++;

		tmp = context.getLine( index );
		index++;
		long offset = Long.parseLong( tmp.substring( tmp.indexOf( " " )+1, tmp.lastIndexOf( " " ) ) ) - context.getBinaryHunkOffset();
		int length = Integer.parseInt( tmp.substring( tmp.lastIndexOf( " " )+1, tmp.length() ) );

		ByteReference audioRef = context.readBinaryHunk( offset, length );

		soundNode.setRawAudioContent( audioRef );

		return index-startIndex;
	}


	/**
	 * Generates a blank UHSNode for spacing.
	 *
	 * <blockquote><pre>
	 * {@code
	 * 2 blank
	 * -
	 * }
	 * </pre></blockquote>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseBlankNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode newNode = new UHSNode( "Blank" );
			newNode.setRawStringContent( "^^^" );
			newNode.setId( startIndex );
			currentNode.addChild( newNode );
			context.getRootNode().addLink( newNode );
		index += innerCount;
		return index-startIndex;
	}


	/**
	 * Generates a version UHSNode.
	 * <p>
	 * This is the version reported by the hint file. It may be inaccurate,
	 * blank, or conflict with what is claimed in the info node.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # version
	 * title
	 * sentence
	 * sentence
	 * sentence
	 * }
	 * </pre></blockquote>
	 * <p>
	 * The title text is the version, like "96a". Sentences that follow
	 * describe the compiler.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Frankenstein: Through the Eyes of the Monster</i> (blank version, info says 96a)</li>
	 * <li>Illustrative UHS: <i>Kingdom O' Magic</i> (blank version, info doesn't say)</li>
	 * <li>Illustrative UHS: <i>Out of This World</i> (blank version, info says 95a)</li>
	 * <li>Illustrative UHS: <i>Spycraft: The Great Game</i> (blank version, info says 95a)</li>
	 * <li>Illustrative UHS: <i>Star Control 3</i> (blank version, info says 96a)</li>
	 * <li>Illustrative UHS: <i>System Shock</i> (blank version, info says 96a)</li>
	 * <li>Illustrative UHS: <i>The Bizarre Adventures of Woodruff</i> (blank version, info doesn't say)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseVersionNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode versionNode = new UHSNode( "Version" );
			versionNode.setRawStringContent( context.getLine( index ) );
			versionNode.setStringContentDecorator( new Version9xTitleDecorator() );
			versionNode.setId( startIndex );
			currentNode.addChild( versionNode );
			context.getRootNode().addLink( versionNode );
		index++;
		innerCount--;

		StringBuffer tmpContent = new StringBuffer();
		UHSNode newNode = new UHSNode( "VersionData" );

		for ( int j=0; j < innerCount; j++ ) {
			if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
			tmpContent.append( context.getLine( index+j ) );
		}
		newNode.setRawStringContent( tmpContent.toString() );
		newNode.setStringContentDecorator( new Version9xVersionDecorator() );
		versionNode.addChild( newNode );

		index += innerCount;
		return index-startIndex;
	}


	/**
	 * Generates an info UHSNode.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # info
	 * -
	 * length=#######
	 * date=DD-Mon-YY
	 * time=24:00:00
	 * author=name
	 * publisher=name
	 * copyright=sentence
	 * copyright=sentence
	 * copyright=sentence
	 * author-note=sentence
	 * author-note=sentence
	 * game-note=sentence
	 * game-note=sentence
	 * >sentence
	 * >sentence
	 * >sentence
	 * }
	 * </pre></blockquote>
	 * <p>
	 * This hunk is not always present. Presumably it was introduced in the
	 * 95a format.
	 * <p>
	 * Length is a long number, which must match the UHS file's total
	 * length, or official readers will complain about corruption.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseInfoNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode infoNode = new UHSNode( "Info" );
			infoNode.setRawStringContent( context.getLine( index ) );
			infoNode.setStringContentDecorator( new Version9xTitleDecorator() );
			infoNode.setId( startIndex );
			currentNode.addChild( infoNode );
			context.getRootNode().addLink( infoNode );
		index++;
		innerCount--;

		if ( innerCount > 0 ) {
			StringBuffer tmpContent = new StringBuffer();

			UHSNode newNode = new UHSNode( "InfoData" );

			for ( int j=0; j < innerCount; j++ ) {
				tmp = context.getLine( index+j );
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
				tmpContent.append( tmp );
			}

			newNode.setRawStringContent( tmpContent.toString() );
			newNode.setStringContentDecorator( new Version9xInfoDecorator() );
			infoNode.addChild( newNode );
		}

		index += innerCount;
		return index-startIndex;
	}


	/**
	 * Generates an incentive UHSNode.
	 * <p>
	 * This node lists ids to show/block if the reader is unregistered.
	 * <p>
	 * The list is a space-separated string of numbers, each with 'Z' or
	 * 'A' appended to each. 'Z' means the node is a nag message that should
	 * be hidden from registered readers. 'A' means only registered readers
	 * can see the node's children or link target. In some files, there is no
	 * list, and this node only occupies 2 lines.
	 *
	 * <blockquote><pre>
	 * {@code
	 * # incentive
	 * -
	 * ID list (encrypted)
	 * More ids
	 * More ids
	 * }
	 * </pre></blockquote>
	 * <p>
	 * This hunk is not always present. Presumably it was introduced in the
	 * 95a format.
	 * <p>
	 * Upon parsing this node, all referenced ids will be looked up by
	 * calling getLink(id) on the rootNode. The nodes will have their
	 * restriction field set, but it is up to readers to actually honor that.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>AGON</i> (no IDs)</li>
	 * </ul>
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseIncentiveNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		String breakChar = "^break^";

		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		UHSNode incentiveNode = new UHSNode( "Incentive" );
			incentiveNode.setRawStringContent( context.getLine( index ) );
			incentiveNode.setId( startIndex );
			currentNode.addChild( incentiveNode );
			context.getRootNode().addLink( incentiveNode );
		index++;
		innerCount--;

		if ( innerCount > 0 ) {
			StringBuffer tmpContent = new StringBuffer();

			UHSNode newNode = new UHSNode( "IncentiveData" );

			for ( int j=0; j < innerCount; j++ ) {
				tmp = decryptNestString( context.getLine( index+j ), context.getEncryptionKey() );
				if ( tmpContent.length() > 0 ) tmpContent.append( breakChar );
				tmpContent.append( tmp );
			}
			newNode.setRawStringContent( tmpContent.toString() );
			newNode.setStringContentDecorator( new Version9xIncentiveDecorator() );
			incentiveNode.addChild( newNode );

			applyRestrictions( context, newNode.getDecoratedStringContent() );
		}

		index += innerCount;
		return index-startIndex;
	}

	/**
	 * Sets access restrictions on UHSNodes based on ids from an Incentive UHSNode.
	 *
	 * @param context  the parse context
	 * @param incentiveString  a space-separated string of numbers, each with 'Z' or 'A' appended
	 * @see #parseIncentiveNode(UHSParseContext, UHSNode, int)
	 * @see net.vhati.openuhs.core.UHSParseContext#registerExtraId(ExtraNodeId)
	 */
	public void applyRestrictions( UHSParseContext context, String incentiveString ) {
		List<String> badTokens = new ArrayList<String>();

		String[] tokens = incentiveString.split( " " );
		for ( int i=0; i < tokens.length; i++ ) {
			if ( tokens[i].matches( "[0-9]+[AZ]" ) ) {
				int tmpId = Integer.parseInt( tokens[i].substring( 0, tokens[i].length()-1 ) );

				ExtraNodeId extraId = context.getExtraId( tmpId );
				if ( extraId != null ) {
					// This id is extraneous and not worth warning about.
					logger.debug( "Ignoring extraneous restriction: {}", extraId.toString() );
				}
				else {
					UHSNode tmpNode = context.getRootNode().getNodeByLinkId( tmpId );
					if ( tmpNode != null ) {
						if ( tokens[i].endsWith( "Z" ) ) {
							tmpNode.setRestriction( UHSNode.RESTRICT_NAG );
						} else if ( tokens[i].endsWith("A") ) {
							tmpNode.setRestriction( UHSNode.RESTRICT_REGONLY );
						}
					}
					else {
						badTokens.add( tokens[i] );
					}
				}
			}
		}
		if ( !badTokens.isEmpty() ) {
			StringBuilder buf = new StringBuilder();
			for ( String badToken : badTokens ) {
				if ( buf.length() > 0 ) buf.append( ", " );
				buf.append( badToken );
			}
			logger.warn( "Incentive string referenced unknown node ids ({}): {}", buf, incentiveString );
		}
	}


	/**
	 * Generates a stand-in UHSNode for an unknown hunk.
	 *
	 * @param context  the parse context
	 * @param currentNode  an existing node to add children to
	 * @param startIndex  the line number to start parsing from
	 * @return the number of lines consumed from the file in parsing children
	 */
	public int parseUnknownNode( UHSParseContext context, UHSNode currentNode, int startIndex ) {
		int index = startIndex;
		String tmp = context.getLine( index );
		index++;
		int innerCount = Integer.parseInt( tmp.substring( 0, tmp.indexOf( " " ) ) ) - 1;

		logger.warn( "Unknown hunk: {} (last parsed line: {})", tmp, context.getLastParsedLineNumber() );

		UHSNode newNode = new UHSNode( "Unknown" );
			newNode.setRawStringContent( "^UNKNOWN HUNK^" );
			currentNode.addChild( newNode );

		index += innerCount;
		return index-startIndex;
	}


	/**
	 * Returns a list of lines, as decoded ascii from the InputStream of a ByteReference.
	 */
	public List<String> decodeByteReference( ByteReference ref ) throws IOException {
		List<String> results = new ArrayList<String>();
		InputStream is = null;
		try {
			CharsetDecoder decoder = Charset.forName( "US-ASCII" ).newDecoder();
			decoder.onMalformedInput( CodingErrorAction.REPORT );
			decoder.onUnmappableCharacter( CodingErrorAction.REPORT );

			is = ref.getInputStream();
			BufferedReader br = new BufferedReader( new InputStreamReader( is, decoder ) );
			String line;
			while ( (line=br.readLine()) != null ) {
				results.add( line );
			}
		}
		finally {
			try {if ( is != null ) is.close();} catch ( IOException e ) {}
		}

		return results;
	}


	/**
	 * Calculates the security checksum of a UHS file.
	 * <p>
	 * It's a CRC16 of the entire file (read as unsigned bytes),
	 * minus the last two bytes.
	 */
	public int calcChecksum( File f ) throws IOException {
		int result = -1;

		CRC16 crc = new CRC16();
		RandomAccessFile raf = null;
		try {
			raf = new RandomAccessFile( f, "r" );
			long len = raf.length();

			byte[] tmpBytes = new byte[1024];
			int count;
			while ( (count=raf.read( tmpBytes )) != -1 ) {
				long pos = raf.getChannel().position();
				if ( pos >= len-2 ) {
					count -= (int)(pos - (len-2));
				}
				// Let the CRC class clear out the signedness
				crc.update( tmpBytes, 0, count );
			}
			result = (int)crc.getValue();
		}
		catch ( IOException e ) {
			throw new IOException( "Could not calculate checksum", e );
		}
		finally {
			try {if ( raf != null ) raf.close();} catch ( IOException e ) {}
		}

		return result;
	}

	/**
	 * Reads the security checksum stored in a UHS file.
	 */
	private int readChecksum( File f ) throws IOException {
		int result = -1;

		RandomAccessFile raf = null;
		try {
			raf = new RandomAccessFile( f, "r" );
			long len = raf.length();
			if ( len >= 2 ) {
				raf.getChannel().position( len-2 );
/*
				// Read unsigned bytes
				int leastByte = inFile.read();
				int mostByte = inFile.read();
				if ( leastByte != -1 && mostByte != -1 ) {
					result = mostByte << 8 | leastByte;
				}
*/
				ByteBuffer crcBuf = ByteBuffer.allocate( 2 );
				crcBuf.order( ByteOrder.LITTLE_ENDIAN );
				while ( raf.getChannel().read( crcBuf ) != -1 ) {
					if ( crcBuf.position() == 2 ) {
						crcBuf.flip();
						result = crcBuf.getShort( 0 ) & 0xFFFF;
						break;
					}
				}

			}
		}
		catch ( IOException e ) {
			throw new IOException( String.format( "Couldn't read stored checksum from: ", f.getAbsolutePath() ) );
		}
		finally {
			try {if ( raf != null ) raf.close();} catch ( IOException e ) {}
		}

		return result;
	}

	/**
	 * Reads the security checksum stored in an array.
	 * <p>
	 * The final two bytes contain the expected CRC16
	 * of the rest of the file.
	 * <p>
	 * It's a little-endian short ((un)signed?).
	 *
	 * @param a  any byte array that includes the end of a UHS file
	 */
	public int readChecksum( byte[] a ) {
		if ( a.length < 2 ) return -1;

		// Strip the signedness, if any
		int leastByte = a[a.length-2] & 0xFF;
		int mostByte = a[a.length-1] & 0xFF;

		return ( mostByte << 8 | leastByte );
	}
} public class UHSRootNode extends UHSNode {
	private boolean legacy = false;
	private UHSRootNode legacyRootNode = null;

	private Map<String, UHSNode> linkMap = new HashMap<String, UHSNode>();


	public UHSRootNode() {
		super( "Root" );
	}


	/**
	 * Sets a flag indicating this root node is a non-canonical message for old readers.
	 * <p>
	 * The 9x format begins with a fake 88a format section telling old
	 * readers to upgrade.
	 *
	 * #setLegacyRootNode(UHSRootNode)
	 */
	public void setLegacy( boolean b ) {
		legacy = b;
	}

	public boolean isLegacy() {
		return legacy;
	}

	/**
	 * Sets an alternate tree to display in legacy 88a readers.
	 * <p>
	 * The 9x format begins with a fake 88a section telling readers to
	 * upgrade.
	 *
	 * @see #setLegacy(boolean)
	 */
	public void setLegacyRootNode( UHSRootNode legacyRootNode ) {
		this.legacyRootNode = legacyRootNode;
	}

	public UHSRootNode getLegacyRootNode() {
		return legacyRootNode;
	}


	/**
	 * Makes a node available to target by link nodes.
	 *
	 * @param newLink  the node to add
	 */
	public void addLink( UHSNode newLink ) {
		linkMap.put( newLink.getId()+"", newLink );
	}

	/**
	 * Makes a node unavailable to target by link nodes.
	 *
	 * @param id  ID of the node to remove
	 */
	public void removeLinkById( int id ) {
		if ( !linkMap.containsKey( id+"" ) ) return;
		linkMap.remove( id+"" );
	}

	/**
	 * Makes a node unavailable to target by link nodes.
	 *
	 * @param doomedLink  the node to remove
	 * @see #addLink(UHSNode)
	 */
	public void removeLink( UHSNode doomedLink ) {
		linkMap.remove( doomedLink.getId()+"" );
	}

	/**
	 * Makes all nodes unavailable to target by link nodes.
	 */
	public void removeAllLinks() {
		linkMap.clear();
	}


	/**
	 * Gets a node by its id.
	 * <p>
	 * The node itself will always be returned, without any temporary group
	 * wrapping it.
	 *
	 * @param id  the id of the node to get
	 * @return the node, or null if not found
	 */
	public UHSNode getNodeByLinkId( int id ) {
		UHSNode targetNode = linkMap.get( id+"" );

		return targetNode;
	}


	public int getLinkCount() {
		return linkMap.size();
	}


	@Override
	public void setChildren( List<UHSNode> newChildren ) {
		super.setChildren( newChildren );
		if ( this.getChildCount() > 0 ) {
			this.setCurrentReveal( this.getMaximumReveal() );
		}
	}

	@Override
	public void addChild( UHSNode newChild ) {
		super.addChild( newChild );
		if ( this.getChildCount() > 0 ) {
			this.setCurrentReveal( this.getMaximumReveal() );
		}
	}


	/**
	 * Returns the master Subject node containing the table of contents.
	 * <p>
	 * This is equivalent to getting the first Subject child.
	 */
	public UHSNode getMasterSubjectNode() {
		return this.getFirstChild( "Subject", UHSNode.class );
	}


	/**
	 * Returns the title of this hint tree.
	 * <p>
	 * This is the string content of the master Subject node.
	 *
	 * @return the title of the hint file, or null if absent or blank
	 * @see #getMasterSubjectNode()
	 */
	public String getUHSTitle() {
		String result = null;

		UHSNode masterSubjectNode = getMasterSubjectNode();
		if ( masterSubjectNode != null ) {
			result = masterSubjectNode.getDecoratedStringContent();
		}
		if ( result != null && result.length() == 0 ) result = null;

		return result;
	}


	/**
	 * Returns the Version node's content.
	 * <p>
	 * It may be inaccurate, blank, or conflict with what is claimed in the info node.
	 *
	 * @return the reported hint version (e.g., "96a"), or null if absent or blank
	 * @see net.vhati.openuhs.core.UHSParser#parseVersionNode(UHSParseContext, UHSNode, int)
	 */
	public String getUHSVersion() {
		String result = null;

		UHSNode versionNode = this.getFirstChild( "Version", UHSNode.class );
		if ( versionNode != null ) {
			String versionString = versionNode.getDecoratedStringContent();
			if ( versionString.length() > 0 ) result = versionString;
		}

		return result;
	}
} public class UHSWriter {
	private static final int ENCRYPT_NONE = 0;
	private static final int ENCRYPT_HINT = 1;
	private static final int ENCRYPT_NEST = 2;
	private static final int ENCRYPT_TEXT = 3;

	private final Logger logger = LoggerFactory.getLogger( UHSWriter.class );

	private static final Pattern crlfPtn = Pattern.compile( "\r\n" );


	public UHSWriter() {
	}


	/**
	 * Generates an encryption key for version 9x formats.
	 *
	 * @param title  the title of the root node of the UHS document (not the filename, must be ASCII)
	 * @return the key
	 * @see #encryptNestString(CharSequence, int[])
	 * @see #encryptTextHunk(CharSequence, int[])
	 */
	public int[] generate9xKey( CharSequence title ) {
		if ( title == null || !Charset.forName( "US-ASCII" ).newEncoder().canEncode( title ) ) {
			throw new IllegalArgumentException( "Version 9x requires an ASCII title for its encryption key" );
		}

		int[] key = new int[title.length()];
		int[] k = {'k', 'e', 'y'};
		for ( int i=0; i < title.length(); i++ ) {
			key[i] = (int)title.charAt( i ) + ( k[i%3] ^ (i + 40) );
			while ( key[i] > 127 ) {
				key[i] -= 96;
			}
		}
		return key;
	}


	/**
	 * Encrypts the content of standalone 'hint' hunks, and all 88a blocks.
	 * <p>
	 * This is only necessary when saving a file.
	 *
	 * @param input  plaintext
	 * @return the encrypted text
	 */
	public String encryptString( CharSequence input ) {
		StringBuilder tmp = new StringBuilder( input.length() );

		for ( int i=0; i < input.length(); i++ ) {
			int mychar = (int)input.charAt( i );
			if ( mychar < 32 ) {
				// NOP
			}
			else if ( mychar%2 == 0 ) {
				mychar = ( mychar+32 ) / 2;
			}
			else {
				mychar = ( mychar+127 ) / 2;
			}

			tmp.append( (char)mychar );
		}

		return tmp.toString();
	}


	/**
	 * Encrypts the content of 'nesthint' and 'incentive' hunks.
	 * <p>
	 * This is only necessary when saving a file.
	 *
	 * @param input  plaintext
	 * @param key  this file's hint decryption key
	 * @return the encrypted text
	 */
	public String encryptNestString( CharSequence input, int[] key ) {
		StringBuilder tmp = new StringBuilder( input.length() );
		int tmpChar = 0;

		for ( int i=0; i < input.length(); i++ ) {
			int codeoffset = i % key.length;
			tmpChar = input.charAt( i ) + ( key[codeoffset] ^ (i + 40) );
			while ( tmpChar > 127 ) {
				tmpChar -= 96;
			}
			tmp.append( (char)tmpChar );
		}

		return tmp.toString();
	}


	/**
	 * Encrypts the content of 'text' hunks.
	 * <p>
	 * This is only necessary when saving a file.
	 *
	 * @param input  plaintext
	 * @param key  this file's hint decryption key
	 * @return the encrypted text
	 */
	public String encryptTextHunk( CharSequence input, int[] key ) {
		StringBuilder tmp = new StringBuilder( input.length() );
		int tmpChar = 0;

		for ( int i=0; i < input.length(); i++ ) {
			int codeoffset = i % key.length;
			tmpChar = input.charAt( i ) + ( key[codeoffset] ^ (codeoffset + 40) );
			while ( tmpChar > 127 ) {
				tmpChar -= 96;
			}
			tmp.append( (char)tmpChar );
		}

		return tmp.toString();
	}


	/**
	 * Tests whether a UHSRootNode can be expressed in 88a format.
	 * <p>
	 * The rootNode must contain...
	 * <blockquote><pre>
	 * {@code
	 * A master Subject node, with 0-or-more Subjects, containing:
	 * - 1-or-more Questions, containing:
	 * - - 1-or-more Hints
	 * 1 Credit node, containing a CreditData node
	 * }
	 * </pre></blockquote>
	 * <p>
	 * The 88a format is pretty limited...
	 * <ul>
	 * <li>All nodes' content must be strings.</li>
	 * <li>Newlines are not part of 88a, and will need to be stripped.</li>
	 * <li>Accented and exotic characters will need to be asciified.</li>
	 * <li>Markup within text will need to be stripped.</li>
	 * </ul>
	 *
	 * @param rootNode  an existing root node
	 */
	public boolean isValid88Format( UHSRootNode rootNode ) {
		if ( !rootNode.isGroup() ) return false;

		List<UHSNode> levelOne = rootNode.getMasterSubjectNode().getChildren();
		for ( int o=0; o < levelOne.size(); o++ ) {
			UHSNode oNode = levelOne.get( o );
			String oType = oNode.getType();
			if ( "Subject".equals( oType ) ) {
				if ( !oNode.isGroup() ) return false;

				// Check Question nodes
				List<UHSNode> levelTwo = oNode.getChildren();
				for ( int t=0; t < levelTwo.size(); t++ ) {
					UHSNode tNode = levelTwo.get( t );
					if ( !tNode.isGroup() ) return false;

					// Check Hint nodes
					List<UHSNode> levelThree = tNode.getChildren();
					for ( int r=0; r < levelThree.size(); r++ ) {
						UHSNode rNode = levelThree.get( r );
						if ( rNode.isGroup() ) return false;
					}
				}
			}
			else return false;
		}

		UHSNode creditsNode = rootNode.getFirstChild( "Credits", UHSNode.class );
		if ( creditsNode == null || creditsNode.getFirstChild( "CreditsData", UHSNode.class ) == null ) {
			return false;
		}

		return true;
	}


	public void write88Format( UHSRootNode rootNode, OutputStream os ) throws IOException, UHSGenerationException {
		CharsetEncoder asciiEncoder = Charset.forName( "US-ASCII" ).newEncoder();
		asciiEncoder.onMalformedInput( CodingErrorAction.REPORT );
		asciiEncoder.onUnmappableCharacter( CodingErrorAction.REPORT );

		Writer textWriter = new OutputStreamWriter( os, asciiEncoder );
		write88Format( rootNode, textWriter );
		textWriter.flush();
	}

	/**
	 * Writes the tree of a UHSRootnode in 88a format.
	 * <p>
	 * TODO: This method needs to be made decorator-aware.
	 * <p>
	 * Newlines and "^break^" are replaced by " ".
	 *
	 * @param rootNode  an existing root node
	 * @param writer  an existing Writer to receive text (encoding must be US-ASCII)
	 * @see #isValid88Format(UHSRootNode)
	 */
	public void write88Format( UHSRootNode rootNode, Writer writer ) throws IOException, UHSGenerationException {
		if ( !isValid88Format( rootNode ) ) {
			throw new UHSGenerationException( "The node tree cannot be expressed in the 88a format" );
		}
		StringBuilder buf = new StringBuilder();

		String tmp = null;
		List<UHSNode> subjectNodes = rootNode.getMasterSubjectNode().getChildren( "Subject", UHSNode.class );
		List<UHSNode> questionNodes = new ArrayList<UHSNode>();
		List<UHSNode> hintNodes = new ArrayList<UHSNode>();
		for ( int s=0; s < subjectNodes.size(); s++ ) {
			UHSNode tmpS = subjectNodes.get( s );
			List<UHSNode> tmpQs = tmpS.getChildren();
			questionNodes.addAll( tmpQs );
			for ( int q=0; q < tmpQs.size(); q++ ) {
				hintNodes.addAll( (tmpQs.get( q )).getChildren() );
			}
		}

		int sSize = 2;
		int qSize = 2;
		int hSize = 1;
		int firstQ = 1 + ( sSize * subjectNodes.size() );        // First line is 1. Questions begin after last subject line.
		int firstH = firstQ + ( qSize * questionNodes.size() );  // Hints begin after the last question line.
		int lastH = firstH + ( hSize * hintNodes.size() ) - 1;   // Final line of the final hint, not 1 line after.

		buf.append( "UHS\r\n" );

		String title = rootNode.getUHSTitle();
		buf.append( title ).append( "\r\n" );
		buf.append( firstH ).append( "\r\n" );
		buf.append( lastH ).append( "\r\n" );


		for ( int s=0; s < subjectNodes.size(); s++ ) {
			UHSNode tmpSubject = subjectNodes.get( s );
			tmp = escapeText( tmpSubject, true );
				tmp = tmp.replaceAll( "\\^break\\^", " " );  // 88a doesn't support newlines.
				tmp = encryptString( tmp );
			int n = ( qSize * questionNodes.indexOf( tmpSubject.getChild( 0 ) ) ) + firstQ;
			buf.append( tmp ).append( "\r\n" ).append( n ).append( "\r\n" );
		}

		for ( int q=0; q < questionNodes.size(); q++ ) {
			UHSNode tmpQuestion = questionNodes.get( q );
			tmp = escapeText( tmpQuestion, true );
				if ( tmp.endsWith( "?" ) ) tmp = tmp.substring( 0, tmp.length()-1 );
				tmp = tmp.replaceAll( "\\^break\\^", " " );  // 88a doesn't support newlines.
				tmp = encryptString( tmp );
			int n = ( hSize * hintNodes.indexOf( tmpQuestion.getChild( 0 ) ) ) + firstH;
			buf.append( tmp ).append( "\r\n" ).append( n ).append( "\r\n" );
		}

		for ( int h=0; h < hintNodes.size(); h++ ) {
			UHSNode tmpHint = hintNodes.get( h );
			tmp = escapeText( tmpHint, true );
				tmp = tmp.replaceAll( "\\^break\\^", " " );  // 88a doesn't support newlines
				tmp = encryptString( tmp );
			buf.append( tmp ).append( "\r\n" );
		}

		// TODO: Check for null nodes.
		UHSNode creditsNode = rootNode.getFirstChild( "Credits", UHSNode.class );
		tmp = creditsNode.getFirstChild( "CreditsData", UHSNode.class ).getRawStringContent();
		tmp = tmp.replaceAll( "\\^break\\^", "\r\n" );
		if ( tmp.length() > 0 ) buf.append( tmp ).append( "\r\n" );

		writer.append( buf );
	}


	/**
	 * Writes the tree of a UHSRootnode in 9x format.
	 * <p>
	 * Writing the 9x format is not straightforward because the text
	 * depends on already knowing what text will be written. Hunk references
	 * are based on line numbers. Binary segment offsets include the exact
	 * byte count of all encoded text.
	 * <p>
	 * Phase 1:
	 * <ul>
	 * <li>Dry-run text encoding. (Critical information will be missing.)</li>
	 * <li>Count text lines, and build an ID-to-line map, for resolving link
	 * targets later.</li>
	 * <li>Count the total bytes of nodes' binary content, without collecting
	 * it. (for zero-padding offsets/lengths to a minimum width)</li>
	 * <li>Use total bytes of encoded text to roughly estimate the binary
	 * hunk's offset.</li>
	 * </ul>
	 * <p>
	 * Phase 2:
	 * <ul>
	 * <li>Another dry-run encoding.</li>
	 * <li>Use the ID-to-Line map to resolve links.</li>
	 * <li>Use total bytes of encoded text to re-estimate the binary hunk's
	 * offset.</li>
	 * </ul>
	 * <p>
	 * Phase 3:
	 * <ul>
	 * <li>Final encoding. All missing info should be ready now.</li>
	 * <li>Collect binary content.</li>
	 * </ul>
	 * <p>
	 * After phase 3, bytes are sent to the output stream: encoded text,
	 * the 0x1a binary hink indicator, the binary hunk, and a CRC16 checksum.
	 *
	 * @param rootNode  an existing root node
	 * @param os  a stream to write into
	 */
	public void write9xFormat( UHSRootNode rootNode, OutputStream os ) throws IOException, UHSGenerationException {
		CharsetEncoder asciiEncoder = Charset.forName( "US-ASCII" ).newEncoder();
		asciiEncoder.onMalformedInput( CodingErrorAction.REPORT );
		asciiEncoder.onUnmappableCharacter( CodingErrorAction.REPORT );

		ByteArrayOutputStream textStream = new ByteArrayOutputStream();
		Writer textWriter = null;
		StringBuilder buf = new StringBuilder();

		UHSGenerationContext context = new UHSGenerationContext();
		context.setEncryptionKey( generate9xKey( rootNode.getUHSTitle() ) );

		context.setLegacyRootNode( rootNode.getLegacyRootNode() );

		if ( context.getLegacyRootNode() == null ) {
			context.setLegacyRootNode( createDefaultLegacyRootNode() );
		}

		for ( int phase=1; phase <= 3; phase++ ) {
			context.setPhase( phase );
			buf.setLength( 0 );  // Null out the buffer's content, but the backing array's capacity remains.
			textStream.reset();  // Null out the stream's content, but the backing array's capacity remains.
			asciiEncoder.reset();
			textWriter = new OutputStreamWriter( textStream, asciiEncoder );

			write88Format( context.getLegacyRootNode(), textWriter );  // Fake 88a section.
			textWriter.append( "** END OF 88A FORMAT **" + "\r\n" );

			writeNode( context, rootNode, buf, 1 );
			textWriter.append( buf );
			textWriter.close();

			//logger.debug( "Writing 9x Phase {}, binHunk offset: {}", phase, textStream.size()+1 );
			context.setBinaryHunkOffset( textStream.size() + 1 );  // Include the indicator byte.
		}

		// Write encoded text bytes.
		// Write the binary indicator byte.
		// Write binary.
		// Write CRC.

		CheckedOutputStream crcStream = new CheckedOutputStream( os, new CRC16() );

		// Docs recommend wrapping with a BufferedWriter. Already got a buffer.
		textStream.writeTo( crcStream );
		crcStream.flush();

		crcStream.write( (byte)0x1a );
		crcStream.flush();

		context.getBinaryHunkOutputStream().writeTo( crcStream );
		crcStream.flush();

		long crcResult = crcStream.getChecksum().getValue();

		ByteBuffer crcBuf = ByteBuffer.allocate( 2 );
		crcBuf.order( ByteOrder.LITTLE_ENDIAN );
		crcBuf.putShort( (short)crcResult );
		os.write( crcBuf.array() );
		os.flush();
	}

	/**
	 * Recursively collects nodes' content in the 9x format.
	 * <p>
	 * Each nested call inserts text into the parent buffer. Then the
	 * parent prepends a total line count to itself.
	 *
	 * @param context  the generation context
	 * @param currentNode  an existing node to collect content from
	 * @param parentBuf  a buffer to send text into (e.g., the parent node's buffer)
	 * @param startIndex  the line number this hunk is expected to appear at (1-based)
	 * @see #write9xFormat(UHSRootNode, OutputStream)
	 */
	private int writeNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		String type = currentNode.getType();

		if ( context.isPhaseOne() ) {
			if ( currentNode.getId() != -1 ) {  // Associate id with current line.
				context.putLine( currentNode.getId(), startIndex );
			}

			if ( currentNode.getRestriction() != UHSNode.RESTRICT_NONE ) {
				context.registerRestrictedNode( currentNode );
			}
		}

		if ( "Root".equals( type ) ) {
			writeRootNode( context, currentNode, parentBuf, startIndex );
			return 0;
		}
		else if ( "Subject".equals( type ) ) {
			return writeSubjectNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "NestHint".equals( type ) ) {
			return writeNestHintNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Hint".equals( type ) ) {
			return writeHintNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Comment".equals( type ) ) {
			return writeCommentNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Credit".equals( type ) ) {
			return writeCreditNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Text".equals( type ) ) {
			return writeTextNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Link".equals( type ) ) {
			return writeLinkNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Hyperpng".equals( type ) || "Hypergif".equals( type ) ) {
			return writeHotSpotNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Sound".equals( type ) ) {
			return writeSoundNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Blank".equals( type ) ) {
			return writeBlankNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Version".equals( type ) ) {
			return writeVersionNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Info".equals( type ) ) {
			return writeInfoNode( context, currentNode, parentBuf, startIndex );
		}
		else if ( "Incentive".equals( type ) ) {
			return writeIncentiveNode( context, currentNode, parentBuf, startIndex );
		}
		else {
			throw new IllegalArgumentException( "Unexpected version 9x node type: "+ currentNode.getType() );
		}
	}

	/**
	 * Returns a common fake 88a section for the start of 9x format files.
	 * <p>
	 * Write this root in 88a, append "** END OF 88A FORMAT **", then
	 * write the real root in 9x.
	 */
	public UHSRootNode createDefaultLegacyRootNode() {
		UHSRootNode rootNode = new UHSRootNode();
		rootNode.setRawStringContent( "Important Message!" );
		rootNode.setLegacy( true );

		UHSNode masterSubjectNode = new UHSNode( "Subject" );
		masterSubjectNode.setRawStringContent( "" );
		rootNode.addChild( masterSubjectNode );

		UHSNode subjectNode = new UHSNode( "Subject" );
		subjectNode.setRawStringContent( "Important!" );
		masterSubjectNode.addChild( subjectNode );

		UHSNode ignoreNode = new UHSNode( "Question" );
		ignoreNode.setRawStringContent( "Ignore this!" );
		subjectNode.addChild( ignoreNode );

		UHSNode whyNode = new UHSNode( "Question" );
		whyNode.setRawStringContent( "Why aren't there any more hints here?" );
		subjectNode.addChild( whyNode );

		List<String> ignoreLines = new ArrayList<String>();
		ignoreLines.add( "The following text will appear encrypted -- it is intended as instructions" );
		ignoreLines.add( "for people without UHS readers." );

		// Pre-encrypted, to be encrypted again, and thus readable in text editors.
		ignoreLines.add( encryptString( "-------------------------------------------------------------------------" ) );
		ignoreLines.add( encryptString( "This file is encoded in the Universal Hint System format.  The UHS is" ) );
		ignoreLines.add( encryptString( "designed to show you only the hints that you need so your game is not" ) );
		ignoreLines.add( encryptString( "spoiled.  You will need a UHS reader for your computer to view this" ) );
		ignoreLines.add( encryptString( "file.  You can find UHS readers and more information on the UHS on the" ) );
		ignoreLines.add( encryptString( "Internet at http://www.uhs-hints.com/ ." ) );
		ignoreLines.add( encryptString( "-------------------------------------------------------------------------" ) );

		List<String> whyLines = new ArrayList<String>();
		whyLines.add( "This file has been written for a newer UHS format which the reader that" );
		whyLines.add( "you are using does not support.  Visit the UHS web site at" );
		whyLines.add( "http://www.uhs-hints.com/ to see if a newer reader is available." );

		for ( String line : ignoreLines ) {
			UHSNode hintNode = new UHSNode( "Hint" );
			hintNode.setRawStringContent( line );
			ignoreNode.addChild( hintNode );
		}

		for ( String line : whyLines ) {
			UHSNode hintNode = new UHSNode( "Hint" );
			hintNode.setRawStringContent( line );
			whyNode.addChild( hintNode );
		}

		return rootNode;
	}

	public void writeRootNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		if ( currentNode instanceof UHSRootNode == false ) return;
		UHSRootNode rootNode = (UHSRootNode)currentNode;

		int innerCount = 0;
		StringBuilder buf = new StringBuilder();

		for ( int i=0; i < rootNode.getChildCount(); i++ ) {
			UHSNode tmpNode = rootNode.getChild( i );
			innerCount += writeNode( context, tmpNode, buf, startIndex + innerCount );  // Recurse.
		}

		parentBuf.append( buf );
	}

	public int writeSubjectNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + various children's lines
		int innerCount = 0;

		UHSNode subjectNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " subject" ).append( "\r\n" );
		contentLines = splitContentLines( subjectNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		for ( int i=0; i < subjectNode.getChildCount(); i++ ) {
			UHSNode tmpNode = subjectNode.getChild( i );
			innerCount += writeNode( context, tmpNode, buf, startIndex + innerCount );  // Recurse.
		}

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeNestHintNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + ugh
		int innerCount = 0;

		if ( currentNode instanceof UHSBatchNode == false ) return 0;
		UHSBatchNode nestNode = (UHSBatchNode)currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " nesthint" ).append( "\r\n" );
		contentLines = splitContentLines( nestNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		boolean first = true;
		for ( int i=0; i < nestNode.getChildCount(); i++ ) {
			UHSNode tmpNode = nestNode.getChild( i );
			boolean addon = nestNode.isAddon( tmpNode );

			if ( first && addon ) {
				throw new UHSGenerationException( "NestHint's first child must not be an addon" );
			}

			if ( "HintData".equals( tmpNode.getType() ) ) {
				// First batch has no divider.
				// Successive batches begin with a divider and a HintData.
				// HintData addons do not get dividers when they follow other non-HintData addons.
				if ( !first && !addon) {
					buf.append( "-" ).append( "\r\n" );
					innerCount++;
				}
				contentLines = splitContentLines( tmpNode, -1 );
				innerCount += contentLines.length;
				encryptContentLines( context, ENCRYPT_NEST, contentLines );
				appendLines( buf, true, contentLines );
			}
			else {
				// Batches can only be started by HintData.
				// Insert an empty "-" HintData divider when any other node thinks it's not an addon.
				// Can't do this for the first batch.
				if ( !addon ) {
					if ( first ) {
						throw new UHSGenerationException( "NestHint's first child must be a HintData node" );
					}
					buf.append( "-" ).append( "\r\n" );
					innerCount++;
				}

				buf.append( "=" ).append( "\r\n" );
				innerCount++;

				innerCount += writeNode( context, tmpNode, buf, startIndex + innerCount );  // Recurse.
			}
			first = false;
		}

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeHintNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + children's content lines + (N children)-1 dividers
		int innerCount = 0;

		UHSNode hintNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " hint" ).append( "\r\n" );
		contentLines = splitContentLines( hintNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		boolean first = true;
		for ( UHSNode tmpNode : hintNode.getChildren( "HintData", UHSNode.class ) ) {
			if ( !first ) {
				buf.append( "-" ).append( "\r\n" );  // Divider between successive children.
				innerCount++;
			}
			contentLines = splitContentLines( tmpNode, -1 );
			innerCount += contentLines.length;
			encryptContentLines( context, ENCRYPT_HINT, contentLines );
			appendLines( buf, true, contentLines );

			first = false;
		}

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeCommentNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + content lines
		int innerCount = 0;

		UHSNode commentNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " comment" ).append( "\r\n" );
		contentLines = splitContentLines( commentNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		UHSNode dataNode = commentNode.getFirstChild( "CommentData", UHSNode.class );
		if ( dataNode == null ) {/* Throw an error */}

		contentLines = splitContentLines( dataNode, -1 );
		innerCount += contentLines.length;
		appendLines( buf, true, contentLines );

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeCreditNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + content lines
		int innerCount = 0;

		UHSNode creditNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " credit" ).append( "\r\n" );
		contentLines = splitContentLines( creditNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		UHSNode dataNode = creditNode.getFirstChild( "CreditData", UHSNode.class );
		if ( dataNode == null ) {/* Throw an error */}

		contentLines = splitContentLines( dataNode, -1 );
		innerCount += contentLines.length;
		appendLines( buf, true, contentLines );

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeTextNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 3
		int innerCount = 0;

		UHSNode textNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " text" ).append( "\r\n" );
		contentLines = splitContentLines( textNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		UHSNode dataNode = textNode.getFirstChild( "TextData", UHSNode.class );
		if ( dataNode == null ) {/* Throw an error */}
		contentLines = splitContentLines( dataNode, -1 );
		encryptContentLines( context, ENCRYPT_TEXT, contentLines );
		StringBuilder dataBuf = new StringBuilder();
		appendLines( dataBuf, true, contentLines );

		CharsetEncoder asciiEncoder = Charset.forName( "US-ASCII" ).newEncoder();
		asciiEncoder.onMalformedInput( CodingErrorAction.REPORT );
		asciiEncoder.onUnmappableCharacter( CodingErrorAction.REPORT );

		byte[] tmpBytes = asciiEncoder.encode( CharBuffer.wrap( dataBuf ) ).array();

		long bytesOffset = context.getNextBinaryOffset();
		context.registerBinarySection( tmpBytes.length );

		if ( context.isPhaseThree() ) {
			context.getBinaryHunkOutputStream().write( tmpBytes, 0, tmpBytes.length );
		}

		String offsetString = zeroPad( bytesOffset, context.getOffsetNumberWidth() );
		String lengthString = zeroPad( tmpBytes.length, context.getLengthNumberWidth() );
		buf.append( "000000 0 " ).append( offsetString ).append( " " ).append( lengthString );
		buf.append( "\r\n" );
		innerCount++;

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeLinkNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 3
		int innerCount = 0;

		UHSNode linkNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " link" ).append( "\r\n" );
		contentLines = splitContentLines( linkNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		if ( !context.isPhaseOne() ) {  // Can't resolve link targets in phase one.
			int targetLine = context.getLine( linkNode.getLinkTarget() );
			buf.append( targetLine );
		}
		buf.append( "\r\n" );
		innerCount++;

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeHotSpotNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + 1 + ugh
		int innerCount = 0;

		if ( currentNode instanceof UHSHotSpotNode == false ) return 0;
		UHSHotSpotNode hotspotNode = (UHSHotSpotNode)currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		// Current node has the title AND the main image.

		buf.append( " " );
		if ( "Hyperpng".equals( hotspotNode.getType() ) ) {
			buf.append( "hyperpng" );
		}
		else if ( "Hypergif".equals( hotspotNode.getType() ) ) {
			buf.append( "gifa" );
		}
		else {
			throw new UHSGenerationException( String.format( "Unexpected type of HotSpot node: %s", hotspotNode.getType() ) );
		}
		buf.append( "\r\n" );
		innerCount++;

		contentLines = splitContentLines( hotspotNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount++;

		// TODO: main id vs main image id? Possibly register the other id in phase one?

		ByteReference mainImageRef = hotspotNode.getRawImageContent();

		long mainBytesOffset = context.getNextBinaryOffset();
		context.registerBinarySection( mainImageRef.length() );

		if ( context.isPhaseThree() ) {
			context.writeBinarySegment( mainImageRef );
		}

		String offsetString = zeroPad( mainBytesOffset, context.getOffsetNumberWidth() );
		String lengthString = zeroPad( mainImageRef.length(), context.getLengthNumberWidth() );
		buf.append( "000000 " ).append( offsetString ).append( " " ).append( lengthString );
		buf.append( "\r\n" );
		innerCount++;

		for ( int i=0; i < hotspotNode.getChildCount(); i++ ) {
			UHSNode tmpNode = hotspotNode.getChild( i );

			HotSpot spot = hotspotNode.getSpot( tmpNode );
			buf.append( zeroPad( spot.zoneX, 4 ) );
			buf.append( " " );
			buf.append( zeroPad( spot.zoneY, 4 ) );
			buf.append( " " );
			buf.append( zeroPad( spot.zoneX+spot.zoneW, 4 ) );
			buf.append( " " );
			buf.append( zeroPad( spot.zoneY+spot.zoneH, 4 ) );
			buf.append( "\r\n" );
			innerCount++;

			if ( "Overlay".equals( tmpNode.getType() ) ) {  // lines: 3 (Technically it was preceeded by a zone in HyperImage.)
				// Overlays need the zone's x/y, so don't recurse.

				if ( tmpNode instanceof UHSImageNode == false ) {/* Throw an error */}
				UHSImageNode overlayNode = (UHSImageNode)tmpNode;

				if ( context.isPhaseOne() ) {
					if ( overlayNode.getId() != -1 ) {
						context.putLine( overlayNode.getId(), startIndex + innerCount - 1 );  // Fudge the line map to point to zone.
					}

					if ( currentNode.getRestriction() != UHSNode.RESTRICT_NONE ) {
						context.registerRestrictedNode( overlayNode );
					}
				}

				StringBuilder oBuf = new StringBuilder();
				oBuf.append( /* lineCount */ " overlay" ).append( "\r\n" );
				contentLines = splitContentLines( overlayNode, 1 );
				appendLines( oBuf, true, contentLines );
				innerCount += 2;

				ByteReference overlayImageRef = overlayNode.getRawImageContent();

				long overlayBytesOffset = context.getNextBinaryOffset();
				context.registerBinarySection( overlayImageRef.length() );

				if ( context.isPhaseThree() ) {
					context.writeBinarySegment( overlayImageRef );
				}

				String oOffsetString = zeroPad( overlayBytesOffset, context.getOffsetNumberWidth() );
				String oLengthString = zeroPad( overlayImageRef.length(), context.getLengthNumberWidth() );
				oBuf.append( "000000 " ).append( oOffsetString ).append( " " ).append( oLengthString );
				oBuf.append( " " );
				oBuf.append( zeroPad( spot.x, 4 ) );
				oBuf.append( " " );
				oBuf.append( zeroPad( spot.y, 4 ) );
				oBuf.append( "\r\n" );
				innerCount++;

				oBuf.insert( 0, 3 );  // Insert innerCount at the beginning.
				buf.append( oBuf );
			}
			else {
				innerCount += writeNode( context, tmpNode, buf, startIndex + innerCount );  // Recurse.

				if ( context.isPhaseOne() ) {
					// Fudge the line map to point one line earlier, to zone.
					if ( tmpNode.getId() != -1 ) {
						int badLine = context.getLine( tmpNode.getId() );
						context.putLine( tmpNode.getId(), badLine-1 );
					}

					// Do not recurse HyperImage id shifts.
				}
			}
		}

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeSoundNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, IOException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 3
		int innerCount = 0;

		if ( currentNode instanceof UHSAudioNode == false ) return 0;
		UHSAudioNode soundNode = (UHSAudioNode)currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " sound" ).append( "\r\n" );
		contentLines = splitContentLines( soundNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		ByteReference audioRef = soundNode.getRawAudioContent();

		long bytesOffset = context.getNextBinaryOffset();
		context.registerBinarySection( audioRef.length() );

		if ( context.isPhaseThree() ) {
			context.writeBinarySegment( audioRef );
		}

		String offsetString = zeroPad( bytesOffset, context.getOffsetNumberWidth() );
		String lengthString = zeroPad( audioRef.length(), context.getLengthNumberWidth() );
		buf.append( "000000 " ).append( offsetString ).append( " " ).append( lengthString );
		buf.append( "\r\n" );
		innerCount++;

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeBlankNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2
		int innerCount = 0;

		UHSNode blankNode = currentNode;

		if ( "--=File Info=--".equals( blankNode.getRawStringContent() ) ) {
			return 0;  // Meta: nested info node.
		}

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " blank" ).append( "\r\n" );
		buf.append( "-" ).append( "\r\n" );
		innerCount += 2;

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeVersionNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + content lines
		int innerCount = 0;

		UHSNode versionNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " version" ).append( "\r\n" );
		contentLines = splitContentLines( versionNode, 1 );
		appendLines( buf, true, contentLines );
		innerCount += 2;

		UHSNode dataNode = versionNode.getFirstChild( "VersionData", UHSNode.class );
		if ( dataNode == null ) {/* Throw an error */}

		contentLines = splitContentLines( dataNode, -1 );
		innerCount += contentLines.length;
		appendLines( buf, true, contentLines );

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeInfoNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + content lines
		int innerCount = 0;

		UHSNode infoNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " info" ).append( "\r\n" );
		buf.append( "-" ).append( "\r\n" );
		innerCount += 2;

		UHSNode dataNode = infoNode.getFirstChild( "InfoData", UHSNode.class );
		if ( dataNode == null ) {/* Throw an error */}

		contentLines = splitContentLines( dataNode, -1 );
		innerCount += contentLines.length;
		for ( int i=contentLines.length-1; i >= 0; i-- ) {
			// Official reader panics if the actual file length does not match Info's line ("length=0204223").
			contentLines[i] = contentLines[i].replaceFirst( "^(length=)[0-9]+$", "$1"+ context.getExpectedFileLength() );
		}
		appendLines( buf, true, contentLines );

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}

	public int writeIncentiveNode( UHSGenerationContext context, UHSNode currentNode, StringBuilder parentBuf, int startIndex ) throws CharacterCodingException, UHSGenerationException, UnsupportedEncodingException {
		// lines: 2 + content lines
		int innerCount = 0;

		UHSNode incentiveNode = currentNode;
		String[] contentLines = null;

		StringBuilder buf = new StringBuilder();
		buf.append( /* lineCount */ " incentive" ).append( "\r\n" );
		buf.append( "-" ).append( "\r\n" );
		innerCount += 2;

		// Generate a whole new Incentive hunk with the proper lines.
		StringBuilder lineBuf = new StringBuilder();
		StringBuilder chunkBuf = new StringBuilder();

		for ( UHSNode restrictedNode : context.getRestrictedNodes() ) {
			if ( restrictedNode.getId() == -1 ) {
				throw new UHSGenerationException( String.format( "Restricted node has no id set: %s", restrictedNode.getRawStringContent() ) );
			}

			String suffix = null;
			if ( restrictedNode.getRestriction() == UHSNode.RESTRICT_NAG ) {
				suffix = "Z";
			}
			else if ( restrictedNode.getRestriction() == UHSNode.RESTRICT_REGONLY ) {
				suffix = "A";
			}
			else {
				throw new UHSGenerationException( "Unexpected node restriction: "+ restrictedNode.getRestriction() );
			}

			chunkBuf.setLength( 0 );
			chunkBuf.append( restrictedNode.getId() ).append( suffix );

			// Word wrap at 78 characters.
			if ( lineBuf.length() > 0 ) {
				if ( lineBuf.length() + 1 + chunkBuf.length() <= 78 ) {
					lineBuf.append( " " ).append( chunkBuf );
				}
				else {
					buf.append( encryptNestString( lineBuf, context.getEncryptionKey() ) ).append( "\r\n" );
					innerCount++;

					lineBuf.setLength( 0 );
					lineBuf.append( chunkBuf );
				}
			}
			else {  // First chunk of the line, no leading space, no wrap.
				lineBuf.append( chunkBuf );
			}
		}

		if ( lineBuf.length() > 0 ) {
			buf.append( encryptNestString( lineBuf, context.getEncryptionKey() ) ).append( "\r\n" );
			innerCount++;
		}

		buf.insert( 0, innerCount );  // Insert innerCount at the beginning.
		parentBuf.append( buf );
		return innerCount;
	}


	/**
	 * Adds zeroes to a number intil it has a minimum number of characters.
	 */
	private String zeroPad( long n, int width ) {
		String s = Long.toString( n );
		if ( s.length() >= width ) return s;

		StringBuilder padBuf = new StringBuilder( s );
		while ( padBuf.length() < width ) {
			padBuf.insert( 0, "0" );
		}
		return ( padBuf.toString() );
	}

	/**
	 * Returns the nimber of CRLF line breaks present in a string.
	 */
	private int crlfCount( CharSequence s ) {
		Matcher m = crlfPtn.matcher( s );
		int lineCount = 0;
		while ( m.find() ) lineCount++;
		return lineCount;
	}


	/**
	 * Returns the raw string content of a UHSNode, split into individual lines.
	 * <p>
	 * This will split on both "^break^" and CRLF.
	 *
	 * @param currentNode  the node to get string content from
	 * @param maxCount  a maximum number of lines to allow, or -1 for no limit
	 * @return an array of lines
	 */
	private String[] splitContentLines( UHSNode currentNode, int maxCount ) throws UHSGenerationException {
		String[] result = currentNode.getRawStringContent().split( "\\^break\\^|\r\n", -1 );
		if ( maxCount != -1 && result.length > maxCount ) {
			throw new UHSGenerationException( String.format( "Node content exceeded max line count (%d): %s", maxCount, currentNode.getRawStringContent() ) );
		}
		return result;
	}

	/**
	 * Applies an encryption algorithm to an array of lines (modifying in-place).
	 *
	 * @param context  a context providing an encryption key, if needed
	 * @param encryption  one of: ENCRYPT_HINT, ENCRYPT_NEST, or ENCRYPT_TEXT
	 * @param lines  an arary of lines to encrypt
	 */
	private void encryptContentLines( UHSGenerationContext context, int encryption, String[] lines ) throws UHSGenerationException {
		if ( context.getEncryptionKey() == null ) {
			throw new UHSGenerationException( "Attempted to encrypt before a key was set in the generation context" );
		}

		for ( int i=lines.length-1; i >= 0; i-- ) {
			if ( encryption == ENCRYPT_HINT ) {
				lines[i] = encryptString( lines[i] );
			}
			else if ( encryption == ENCRYPT_NEST ) {
				lines[i] = encryptNestString( lines[i], context.getEncryptionKey() );
			}
			else if ( encryption == ENCRYPT_TEXT ) {
				lines[i] = encryptTextHunk( lines[i], context.getEncryptionKey() );
			}
		}
	}

	/**
	 * Appends lines to a buffer, adding a line break between each.
	 *
	 * @param buf  the buffer
	 * @param terminate  true to add a line break after the final line, false otherwise
	 * @param lines  an array of lines to append
	 */
	private void appendLines( StringBuilder buf, boolean terminate, String[] lines ) {
		for ( int i=0; i < lines.length; i++ ) {
			buf.append( lines[i] );
			if ( i < lines.length-1 || terminate ) buf.append( "\r\n" );
		}
	}


	/**
	 * Returns text from a UHSNode, escaped.
	 * <p>
	 * Unexpected non-ascii characters are replaced with <b>^?^</b>.
	 * <p>
	 * Escapes have existed from version 88a onwards in most nodes' content and titles.
	 * The # character is the main escape char.
	 * <br>
	 * <ul>
	 * <li><b>##</b> a '#' character.</li>
	 * <li><b>#a+</b>[AaEeIiOoUu][:'`^]<b>#a-</b> accent enclosed letter; :=diaeresis,'=acute,`=grave,^=circumflex.</li>
	 * <li><b>#a+</b>[Nn]~<b>#a-</b> accent enclosed letter with a tilde.</li>
	 * <li><b>#a+</b>ae<b>#a-</b> an ash character.</li>
	 * <li><b>#a+</b>TM<b>#a-</b> a trademark character.</li>
	 * <li><b>#w.</b> raw newlines are spaces.</li>
	 * <li><b>#w+</b> raw newlines are spaces (default).</li>
	 * <li><b>#w-</b> raw newlines are newlines.</li>
	 * </ul>
	 * <p>
	 * The following are left for display code to handle (e.g., UHSTextArea).
	 * <ul>
	 * <li><b>#p+</b> proportional font (default).</li>
	 * <li><b>#p-</b> non-proportional font.</li>
	 * </ul>
	 * <p>
	 * This is displayed as a hyperlink, but it is not clickable.
	 * <ul>
	 * <li><b>#h+</b> through <b>#h-</b> is a hyperlink (http or email).</li>
	 * </ul>
	 * <p>
	 * TODO: Translate between decorated strings.
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Portal: Achievements</i> (hyperlink)</li>
	 * </ul>
	 *
	 * @param currentNode  the node to get content from
	 * @param plain  false to add markup, true to replace with ascii equivalent characters
	 * @return an escaped string, or null if the content wasn't text
	 */
	public String escapeText( UHSNode currentNode, boolean plain ) {
		CharsetEncoder asciiEncoder = Charset.forName( "US-ASCII" ).newEncoder();

		String accentPrefix = "#a+";
		String accentSuffix = "#a-";

		char[] diaeresisMarkup = new char[] {':'};
		char[] diaeresisAccent = new char[] {'Ä','Ë','Ï','Ö','Ü','ä','ë','ï','ö','ü'};
		char[] diaeresisNormal = new char[] {'A','E','I','O','U','a','e','i','o','u'};
		char[] acuteMarkup = new char[] {'\''};
		char[] acuteAccent = new char[] {'Á','É','Í','Ó','Ú','á','é','í','ó','ú'};
		char[] acuteNormal = new char[] {'A','E','I','O','U','a','e','i','o','u'};
		char[] graveMarkup = new char[] {'`'};
		char[] graveAccent = new char[] {'À','È','Ì','Ò','Ù','à','è','ì','ò','ù'};
		char[] graveNormal = new char[] {'A','E','I','O','U','a','e','i','o','u'};
		char[] circumflexMarkup = new char[] {'^'};
		char[] circumflexAccent = new char[] {'Â','Ê','Î','Ô','Û','â','ê','î','ô','û'};
		char[] circumflexNormal = new char[] {'A','E','I','O','U','a','e','i','o','u'};
		char[] tildeMarkup = new char[] {'~'};
		char[] tildeAccent = new char[] {'Ñ','ñ'};
		char[] tildeNormal = new char[] {'N','n'};

		char[][][] accents = new char[][][] {
			{diaeresisMarkup, diaeresisAccent, diaeresisNormal},
			{acuteMarkup, acuteAccent, acuteNormal},
			{graveMarkup, graveAccent, graveNormal},
			{circumflexMarkup, circumflexAccent, circumflexNormal},
			{graveMarkup, graveAccent, graveNormal}
		};

		StringBuilder buf = new StringBuilder();
		char[] tmp = currentNode.getRawStringContent().toCharArray();
		for ( int c=0; c < tmp.length; c++ ) {
			boolean escaped = false;

			for ( int i=0; !escaped && i < accents.length; i++ ) {
				char markup = accents[i][0][0]; char[] accent = accents[i][1]; char[] normal = accents[i][2];

				for ( int a=0; !escaped && a < accent.length; a++ ) {
					if ( tmp[c] == accent[a] ) {
						if ( plain ) {
							buf.append( normal[a] );
						} else {
							buf.append( accentPrefix ).append( normal[a] ).append( markup ).append( accentSuffix );
						}
						escaped = true;
					}
				}
			}

			if ( !escaped && tmp[c] == 'æ' ) {
				String normal = "ae";
				if ( plain ) {
					buf.append( normal );
				} else {
					buf.append( accentPrefix ).append( normal ).append( accentSuffix );
				}
				escaped = true;
			}
			if ( !escaped && tmp[c] == '™' ) {
				String normal = "TM";
				if ( plain ) {
					buf.append( normal );
				} else {
					buf.append( accentPrefix ).append( normal ).append( accentSuffix );
				}
				escaped = true;
			}
			if ( !escaped ) {
				if (asciiEncoder.canEncode( tmp[c] )) {
					buf.append( tmp[c] );
				} else {
					logger.warn( "No escape known for this non-ascii character: {}", tmp[c] );
					buf.append( "^?^" );
				}
			}
			// TODO: Linebreaks
		}

		return buf.toString();
	}
} public class UHSReaderConfig {

	private Properties config;
	private File configFile;


	public UHSReaderConfig( File configFile ) {
		this( new Properties(), configFile );
	}

	public UHSReaderConfig( Properties config, File configFile ) {
		this.config = config;
		this.configFile = configFile;
	}

	/**
	 * Returns a copy of an existing UHSReaderConfig object.
	 */
	public UHSReaderConfig( UHSReaderConfig srcConfig ) {
		this.configFile = srcConfig.getConfigFile();
		this.config = new Properties();
		this.config.putAll( srcConfig.getConfig() );
	}


	public Properties getConfig() {
		return config;
	}

	public File getConfigFile() {
		return configFile;
	}


	public Object setProperty( String key, String value ) {
		return config.setProperty( key, value );
	}

	public int getPropertyAsInt( String key, int defaultValue ) {
		String s = config.getProperty( key );
		if ( s != null && s.matches("^\\d+$") )
			return Integer.parseInt( s );
		else
			return defaultValue;
	}

	public String getProperty( String key, String defaultValue ) {
		return config.getProperty( key, defaultValue );
	}

	public String getProperty( String key ) {
		return config.getProperty( key );
	}


	public void load() throws IOException {
		FileInputStream fis = null;
		try {
			fis = new FileInputStream( configFile );
			config.load( new InputStreamReader( fis, "UTF-8" ) );
		}
		finally {
			try {if ( fis != null ) fis.close();} catch ( IOException e ) {}
		}
	}

	public void store() throws IOException {

		FileOutputStream fos = null;
		try {
			fos = new FileOutputStream( configFile );
			StringBuilder commentsBuf = new StringBuilder();
			commentsBuf.append( "\n" );
			commentsBuf.append( "font_size   - The reader's font size. Default: 12" ).append( "\n" );
			commentsBuf.append( "http_proxy  - An optional network proxy (example: 127.0.0.1:1234)" ).append( "\n" );
			commentsBuf.append( "socks_proxy - An optional network proxy (example: 127.0.0.1:1234)" ).append( "\n" );

			Writer writer = new BufferedWriter( new OutputStreamWriter( fos, "UTF-8" ) );
			config.store( writer, commentsBuf.toString() );
			writer.flush();
		}
		finally {
			try {if ( fos != null ) fos.close();} catch ( IOException e ) {}
		}
	}
} public class UHSReaderFrame extends JFrame implements Nerfable {

	private final Logger logger = LoggerFactory.getLogger( UHSReaderFrame.class );

	private File appDir = new File( "./" );
	private File appDataDir = new File( "./" );
	private File userDataDir = new File( "./" );
	private File hintsDir = new File( userDataDir, "hints" );

	private UHSReaderConfig appConfig = null;

	private String titlePrefix = "";
	private UHSReaderPanel readerPanel = new UHSReaderPanel();
	private UHSDownloaderPanel downloaderPanel = new UHSDownloaderPanel();
	private UHSReaderConfigPanel configPanel = new UHSReaderConfigPanel();
	private UHSReaderAboutPanel aboutPanel = new UHSReaderAboutPanel();


	public UHSReaderFrame( UHSReaderConfig appConfig ) {
		super();
		this.appConfig = appConfig;

		JPanel pane = new JPanel( new BorderLayout() );

		final JTabbedPane tabbedPane = new JTabbedPane();
		readerPanel.setHintsDir( hintsDir );
		readerPanel.registerNodePanel( new DefaultNodePanel() );
		readerPanel.registerNodePanel( new AudioNodePanel() );
		readerPanel.registerNodePanel( new ImageNodePanel() );
		readerPanel.registerNodePanel( new HotSpotNodePanel() );
		readerPanel.registerNodePanel( new RootNodePanel() );
		tabbedPane.add( readerPanel, "Reader" );

		downloaderPanel.setHintsDir( hintsDir );
		tabbedPane.add( downloaderPanel, "Downloader" );

		tabbedPane.add( configPanel, "Settings" );

		tabbedPane.add( aboutPanel, "About" );

		readerPanel.addAncestorListener(new AncestorListener() {
			@Override
			public void ancestorAdded( AncestorEvent event ) {
				UHSReaderFrame.this.setTitle( readerPanel.getReaderTitle() );
			}

			@Override
			public void ancestorMoved( AncestorEvent event ) {
			}

			@Override
			public void ancestorRemoved( AncestorEvent event ) {
				UHSReaderFrame.this.setTitle( null );
			}
		});

		downloaderPanel.getCatalogTable().addMouseListener(new MouseAdapter() {
			@Override
			public void mouseClicked( MouseEvent e ) {
				JTable catalogTable = (JTable)e.getSource();
				if ( catalogTable.getRowCount() == 0 ) return;

				if ( e.getClickCount() == 2 ) {
					int row = catalogTable.getSelectedRow();
					if ( row == -1 ) return;
					CatalogItem catItem = ((CatalogTableModel)catalogTable.getModel()).getUHS( row );
					File uhsFile = new File( hintsDir, catItem.getName() );
					if ( uhsFile.exists() ) {
						tabbedPane.setSelectedIndex( tabbedPane.indexOfTab( "Reader" ) );
						readerPanel.openFile( uhsFile );
					}
				}
			}
		});

		configPanel.addAncestorListener(new AncestorListener() {
			@Override
			public void ancestorAdded( AncestorEvent event ) {
				configPanel.setConfig( UHSReaderFrame.this.appConfig );
			}

			@Override
			public void ancestorMoved( AncestorEvent event ) {
			}

			@Override
			public void ancestorRemoved( AncestorEvent event ) {
				configPanel.setConfig( null );
			}
		});

		configPanel.setApplyCallback(new Runnable() {
			@Override
			public void run() {
				configChanged();
				try {
					UHSReaderFrame.this.appConfig.store();
				}
				catch ( IOException e ) {
					logger.error( "Error storing config", e );
				}
			}
		});

		// Prep the glasspane to nerf this window later.
		this.getGlassPane().setCursor(Cursor.getPredefinedCursor( Cursor.WAIT_CURSOR ));
		this.getGlassPane().setFocusTraversalKeysEnabled( false );
		this.getGlassPane().addMouseListener(new MouseAdapter() {
			@Override
			public void mousePressed( MouseEvent e ) {
				e.consume();
			}

			@Override
			public void mouseReleased( MouseEvent e ) {
				e.consume();
			}
		});
		this.getGlassPane().addKeyListener(new KeyListener() {
			@Override
			public void keyPressed( KeyEvent e ) {
				e.consume();
			}

			@Override
			public void keyReleased( KeyEvent e ) {
				e.consume();
			}

			@Override
			public void keyTyped( KeyEvent e ) {
				e.consume();
			}
		});

		pane.add( tabbedPane, BorderLayout.CENTER );
		this.setContentPane( pane );
	}

	/**
	 * Does extra initialization after the constructor.
	 * <p>
	 * This must be called on the Swing event thread (use invokeLater()).
	 */
	public void init() {
		configChanged();

		// Get the JFileChooser cached.
		try {
			Class.forName( "javax.swing.JFileChooser" );
		}
		catch( ClassNotFoundException e ) {
			logger.error( "Could not cache the JFileChooser class", e );
		}
	}


	public void configChanged() {
		int newFontSize = appConfig.getPropertyAsInt( "font_size", 0 );
		if ( newFontSize > 0 ) readerPanel.setFontSize( newFontSize );

		Pattern hostPortPtn = Pattern.compile( "^([a-z\\d](?:[a-z\\d\\-]{0,61}[a-z\\d])?(\\.[a-z\\d](?:[a-z\\d\\-]{0,61}‌​[a-z\\d])?)*):([0-9]+)$" );
		Matcher m = null;

		Properties sysProp = System.getProperties();
		if ( (m=hostPortPtn.matcher( appConfig.getProperty( "http_proxy", "" ) )).matches() ) {
			sysProp.setProperty( "http.proxyHost", m.group( 1 ) );
			sysProp.setProperty( "http.proxyPort", m.group( 2 ) );
		}
		if ( (m=hostPortPtn.matcher( appConfig.getProperty( "socks_proxy", "" ) )).matches() ) {
			sysProp.setProperty( "socks.proxyHost", m.group( 1 ) );
			sysProp.setProperty( "socks.proxyPort", m.group( 2 ) );
		}
	}


	public void setAppDir( File d ) {
		appDir = d;
	}

	public File getAppDir() {
		return appDir;
	}

	public void setAppDataDir( File d ) {
		appDataDir = d;
	}

	public File getAppDataDir() {
		return appDataDir;
	}

	public void setUserDataDir( File d ) {
		userDataDir = d;
		hintsDir = new File( userDataDir, "hints" );
		readerPanel.setHintsDir( hintsDir );
		downloaderPanel.setHintsDir( hintsDir );
	}

	public File getUserDataDir() {
		return userDataDir;
	}


	public UHSReaderPanel getUHSReaderPanel() {
		return readerPanel;
	}

	public UHSDownloaderPanel getUHSDownloaderPanel() {
		return downloaderPanel;
	}

	public UHSReaderConfigPanel getConfigPanel() {
		return configPanel;
	}


	public void setTitlePrefix( String s ) {
		if ( s == null || s.length() == 0 ) titlePrefix = "";
		else titlePrefix = s;
	}

	public void setTitle( String title ) {
		if ( title == null || title.length() == 0 ) super.setTitle( titlePrefix );
		else super.setTitle( titlePrefix +"-"+ title );
	}


	@Override
	public void setNerfed( boolean b ) {
		//button mnemonics will still work
		Component glassPane = this.getGlassPane();
		if ( b ) {
			glassPane.setVisible( true );
			glassPane.requestFocusInWindow();
		} else {
			glassPane.setVisible( false );
		}
	}
} public class UHSXML {


	/**
	 * Exports a node and its children to xml.
	 *
	 * @param currentNode  a node to start exporting from
	 * @param basename  prefix for referenced binary files
	 * @param out  a stream to print to (e.g., System.out)
	 */
	public static void exportTree( UHSNode currentNode, String basename, OutputStream out ) throws IOException {
		Element rootElement = new Element( "uhs" );
		rootElement.setAttribute( "openuhs-xml-version", "1" );
		Document doc = new Document( rootElement );

		exportNode( rootElement, currentNode, basename, 1 );

		XMLOutputter outputter = new XMLOutputter( Format.getPrettyFormat() );
		outputter.output( doc, out );
	}


	/**
	 * Recursively exports a node and its children to xml Elements.
	 * <p>
	 * Extensions are guessed.
	 *
	 * @param currentNode  a node to start extracting from
	 * @param basename  prefix for referenced binary files
	 * @param n  a number for uniqueness, incrementing with each file
	 * @return a new value for n
	 * @see net.vhati.openuhs.desktopreader.UHSUtil#guessFileExtension(InputStream)
	 */
	private static int exportNode( Element parentElement, UHSNode currentNode, String basename, int n ) throws IOException {
		Element currentElement = null;
		if ( currentNode instanceof UHSHotSpotNode ) {
			currentElement = new Element( "hotspot-node" );
		}
		else if ( currentNode instanceof UHSImageNode ) {
			currentElement = new Element( "image-node" );
		}
		else if ( currentNode instanceof UHSAudioNode ) {
			currentElement = new Element( "audio-node" );
		}
		else if ( currentNode instanceof UHSBatchNode ) {
			currentElement = new Element( "batch-node" );
		}
		else {
			currentElement = new Element( "node" );
		}

		currentElement.setAttribute( "type", currentNode.getType() );

		int id = currentNode.getId();
		currentElement.setAttribute( "id", (( id == -1 ) ? "" : id+"") );

		int restriction = currentNode.getRestriction();
		if ( restriction == UHSNode.RESTRICT_NAG ) {
			currentElement.setAttribute( "restriction", "nag" );
		}
		else if ( restriction == UHSNode.RESTRICT_REGONLY ) {
			currentElement.setAttribute( "restriction", "regonly" );
		}

		int linkId = currentNode.getLinkTarget();
		currentElement.setAttribute( "link-id", (( linkId == -1 ) ? "" : linkId+"") );

		// Content.
		if ( currentNode instanceof UHSNode ) {
			String contentTypeString = "string";

			String contentString = currentNode.getRawStringContent();

			Element contentElement = new Element( "content" );
				contentElement.setAttribute( "type", contentTypeString );
				contentElement.setContent( new CDATA( contentString ) );
				currentElement.addContent( contentElement );
		}
		if ( currentNode instanceof UHSAudioNode ) {
			UHSAudioNode audioNode = (UHSAudioNode)currentNode;
			String contentTypeString = "audio";

			ByteReference audioRef = audioNode.getRawAudioContent();
			InputStream is = null;
			String ext = null;
			try {
				is = audioRef.getInputStream();
				ext = UHSUtil.guessFileExtension( is );
			}
			catch ( IOException e ) {
				throw new IOException( String.format( "Error loading binary content of %s node (\"%s\")", audioNode.getType(), audioNode.getRawStringContent() ), e );
			}
			finally {
				try {if ( is != null ) is.close();} catch ( IOException e ) {}
			}

			String contentString = String.format( "%s%d%s.%s", basename, n, (( id == -1 ) ? "" : "_"+id), ext );
			n++;

			Element contentElement = new Element( "content" );
				contentElement.setAttribute( "type", contentTypeString );
				contentElement.setContent( new CDATA( contentString ) );
				currentElement.addContent( contentElement );
		}
		if ( currentNode instanceof UHSImageNode ) {
			UHSImageNode imageNode = (UHSImageNode)currentNode;
			String contentTypeString = "image";

			ByteReference imageRef = imageNode.getRawImageContent();
			InputStream is = null;
			String ext = null;
			try {
				is = imageRef.getInputStream();
				ext = UHSUtil.guessFileExtension( is );
			}
			catch ( IOException e ) {
				throw new IOException( String.format( "Error loading binary content of %s node (\"%s\")", imageNode.getType(), imageNode.getRawStringContent() ), e );
			}
			finally {
				try {if ( is != null ) is.close();} catch ( IOException e ) {}
			}

			String contentString = String.format( "%s%d%s.%s", basename, n, (( id == -1 ) ? "" : "_"+id), ext );
			n++;

			Element contentElement = new Element( "content" );
				contentElement.setAttribute( "type", contentTypeString );
				contentElement.setContent( new CDATA( contentString ) );
				currentElement.addContent( contentElement );
		}

		// Children.
		if ( currentNode instanceof UHSHotSpotNode ) {
			UHSHotSpotNode hotspotNode = (UHSHotSpotNode)currentNode;

			List<UHSNode> children = hotspotNode.getChildren();
			if ( children != null ) {
				for ( UHSNode tmpNode : children ) {
					HotSpot spot = hotspotNode.getSpot( tmpNode );
					Element childElement = new Element( "hotspot-child" );
						childElement.setAttribute( "zx", spot.zoneX+"" );
						childElement.setAttribute( "zy", spot.zoneY+"" );
						childElement.setAttribute( "zw", spot.zoneW+"" );
						childElement.setAttribute( "zh", spot.zoneH+"" );
						childElement.setAttribute( "px", spot.x+"" );
						childElement.setAttribute( "py", spot.y+"" );
						currentElement.addContent( childElement );
						n = exportNode( childElement, tmpNode, basename, n );
				}
			}
		}
		else if ( currentNode instanceof UHSBatchNode ) {
			List<UHSNode> children = currentNode.getChildren();
			if ( children != null ) {
				int childCount = children.size();
				for ( UHSNode tmpNode : children ) {
					Element childElement = new Element( "batch-child" );
						childElement.setAttribute( "addon", Boolean.toString( ((UHSBatchNode)currentNode).isAddon( tmpNode ) ) );
						currentElement.addContent( childElement );
						n = exportNode( childElement, tmpNode, basename, n );
				}
			}
		}
		else {
			List<UHSNode> children = currentNode.getChildren();
			if ( children != null ) {
				int childCount = children.size();
				for ( UHSNode tmpNode : children ) {
					Element childElement = new Element( "child" );
						currentElement.addContent( childElement );
						n = exportNode( childElement, tmpNode, basename, n );
				}
			}
		}

		parentElement.addContent( currentElement );
		return n;
	}
}
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.security.CodeSource;
import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpec;
import joptsimple.util.RegexMatcher;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
import ch.qos.logback.classic.filter.ThresholdFilter;
import ch.qos.logback.core.Appender;
import ch.qos.logback.core.ConsoleAppender;
import ch.qos.logback.core.FileAppender;
import ch.qos.logback.core.filter.Filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.vhati.openuhs.core.ByteReference;
import net.vhati.openuhs.core.Proto4xUHSParser;
import net.vhati.openuhs.core.UHSAudioNode;
import net.vhati.openuhs.core.UHSImageNode;
import net.vhati.openuhs.core.UHSNode;
import net.vhati.openuhs.core.UHSParser;
import net.vhati.openuhs.core.UHSRootNode;
import net.vhati.openuhs.core.UHSWriter;
import net.vhati.openuhs.desktopreader.UHSReaderConfig;
import net.vhati.openuhs.desktopreader.UHSReaderFrame;
import net.vhati.openuhs.desktopreader.UHSXML;

				if ( "off".equalsIgnoreCase( options.valueOf( optionLoglevel ) ) ) {
					newLevel = Level.OFF;
				}
				else if ( "error".equalsIgnoreCase( options.valueOf( optionLoglevel ) ) ) {
					newLevel = Level.ERROR;
				}
				else if ( "warn".equalsIgnoreCase( options.valueOf( optionLoglevel ) ) ) {
					newLevel = Level.WARN;
				}
				else if ( "info".equalsIgnoreCase( options.valueOf( optionLoglevel ) ) ) {
					newLevel = Level.INFO;
				}
				else if ( "debug".equalsIgnoreCase( options.valueOf( optionLoglevel ) ) ) {
					newLevel = Level.DEBUG;
				}
				if ( newLevel != null ) {
					ch.qos.logback.classic.Logger classicRootLogger = (ch.qos.logback.classic.Logger)LoggerFactory.getLogger( Logger.ROOT_LOGGER_NAME );

					// Find the console's threshold filter.
					Iterator<Appender<ILoggingEvent>> appIt = classicRootLogger.iteratorForAppenders();
					boolean done = false;
					while ( appIt.hasNext() && !done ) {
						Appender<ILoggingEvent> appender = appIt.next();

						if ( appender instanceof ConsoleAppender ) {

							for ( Filter f : appender.getCopyOfAttachedFiltersList() ) {
								if ( f instanceof ThresholdFilter ) {
									((ThresholdFilter)f).setLevel( newLevel.toString() );
									done = true;
									break;
								}
							}
						}
					}
				}
			}

			// Scan  an entire dir for parse errors, discard rootNodes.
			if ( options.has( optionScanDir ) ) {
				File scanDir = options.valueOf( optionScanDir );

				long scanStartNano = System.nanoTime();

				for ( File f : scanDir.listFiles() ) {
					try {
						UHSRootNode tmpRootNode = null;

						if ( f.getName().matches( "(?i).*[.]uhs$" ) ) {
							logger.info( "Scanning \"{}\"", f.getName() );
							UHSParser uhsParser = new UHSParser();

							if ( options.has( optionForce88a ) ) {
								uhsParser.setForce88a( true );
							}
							tmpRootNode = uhsParser.parseFile( f );
						}
						else if ( f.getName().matches( "(?i).*[.]puhs" ) ) {
							logger.info( "Scanning \"{}\"", f.getName() );
							Proto4xUHSParser protoParser = new Proto4xUHSParser();

							tmpRootNode = protoParser.parseFile( f );
						}

						if ( tmpRootNode != null ) {
							if ( options.has( optionSummary ) ) {
								printSummary( f, tmpRootNode );
							}
							validateNode( tmpRootNode, tmpRootNode );
						}
					}
					catch ( Exception e ) {
						logger.error( "Parsing/validating \"{}\" failed", f.getName(), e );
					}
				}

				long scanDurationNano = System.nanoTime() - scanStartNano;
				logger.info( "Dir scan completed ({} seconds)", String.format( "%.2f", ((double)scanDurationNano / 1000000000) ) );
			}

			if ( etcFile != null ) {
				try {
					if ( etcFile.getName().matches( "(?i).*[.]uhs$" ) ) {
						UHSParser uhsParser = new UHSParser();

						if ( options.has( optionForce88a ) ) {
							uhsParser.setForce88a( true );
						}
						rootNode = uhsParser.parseFile( etcFile );
					}
					else if ( etcFile.getName().matches( "(?i).*[.]puhs" ) ) {
						Proto4xUHSParser protoParser = new Proto4xUHSParser();

						rootNode = protoParser.parseFile( etcFile );
					}
				}
				catch ( Exception e ) {
					logger.error( "Parsing \"{}\" failed", etcFile.getName(), e );
				}

				if ( rootNode == null ) {
					throw new ExitException();
				}
			}

			if ( rootNode != null ) {
				logger.debug( "Parsing \"{}\" succeeded", etcFile.getName() );

				if ( options.has( optionSummary ) ) {
					printSummary( etcFile, rootNode );
				}
				if ( options.has( optionPrint ) ) {
					rootNode.printNode("", "\t", System.out);
				}
				if ( options.has( optionSaveXml ) ) {
					String basename = etcFile.getName().replaceAll( "[.][^.]*$", "" );
					FileOutputStream xmlOS = null;
					try {
						xmlOS = new FileOutputStream( "./"+ basename +".xml" );
						UHSXML.exportTree( rootNode, basename +"_", xmlOS );