public interface ByteReference {
	public long length();

	public InputStream getInputStream() throws IOException;
} public class CRC16 implements Checksum {
	private static final int[] a = new int[] {
		0, 32773, 32783, 10, 32795, 30, 20, 32785, 32819, 54,
		60, 32825, 40, 32813, 32807, 34, 32867, 102, 108, 32873,
		120, 32893, 32887, 114, 80, 32853, 32863, 90, 32843, 78,
		68, 32833, 32963, 198, 204, 32969, 216, 32989, 32983, 210,
		240, 33013, 33023, 250, 33003, 238, 228, 32993, 160, 32933,
		32943, 170, 32955, 190, 180, 32945, 32915, 150, 156, 32921,
		136, 32909, 32903, 130, 33155, 390, 396, 33161, 408, 33181,
		33175, 402, 432, 33205, 33215, 442, 33195, 430, 420, 33185,
		480, 33253, 33263, 490, 33275, 510, 500, 33265, 33235, 470,
		476, 33241, 456, 33229, 33223, 450, 320, 33093, 33103, 330,
		33115, 350, 340, 33105, 33139, 374, 380, 33145, 360, 33133,
		33127, 354, 33059, 294, 300, 33065, 312, 33085, 33079, 306,
		272, 33045, 33055, 282, 33035, 270, 260, 33025, 33539, 774,
		780, 33545, 792, 33565, 33559, 786, 816, 33589, 33599, 826,
		33579, 814, 804, 33569, 864, 33637, 33647, 874, 33659, 894,
		884, 33649, 33619, 854, 860, 33625, 840, 33613, 33607, 834,
		960, 33733, 33743, 970, 33755, 990, 980, 33745, 33779, 1014,
		1020, 33785, 1000, 33773, 33767, 994, 33699, 934, 940, 33705,
		952, 33725, 33719, 946, 912, 33685, 33695, 922, 33675, 910,
		900, 33665, 640, 33413, 33423, 650, 33435, 670, 660, 33425,
		33459, 694, 700, 33465, 680, 33453, 33447, 674, 33507, 742,
		748, 33513, 760, 33533, 33527, 754, 720, 33493, 33503, 730,
		33483, 718, 708, 33473, 33347, 582, 588, 33353, 600, 33373,
		33367, 594, 624, 33397, 33407, 634, 33387, 622, 612, 33377,
		544, 33317, 33327, 554, 33339, 574, 564, 33329, 33299, 534,
		540, 33305, 520, 33293, 33287, 514
	};

	private static final int[] b = new int[] {
		0, 33539, 33155, 640, 32963, 960, 320, 33347, 32867, 864,
		480, 33507, 160, 33699, 33059, 544, 32819, 816, 432, 33459,
		240, 33779, 33139, 624, 80, 33619, 33235, 720, 32915, 912,
		272, 33299, 32795, 792, 408, 33435, 216, 33755, 33115, 600,
		120, 33659, 33275, 760, 32955, 952, 312, 33339, 40, 33579,
		33195, 680, 33003, 1000, 360, 33387, 32843, 840, 456, 33483,
		136, 33675, 33035, 520, 32783, 780, 396, 33423, 204, 33743,
		33103, 588, 108, 33647, 33263, 748, 32943, 940, 300, 33327,
		60, 33599, 33215, 700, 33023, 1020, 380, 33407, 32863, 860,
		476, 33503, 156, 33695, 33055, 540, 20, 33559, 33175, 660,
		32983, 980, 340, 33367, 32887, 884, 500, 33527, 180, 33719,
		33079, 564, 32807, 804, 420, 33447, 228, 33767, 33127, 612,
		68, 33607, 33223, 708, 32903, 900, 260, 33287, 32773, 774,
		390, 33413, 198, 33733, 33093, 582, 102, 33637, 33253, 742,
		32933, 934, 294, 33317, 54, 33589, 33205, 694, 33013, 1014,
		374, 33397, 32853, 854, 470, 33493, 150, 33685, 33045, 534,
		30, 33565, 33181, 670, 32989, 990, 350, 33373, 32893, 894,
		510, 33533, 190, 33725, 33085, 574, 32813, 814, 430, 33453,
		238, 33773, 33133, 622, 78, 33613, 33229, 718, 32909, 910,
		270, 33293, 10, 33545, 33161, 650, 32969, 970, 330, 33353,
		32873, 874, 490, 33513, 170, 33705, 33065, 554, 32825, 826,
		442, 33465, 250, 33785, 33145, 634, 90, 33625, 33241, 730,
		32921, 922, 282, 33305, 32785, 786, 402, 33425, 210, 33745,
		33105, 594, 114, 33649, 33265, 754, 32945, 946, 306, 33329,
		34, 33569, 33185, 674, 32993, 994, 354, 33377, 32833, 834,
		450, 33473, 130, 33665, 33025, 514
	};

	private int sum;


	public CRC16() {
		sum = 0x0000;
	}


	@Override
	public long getValue() {
		long result = (((long)sum) & 0xFFFFL);

		if (result >= 0x8000) result = (result + 0x0100) & 0xFFFF;

		return result;
	}

	@Override
	public void reset() {
		sum = 0x0000;
	}

	@Override
	public void update( byte[] b, int off, int len ) {
		for ( int i = off; i < off+len; i++ ) {
			update( (int)b[i] );
		}
	}

	@Override
	public void update( int n ) {
		sum = a[((sum >> 8) & 0xFF)] ^ ((sum & 0xFF) << 8) ^ b[n & 0xFF];
		sum &= 0xFFFF;
	}
} public abstract class ExtraNodeId {
	private final int id;


	public ExtraNodeId( int id ) {
		this.id = id;
	}


	public int getId() {
		return id;
	}
} public class HotSpot {
	public int zoneX;
	public int zoneY;
	public int zoneW;
	public int zoneH;
	public int x;
	public int y;


	public HotSpot( int zX, int zY, int zW, int zH, int pX, int pY ) {
		zoneX = zX;
		zoneY = zY;
		zoneW = zW;
		zoneH = zH;
		x = pX;
		y = pY;
	}

	public HotSpot( int zX, int zY, int zW, int zH ) {
		this( zX, zY, zW, zH, -1, -1 );
	}

	public HotSpot() {
		this( 0, 0, 10, 10, -1, -1 );
	}
} 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 UHSBatchNode extends UHSNode {
	private List<Boolean> batchAddons = new Vector<Boolean>();


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


	/**
	 * Returns the batch status of a child.
	 *
	 * @param childNode  a child node
	 * @return true if that node should be revealed when its predecessor is
	 */
	public boolean isAddon( UHSNode childNode ) {
		int index = super.indexOfChild( childNode );
		if ( index == -1 ) return false;
		return batchAddons.get( index ).booleanValue();
	}

	/**
	 * Returns the batch status of a child.
	 *
	 * @param n  index of a child node
	 * @return if that node should be revealed when its predecessor is
	 */
	public boolean isAddon( int n ) {
		if ( super.getChildCount()-1 < n ) return false;
		return batchAddons.get( n ).booleanValue();
	}

	/**
	 * Sets the batch status of a child.
	 *
	 * @param childNode  a child node
	 * @param addon  true if that node should be revealed when its predecessor is, false otherwise
	 */
	public void setAddon( UHSNode childNode, boolean addon ) {
		int index = super.indexOfChild( childNode );
		if ( index == -1 ) return;

		batchAddons.set( index, new Boolean( addon ) );
	}

	/**
	 * Sets the zone/position of a child.
	 *
	 * @param n  index of a child node
	 * @param addon  true if that node should be revealed when its predecessor is, false otherwise
	 */
	public void setAddon( int n, boolean addon ) {
		if ( super.getChildCount()-1 < n ) return;
		batchAddons.set( n, new Boolean( addon ) );
	}


	/**
	 * Overridden to make linking impossible.
	 *
	 * @param n  ID of the node to target
	 * @see net.vhati.openuhs.core.UHSNode#setLinkTarget(int)
	 */
	@Override
	public void setLinkTarget( int n ) {
		return;
	}


	/**
	 * Replace or initialize the current children.
	 * <p>
	 * This method gives the new nodes default batch status (revealed individually).
	 *
	 * @param newChildren  a List of new child UHSNodes
	 */
	@Override
	public void setChildren( List<UHSNode> newChildren ) {
		if ( newChildren == null || newChildren.size() == 0 ) {
			this.removeAllChildren();
		}
		else {
			super.setChildren( newChildren );
			batchAddons.clear();
			for ( int i=0; i < newChildren.size(); i++ ) {
				batchAddons.add( Boolean.FALSE );
			}
		}
	}


	@Override
	public void addChild( UHSNode newNode ) {
		super.addChild( newNode );
		batchAddons.add( Boolean.FALSE );
	}

	@Override
	public void removeChild( UHSNode doomedNode ) {
		int index = super.indexOfChild( doomedNode );
		if ( index == -1 ) return;
		super.removeChild( index );
		batchAddons.remove( index );
	}

	@Override
	public void removeAllChildren() {
		super.removeAllChildren();
		batchAddons.clear();
	}


	/**
	 * Sets the current reveal progress.
	 * <p>
	 * The given amount will be a minimum. Revealing will continue to
	 * increment for every 'addon' child following the last one explicitly
	 * revealed.
	 *
	 * @param n  a non-negative number less than or equal to the maximum reveal
	 */
	@Override
	public void setCurrentReveal( int n ) {
		n = Math.min( Math.max( 0, n ), getMaximumReveal() );
		int pendingReveal = n;
		for ( int i=(n-1)+1; i < this.getChildCount(); i++ ) {  // Check the children that follow.
			if ( batchAddons.get( i ).booleanValue() ) {
				pendingReveal++;
			} else {
				break;
			}
		}
		super.setCurrentReveal( pendingReveal );
	}
} public class UHSHotSpotNode extends UHSImageNode {
	private List<HotSpot> spots = new Vector<HotSpot>();


	public UHSHotSpotNode( String inType ) {
		super( inType );
	}


	/**
	 * Returns the zone/position of a child.
	 *
	 * @param childNode  a child node
	 * @return a HotSpot
	 */
	public HotSpot getSpot( UHSNode childNode ) {
		int index = super.indexOfChild( childNode );
		if ( index == -1 ) return null;
		return spots.get( index );
	}

	/**
	 * Gets the zone/position of a child.
	 *
	 * @param n  index of a child node
	 * @return a HotSpot
	 */
	public HotSpot getSpot( int n ) {
		if ( super.getChildCount()-1 < n ) return null;
		return spots.get( n );
	}

	/**
	 * Sets the zone/position of a child.
	 *
	 * @param childNode  an existing child node
	 * @param spot  a HotSpot
	 */
	public void setSpot( UHSNode childNode, HotSpot spot ) {
		int index = super.indexOfChild( childNode );
		if ( index == -1 ) return;

		spots.set( index, spot );
	}

	/**
	 * Sets the zone/position of a child.
	 *
	 * @param n  index of a child node
	 * @param spot  a HotSpot
	 */
	public void setSpot( int n, HotSpot spot ) {
		if ( super.getChildCount()-1 < n ) return;
		spots.set( n, spot );
	}


	/**
	 * Overridden to make linking impossible.
	 *
	 * @param n  ID of the node to target
	 * @see net.vhati.openuhs.core.UHSNode#setLinkTarget(int)
	 */
	@Override
	public void setLinkTarget( int n ) {
		return;
	}


	/**
	 * Replace or initialize the current children.
	 * <p>
	 * This method gives the new nodes default zones/positions.
	 *
	 * @param newChildren  a List of new child UHSNodes
	 */
	@Override
	public void setChildren( List<UHSNode> newChildren ) {
		if ( newChildren == null || newChildren.size() == 0 ) {
			this.removeAllChildren();
		}
		else {
			super.setChildren( newChildren );
			spots.clear();
			for ( int i=0; i < newChildren.size(); i++ ) {
				spots.add( new HotSpot() );
			}
		}
	}


	@Override
	public void addChild( UHSNode newChild ) {
		super.addChild( newChild );
		spots.add( new HotSpot() );
	}

	@Override
	public void removeChild( UHSNode childNode ) {
		int index = super.indexOfChild( childNode );
		if ( index == -1 ) return;
		super.removeChild( index );
		spots.remove( index );
	}

	@Override
	public void removeAllChildren() {
		super.removeAllChildren();
		spots.clear();
	}



	public static class HotSpotMainImageId extends ExtraNodeId {

		public HotSpotMainImageId( int id ) {
			super( id );
		}

		@Override
		public String toString() {
			return String.format( "%d (HotSpotMainImage)", this.getId() );
		}
	}
} 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 UHSParseContext {

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

	protected boolean binaryDeferred = false;
	protected File file = null;
	protected long binHunkOffset = -1;
	protected UHSRootNode rootNode = null;
	protected int[] encryptionKey = null;

	protected List<String> allLines = null;
	protected byte[] binHunk = null;
	protected long binHunkLength = 0;

	protected List<ExtraNodeId> extraIds = new ArrayList<ExtraNodeId>();

	protected int lineIndexFudge = 0;
	protected int lastLineIndex = -1;


	public UHSParseContext() {
	}


	/**
	 * Sets whether to preload binary hunk segments into arrays or defer reads until needed.
	 */
	public void setBinaryDeferred( boolean b ) {
		binaryDeferred = b;
	}

	public boolean isBinaryDeferred() {
		return binaryDeferred;
	}


	/**
	 * Sets the file being parsed.
	 */
	public void setFile( File file ) {
		this.file = file;
	}

	public File getFile() {
		return file;
	}


	/**
	 * Sets the offset, from the beginning of the UHS file, to the binary hunk.
	 * <p>
	 * In the 88a format, there is no binary hunk.
	 * <p>
	 * In the 9x format, there's a special 0x1a byte in the file.
	 * Everything after that is the binary hunk.
	 */
	public void setBinaryHunkOffset( long binHunkOffset ) {
		this.binHunkOffset = binHunkOffset;
	}

	public long getBinaryHunkOffset() {
		return binHunkOffset;
	}

	/**
	 * Sets the binary hunk: either a preloaded array, or null.
	 *
	 * @param binHunk  the array, can be null if binaryDeferred is true
	 * @see #setBinaryHunkLength(long)
	 */
	public void setBinaryHunk( byte[] binHunk ) {
		this.binHunk = binHunk;
	}

	/**
	 * Sets the length of the binary hunk.
	 *
	 * @param binHunkLength  the length of the binary hunk (required even when binhunk is null)
	 * @see #setBinaryHunk(byte[])
	 */
	public void setBinaryHunkLength( long binHunkLength ) {
		this.binHunkLength = binHunkLength;
	}


	/**
	 * Sets the root node whose tree is under construction.
	 */
	public void setRootNode( UHSRootNode rootNode ) {
		this.rootNode = rootNode;
	}

	public UHSRootNode getRootNode() {
		return rootNode;
	}


	/**
	 * Sets the key for decrypting various hunks' text in the 9x format.
	 *
	 * @param key
	 * @see net.vhati.openuhs.core.UHSParser#decryptNestString(CharSequence, int[])
	 * @see net.vhati.openuhs.core.UHSParser#decryptTextHunk(CharSequence, int[])
	 * @see net.vhati.openuhs.core.UHSParser#generate9xKey(CharSequence)
	 */
	public void setEncryptionKey( int[] key ) {
		this.encryptionKey = key;
	}

	public int[] getEncryptionKey() {
		return encryptionKey;
	}


	/**
	 * An amount to add to any index passed into getLine().
	 * <p>
	 * For the 9x format, this is used to ignore the fake 88a header lines.
	 *
	 * @param lineIndexFudge  the number of lines to ignore
	 * @see #getLine(int)
	 */
	public void setLineFudge( int lineIndexFudge ) {
		this.lineIndexFudge = lineIndexFudge;
	}

	public int getLineFudge() {
		return lineIndexFudge;
	}

	/**
	 * Sets a list of all line break delimited strings decoded from the UHS file.
	 */
	public void setAllLines( List<String> allLines ) {
		this.allLines = allLines;
	}


	/**
	 * Registers an extraneous id to ignore.
	 * <p>
	 * Rather than track unnecessary ids, this will suppress warnings about
	 * unsatisfied Incentive hunk references.
	 * <p>
	 * HyperImage hunks sometimes have their main image line referenced in
	 * an Incentive hunk.
	 */
	public void registerExtraId( ExtraNodeId extraId ) {
		extraIds.add( extraId );
	}

	/**
	 * Returns an object representing an extraneous id, or null.
	 */
	public ExtraNodeId getExtraId( int id ) {
		for ( ExtraNodeId extraId : extraIds ) {
			if ( extraId.getId() == id ) return extraId;
		}
		return null;
	}


	/**
	 * Returns a reference to read bytes from the binary hunk.
	 * <p>
	 * Images, comments, sounds, etc., are stored there.
	 * <p>
	 * If the binary hunk has been set to an array, an ArrayByteReference
	 * will be returned. Otherwise, a FileRegionByteReference will be
	 * returned, as long as the binary hunk offset has been set.
	 * <p>
	 * The offset here is relative to the start of the binary hunk, NOT the beginning of the file.
	 *
	 * @param offset  the start offset of the data, within the binary hunk
	 * @param length  the number of bytes to read
	 * @return a ByteReference to retrieve the relevant bytes
	 * @see #setBinaryHunk(byte[])
	 * @see #setBinaryHunkOffset(long)
	 */
	public ByteReference readBinaryHunk( long offset, int length ) {
		if ( offset < 0 || length < 0 ) {
			throw new IllegalArgumentException( String.format( "Offset (%d) and length (%d) must not be negative (last parsed line: %d)", offset, length, getLastParsedLineNumber() ) );
		}
		if ( offset+length > binHunkLength ) {
			throw new ArrayIndexOutOfBoundsException( String.format( "Offset (%d) + length (%d) exceeded the binary hunk length (%d) (last parsed line: %d)", offset, length, binHunkLength, getLastParsedLineNumber() ) );
		}

		if ( isBinaryDeferred() ) {
			if ( binHunkOffset >= 0 ) {
				return new FileRegionByteReference( file, binHunkOffset+offset, length );
			}
			else {
				throw new IllegalStateException( "Binary hunk offset was not set" );
			}
		}
		else {
			if ( binHunk != null ) {
				byte[] data = new byte[length];
				System.arraycopy( binHunk, (int)offset, data, 0, length );
				return new ArrayByteReference( data );
			}
			else {
				throw new IllegalStateException( "Binary hunk array was not set" );
			}
		}
	}

	/**
	 * Returns the unsigned checksum value stored at the end of the file.
	 * <p>
	 * The last two bytes of a 9x format file are a little-endian 16-bit
	 * unsigned short CRC16 of the entire file, excluding those last two
	 * bytes.
	 * <p>
	 * Java doesn't have unsigned types, so the return value is a masked
	 * int.
	 */
	public int readStoredChecksumValue() {
		int result = -1;
		ByteReference crcRef = readBinaryHunk( binHunkLength-2, 2 );
		InputStream is = null;
		try {
			is = crcRef.getInputStream();

			ByteBuffer crcBuf = ByteBuffer.allocate( 2 );
			crcBuf.order( ByteOrder.LITTLE_ENDIAN );
			for ( int i=0; i < 2; i++ ) {
				int b = is.read();
				if ( b == -1 ) throw new IOException( "Unexpected end of binary hunk" );
				crcBuf.put( (byte)b );
			}
			crcBuf.flip();
			result = crcBuf.getShort() & 0xFFFF;
		}
		catch ( IOException e ) {
			logger.error( "Error reading stored checksum: {}", e );
		}

		return result;
	}

	/**
	 * Returns a string from allLines at a given index, while caching that index for logging purposes.
	 * <p>
	 * Note: While a list of lines is involved, the index is not synonymous
	 * with the line number seen in text editors. When parsing the 9x format,
	 * index is reset to 0 at the end of the fake 88a header.
	 *
	 * @param index  an index within allLines (0-based)
	 */
	public String getLine( int index ) {
		lastLineIndex = index;
		return allLines.get( lineIndexFudge + index );
	}

	/**
	 * Returns true if a given index would be valid in a call to getLine(), false otherwise.
	 */
	public boolean hasLine( int index ) {
		return ( (lineIndexFudge + index) > 0 && (lineIndexFudge + index) < allLines.size() );
	}

	/**
	 * Returns the 1-based line number of the index in the last call to getLine().
	 * <p>
	 * This will be counted from the very beginning of the file,
	 * including 9x format's fake 88a header.
	 */
	public int getLastParsedLineNumber() {
		return ( lineIndexFudge + lastLineIndex + 1 );
	}
} public class UHSParseException extends IOException {


    public UHSParseException() {
	}

	public UHSParseException( String message ) {
		super( message );
	}

	public UHSParseException( Throwable cause ) {
		super( cause );
	}

	public UHSParseException( String message, Throwable cause ) {
		super( message, cause );
	}
} 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 Version88CreditsDecorator extends Version88StringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		char[] linebreak = StringDecorator.linebreak;

		int lineCharCount = 0;
		for ( int c=0; c < tmp.length; c++ ) {
			if ( c+linebreak.length < tmp.length ) {
				char[] chunkA = new char[linebreak.length];
				System.arraycopy( tmp, c, chunkA, 0, linebreak.length );
				if (Arrays.equals( chunkA, linebreak )) {
					if ( lineCharCount < 20 ) {
						buf.append( "\n" );
					} else {
						buf.append( " " );
					}
					lineCharCount = 0;
					c += linebreak.length-1; continue;
				}
			}

			buf.append( tmp[c] );
			lineCharCount++;
		}


		String fragment = buf.toString();
		String[] decoNames = new String[0];
		Map[] argMaps = new LinkedHashMap[0];
		DecoratedFragment[] result = new DecoratedFragment[] {new DecoratedFragment( fragment, decoNames, argMaps )};
		return result;
	}
} public class Version9xCommentDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}
} public class Version9xCreditDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}
} public class Version9xHintDecorator extends Version9xStringDecorator {
	private static final char[] emptyline = getEmptyLineChars();
	private static final char[] emptysplice = new char[] {'\n',' ','\n'};


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			// Handle empty lines
			if ( c+emptyline.length < tmp.length ) {
				char[] chunkA = new char[emptyline.length];
				System.arraycopy( tmp, c, chunkA, 0, emptyline.length );
				if (Arrays.equals( chunkA, emptyline )) {
					buf.append( emptysplice );
					c += emptyline.length-1; continue;
				}
			}

			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}


	/**
	 * Builds the empty line char sequence.
	 * That is: "^break^ ^break^".
	 *
	 * @see StringDecorator#linebreak StringDecorator.linebreak
	 */
	private static char[] getEmptyLineChars() {
		char[] linebreak = StringDecorator.linebreak;
		char[] middle = new char[] {' '};
		char[] result = new char[linebreak.length+middle.length+linebreak.length];
		System.arraycopy( linebreak, 0, result, 0, linebreak.length );
		System.arraycopy( middle, 0, result, linebreak.length, middle.length );
		System.arraycopy( linebreak, 0, result, linebreak.length+middle.length, linebreak.length );
		return result;
	}
} public class Version9xIncentiveDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}
} public class Version9xInfoDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		String[] lines = rawContent.split( "\\^break\\^" );

		StringBuffer lengthBuf = new StringBuffer();
		StringBuffer dateBuf = new StringBuffer();
		StringBuffer timeBuf = new StringBuffer();
		StringBuffer authorBuf = new StringBuffer();
		StringBuffer publisherBuf = new StringBuffer();
		StringBuffer copyrightBuf = new StringBuffer();
		StringBuffer authorNoteBuf = new StringBuffer();
		StringBuffer gameNoteBuf = new StringBuffer();
		StringBuffer noticeBuf = new StringBuffer();  // ">"

		StringBuffer unknownBuf = new StringBuffer();
		StringBuffer[] buffers = new StringBuffer[] {lengthBuf, dateBuf, timeBuf, authorBuf, publisherBuf, copyrightBuf, authorNoteBuf, gameNoteBuf, noticeBuf, unknownBuf};
		String[] prefixes = new String[] {"length=", "date=", "time=", "author=", "publisher=", "copyright=", "author-note=", "game-note=", ">"};
		String[] breaks = new String[] {"\n", "\n", "\n", "\n", "\n", " ", " ", " ", " "};
		boolean[] trims = new boolean[] {false, false, false, false, false, true, true, true, true};

		StringBuffer currentBuffer = null;
		String breakChar = " ";

		for ( int i=0; i < lines.length; i++ ) {
			String tmp = lines[i];

			currentBuffer = null;
			for ( int p=0; p < prefixes.length; p++ ) {
				if (tmp.startsWith( prefixes[p] )) {
					currentBuffer = buffers[p];
					breakChar = breaks[p];
					if ( trims[p] == true ) tmp = tmp.substring( prefixes[p].length() );
					break;
				}
			}
			if ( currentBuffer == null ) {
				// TODO: Get the logger out of here?
				Logger logger = LoggerFactory.getLogger( Version9xInfoDecorator.class );
				logger.warn( "InfoDecorator encountered an unexpected line in node content: {}", tmp );

				currentBuffer = unknownBuf;
				breakChar = "\n";
			}

			// Certain multi-line buffers, get a prefix when they're 0-length
			if ( currentBuffer.length() > 0 ) currentBuffer.append( breakChar );
			else if ( currentBuffer == copyrightBuf ) currentBuffer.append( "copyright=" );
			else if ( currentBuffer == authorNoteBuf ) currentBuffer.append( "author-note=" );
			else if ( currentBuffer == gameNoteBuf ) currentBuffer.append( "game-note=" );
			currentBuffer.append( tmp );
		}

		StringBuffer tmpContent = new StringBuffer();
		for ( int i=0; i < buffers.length; i++ ) {
			if ( buffers[i].length() == 0 ) continue;
			if ( tmpContent.length() > 0 ) {
				tmpContent.append( "\n" );
				if ( buffers[i] == copyrightBuf ||
						buffers[i] == authorNoteBuf ||
						buffers[i] == gameNoteBuf ||
						buffers[i] == noticeBuf ) {
					tmpContent.append( "\n" );
				}
			}
			tmpContent.append( buffers[i] );
		}

		String fragment = tmpContent.toString();
		String[] decoNames = new String[0];
		Map[] argMaps = new LinkedHashMap[0];
		DecoratedFragment[] result = new DecoratedFragment[] {new DecoratedFragment( fragment, decoNames, argMaps )};
		return result;
	}
} public class Version9xStringDecorator extends StringDecorator {
	public static final String MONOSPACED = "Monospaced";
	public static final String HYPERLINK = "Hyperlink";

	private static final Decoration[] decorations = new Decoration[] {
		new Decoration( MONOSPACED, new char[]{'#','p','-'}, new char[]{'#','p','+'} ),
		new Decoration( HYPERLINK, new char[]{'#','h','+'}, new char[]{'#','h','-'} )
	};

	private static final char[] wspcA = new char[] {'#','w','+'};
	private static final char[] wspcB = new char[] {'#','w','.'};
	private static final char[] wnlin = new char[] {'#','w','-'};
	private static final char[] accentPrefix = new char[] {'#','a','+'};
	private static final char[] accentSuffix = new char[] {'#','a','-'};

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

	private static final char[][][] accents = new char[][][] {
		{diaeresisMarkup, diaeresisAccent, diaeresisNormal},
		{acuteMarkup, acuteAccent, acuteNormal},
		{graveMarkup, graveAccent, graveNormal},
		{circumflexMarkup, circumflexAccent, circumflexNormal},
		{tildeMarkup, tildeAccent, tildeNormal}
	};


	public Version9xStringDecorator() {
		super();
	}


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {"\n"};  // Initial value varies by decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decorations.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decorations.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decorations[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}


	/**
	 * Consumes any markup at an index and adds replacement chars to a buffer.
	 * <p>
	 * This should be called early to intercept the escaped '#' escape
	 * character before other parse methods.
	 * <br>
	 * <ul>
	 * <li><b>##</b> a '#' character.</li>
	 * <li><b>#a+</b>[AaEeIiOoUu][:'`^]<b>#a-</b> accent enclosed letter; :=diaeresis,'=acute,`=grave,^=curcumflex.</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>
	 * </ul>
	 *
	 * @param contentChars  raw content with markup
	 * @param buf  a destination buffer
	 * @param c  index in contentChars to check for markup
	 * @return offset to the final consumed char
	 */
	public int parseSymbolMarkup( char[] contentChars, StringBuffer buf, int c ) {
		char[] tmp = contentChars;

		if ( c+1 < tmp.length ) {
			if ( tmp[c] == '#' && tmp[c+1] == '#' ) {buf.append( '#' ); return 1;}
		}

		if ( c+7 < tmp.length ) {
			char[] chunkA = new char[] {tmp[c],tmp[c+1],tmp[c+2]};
			char[] chunkB = new char[] {tmp[c+5],tmp[c+6],tmp[c+7]};
			if ( Arrays.equals( chunkA, accentPrefix ) && Arrays.equals( chunkB, accentSuffix ) ) {
				boolean replaced = false;
				for ( int i=0; !replaced && i < accents.length; i++ ) {
					char markup = accents[i][0][0]; char[] accent = accents[i][1]; char[] normal = accents[i][2];
					if ( tmp[c+4] == markup ) {
						for ( int a=0; !replaced && a < accent.length; a++ ) {
							if ( tmp[c+3] == normal[a] ) {
								buf.append( accent[a] );
								replaced = true;
							}
						}
					}
				}
				if ( !replaced && tmp[c+3] == 'a' && tmp[c+4] == 'e' ) {
					buf.append( 'æ' ); replaced = true;
				}
				if ( !replaced && tmp[c+3] == 'T' && tmp[c+4] == 'M' ) {
					buf.append( '™' ); replaced = true;
				}
				if ( !replaced ) {
					// TODO: Get the logger out of here?
					Logger logger = LoggerFactory.getLogger( Version9xStringDecorator.class );
					logger.warn( "StringDecorator encountered an expected accent ({}) in node content: {}", String.format( "%c%c", tmp[c+3], tmp[c+4] ), (new String(tmp)) );
				}
				if ( replaced ) {return 7;}
			}
		}

		return -1;
	}


	/**
	 * Consumes any markup at an index and adds replacement chars to a buffer.
	 * <br>
	 * <ul>
	 * <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>
	 *
	 * @param contentChars  raw content with markup
	 * @param buf  a destination buffer
	 * @param c  index in contentChars to check for markup
	 * @param breakStr  a single-element wrapper array to return the current line break replacement string
	 * @return offset to the final consumed char
	 * @see StringDecorator#linebreak
	 */
	public int parseLineBreakMarkup( char[] contentChars, StringBuffer buf, int c, String[] breakStr ) {
		char[] tmp = contentChars;
		if ( c+2 < tmp.length ) {
			char[] chunkA = new char[] {tmp[c],tmp[c+1],tmp[c+2]};
			if ( Arrays.equals( chunkA, wspcA ) || Arrays.equals( chunkA, wspcB ) ) {breakStr[0] = " "; return 2;}
			else if ( Arrays.equals( chunkA, wnlin ) ) {breakStr[0] = "\n"; return 2;}
		}

		char[] linebreak = StringDecorator.linebreak;
		if ( c+linebreak.length <= tmp.length ) {
			char[] chunkA = new char[linebreak.length];
			System.arraycopy( tmp, c, chunkA, 0, linebreak.length );
			if ( Arrays.equals( chunkA, linebreak ) ) {buf.append( breakStr[0] ); return linebreak.length-1;}
		}

		return -1;
	}


	/**
	 * Consumes markup, if found, and creates a decorated fragment using buffer contents.
	 * <p>
	 * A dictionary of decoration markup is used to determine where new
	 * fragments begin, so subclasses can customize what to look for.
	 * The destination buffer will be cleared as fragments are created.
	 * <p>
	 * This should be called last after other parse methods have
	 * filled the buffer.
	 *
	 * @param contentChars  raw content with markup
	 * @param buf  a destination buffer
	 * @param c  index in contentChars to check for markup
	 * @param decorations  a dictionary of Decorations to check for
	 * @param decoStates  an array to return the current decoration in-use counts
	 * @param fragmentList  a List in which to store any new DecoratedFragments
	 * @return offset to the final consumed char
	 * @see #getDecorations()
	 */
	public int parseDecorationMarkup( char[] contentChars, StringBuffer buf, int c, Decoration[] decorations, int[] decoStates, List<DecoratedFragment> fragmentList ) {
		char[] tmp = contentChars;
		int result = -1;
		int dNum = -1;  // Decoration to apply delayed inc/dec
		int dOff = 0;   // Delayed inc/dec amount

		// Discover a pending decoration state change, but change it later
		for ( int d=0; d < decorations.length; d++ ) {
			if ( decorations[d].prefixMatches( tmp, c ) ) {
				dNum = d; dOff += 1;
				result = decorations[d].prefix.length-1;
				break;
			}
			if ( decoStates[d] > 0 && decorations[d].suffixMatches( tmp, c ) ) {
				dNum = d; dOff -= 1;
				result = decorations[d].suffix.length-1;
				break;
			}
		}

		// Finalize the current decoration state, then apply the new state
		if ( result != -1 ) {
			if ( buf.length() > 0 ) {
				String fragment = buf.toString();
				List<String> attribList = new ArrayList<String>( 1 );
				for ( int d=0; d < decorations.length; d++ ) {
					if ( decoStates[d] > 0 ) attribList.add( decorations[d].name );
				}
				String[] decoNames = attribList.toArray( new String[attribList.size()] );
				Map[] argMaps = new LinkedHashMap[attribList.size()];
				fragmentList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
				buf.setLength( 0 );
			}
			decoStates[dNum] += dOff;  // Now apply the new decoration state
		}

		return result;
	}


	/**
	 * Returns known markup decorations.
	 * <p>
	 * Subclasses may call this, add their own decorations,
	 * and then call parseDecorationMarkup().
	 * <br>
	 * <ul>
	 * <li><b>#h+</b> through <b>#h-</b> is a hyperlink (http or email).</li>
	 * <li><b>#p+</b> proportional font (default).</li>
	 * <li><b>#p-</b> non-proportional font.</li>
	 * </ul>
	 * <br>
	 * <ul>
	 * <li>Illustrative UHS: <i>Portal: Achievements</i> (hyperlink)</li>
	 * </ul>
	 *
	 * @see #parseDecorationMarkup(char[], StringBuffer, int, Decoration[], int[], List)
	 */
	public Decoration[] getDecorations() {
		return decorations;
	}
} public class Version9xTextDecorator extends Version9xStringDecorator {
	private static final char[] emptyline = getEmptyLineChars();
	private static final char[] emptysplice = new char[] {'\n',' ','\n'};


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {"\n"};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			// Handle empty lines
			if ( c+emptyline.length < tmp.length ) {
				char[] chunkA = new char[emptyline.length];
				System.arraycopy( tmp, c, chunkA, 0, emptyline.length );
				if (Arrays.equals( chunkA, emptyline )) {
					buf.append( emptysplice );
					c += emptyline.length-1; continue;
				}
			}

			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}


	/**
	 * Builds the empty line char sequence.
	 * <p>
	 * That is: "^break^ ^break^".
	 *
	 * @see StringDecorator#linebreak
	 */
	private static char[] getEmptyLineChars() {
		char[] linebreak = StringDecorator.linebreak;
		char[] middle = new char[] {' '};
		char[] result = new char[linebreak.length+middle.length+linebreak.length];
		System.arraycopy( linebreak, 0, result, 0, linebreak.length );
		System.arraycopy( middle, 0, result, linebreak.length, middle.length );
		System.arraycopy( linebreak, 0, result, linebreak.length+middle.length, linebreak.length );
		return result;
	}
} public class Version9xTitleDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] linebreak = StringDecorator.linebreak;
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			if ( c+linebreak.length < tmp.length ) {
				char[] chunkA = new char[linebreak.length];
				System.arraycopy( tmp, c, chunkA, 0, linebreak.length );
				if (Arrays.equals( chunkA, linebreak )) {
					buf.append( " " );
					c += linebreak.length-1; continue;
				}
			}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}
} public class Version9xVersionDecorator extends Version9xStringDecorator {


	@Override
	public DecoratedFragment[] getDecoratedString( String rawContent ) {
		List<DecoratedFragment> resultList = new ArrayList<DecoratedFragment>();
		char[] tmp = rawContent.toCharArray();
		StringBuffer buf = new StringBuffer( tmp.length );
		int consumedOffset = -1;
		String[] breakStr = new String[] {" "};  // Initial value varies by Decorator
		Decoration[] decos = getDecorations();
		int[] decoStates = new int[decos.length];

		for ( int c=0; c < tmp.length; c++ ) {
			consumedOffset = parseSymbolMarkup( tmp, buf, c );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseLineBreakMarkup( tmp, buf, c, breakStr );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			consumedOffset = parseDecorationMarkup( tmp, buf, c, decos, decoStates, resultList );
			if ( consumedOffset != -1 ) {c += consumedOffset; continue;}

			buf.append( tmp[c] );
		}

		// Handle lingering content
		if ( buf.length() > 0 ) {
			String fragment = buf.toString();
			List<String> attribList = new ArrayList<String>( 1 );
			for ( int d=0; d < decos.length; d++ ) {
				if ( decoStates[d] > 0 ) attribList.add( decos[d].name );
			}
			String[] decoNames = attribList.toArray( new String[attribList.size()] );
			Map[] argMaps = new LinkedHashMap[attribList.size()];
			resultList.add( new DecoratedFragment( fragment, decoNames, argMaps ) );
		}

		return resultList.toArray( new DecoratedFragment[resultList.size()] );
	}
}
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.CharBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.vhati.openuhs.core.ByteReference;
import net.vhati.openuhs.core.CRC16;
import net.vhati.openuhs.core.ExtraNodeId;
import net.vhati.openuhs.core.HotSpot;
import net.vhati.openuhs.core.UHSAudioNode;
import net.vhati.openuhs.core.UHSBatchNode;
import net.vhati.openuhs.core.UHSHotSpotNode;
import net.vhati.openuhs.core.UHSImageNode;
import net.vhati.openuhs.core.UHSNode;
import net.vhati.openuhs.core.UHSParseContext;
import net.vhati.openuhs.core.UHSParseException;
import net.vhati.openuhs.core.UHSRootNode;
import net.vhati.openuhs.core.markup.Version88CreditsDecorator;
import net.vhati.openuhs.core.markup.Version9xCommentDecorator;
import net.vhati.openuhs.core.markup.Version9xCreditDecorator;
import net.vhati.openuhs.core.markup.Version9xHintDecorator;
import net.vhati.openuhs.core.markup.Version9xIncentiveDecorator;
import net.vhati.openuhs.core.markup.Version9xInfoDecorator;
import net.vhati.openuhs.core.markup.Version9xStringDecorator;
import net.vhati.openuhs.core.markup.Version9xTextDecorator;
import net.vhati.openuhs.core.markup.Version9xTitleDecorator;
import net.vhati.openuhs.core.markup.Version9xVersionDecorator;
	 * <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 );