Java tutorial
/* * $Id$ * Copyright (C) 2006 Klaus Reimer <k@ailis.de> * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to * deal in the Software without restriction, including without limitation the * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or * sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS * IN THE SOFTWARE. */ package de.ailis.wlandsuite.game.blocks; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.util.HashMap; import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.dom4j.Document; import org.dom4j.Element; import de.ailis.wlandsuite.common.exceptions.GameException; import de.ailis.wlandsuite.game.RotatingXorInputStream; import de.ailis.wlandsuite.game.RotatingXorOutputStream; import de.ailis.wlandsuite.game.parts.Action; import de.ailis.wlandsuite.game.parts.ActionClassMap; import de.ailis.wlandsuite.game.parts.ActionMap; import de.ailis.wlandsuite.game.parts.Actions; import de.ailis.wlandsuite.game.parts.BattleStrings; import de.ailis.wlandsuite.game.parts.CentralDirectory; import de.ailis.wlandsuite.game.parts.Info; import de.ailis.wlandsuite.game.parts.Monsters; import de.ailis.wlandsuite.game.parts.NPCs; import de.ailis.wlandsuite.game.parts.SpecialAction; import de.ailis.wlandsuite.game.parts.SpecialActionTable; import de.ailis.wlandsuite.game.parts.Strings; import de.ailis.wlandsuite.game.parts.TileMap; import de.ailis.wlandsuite.io.SeekableInputStream; import de.ailis.wlandsuite.io.SeekableOutputStream; import de.ailis.wlandsuite.utils.StringUtils; import de.ailis.wlandsuite.utils.XmlUtils; /** * A game map. * * @author Klaus Reimer (k@ailis.de) * @version $Revision$ */ public class GameMap extends GameBlock implements Serializable { /** Serial version UID */ private static final long serialVersionUID = 3535759235422069966L; /** The logger */ private static final Log log = LogFactory.getLog(GameMap.class); /** The map size */ private int mapSize; /** The MSQ block size */ private int msqSize; /** The offset of the tilemap */ private int tilemapOffset; /** The map info */ private Info info; /** The battle strings */ private BattleStrings battleStrings; /** The action map */ private ActionClassMap actionClassMap; /** The action map */ private ActionMap actionMap; /** The tiles map */ private TileMap tileMap; /** The strings */ private Strings strings; /** The NPCs */ private NPCs npcs; /** The monsters */ private Monsters monsters; /** The actions */ private final Map<Integer, Actions> actions; /** * Constructs a new map with the specified map size. The map size must be 64 * or 32. Maps are always quadratic. It's not possible to have different * widths and heights. * * The MSQ block size must be specified so the object knows how many padding * bytes must be inserted during save. MSQ block sizes are hardcoded in the * EXE so new Maps must fit these hardcoded boundaries to be loadable. * * The tilemap offset must also be specified because the position of the * tilemap is also hardcoded in the exe. So a new map must know where to * save the tilemap in the map file. * * @param mapSize * The map size * @param msqBlockSize * The MSQ block size * @param tilemapOffset * The offset of the tilemap */ public GameMap(final int mapSize, final int msqBlockSize, final int tilemapOffset) { if (mapSize != 32 && mapSize != 64) { throw new IllegalArgumentException( "Illegal map size specified: " + mapSize + ". Valid sizes are 32 and 64"); } this.mapSize = mapSize; this.msqSize = msqBlockSize; this.tilemapOffset = tilemapOffset; this.actionClassMap = new ActionClassMap(mapSize); this.actionMap = new ActionMap(mapSize); this.actions = new HashMap<Integer, Actions>(15); } /** * Constructs a map by reading it from a wasteland gameX file stream. The * stream must point at the beginning of the MSQ block (which is at the "m" * of the "msq" header string. * * Because it's not possible to read the length of a MSQ block from the MSQ * block itself the size of the block must be specified manually. * * @param stream * The input stream * @param msqBlockSize * The block size * @return The newly constructed Game Map * @throws IOException * When file operation fails. */ public static GameMap read(final SeekableInputStream stream, final int msqBlockSize) throws IOException { byte[] headerBytes; String header; RotatingXorInputStream xorStream; byte[] bytes; int mapSize; int encSize; int tilemapOffset; GameMap gameMap; long startOffset; // Read the MSQ block header and validate it headerBytes = new byte[4]; stream.read(headerBytes); header = new String(headerBytes, "ASCII"); if (!header.equals("msq0") && !header.equals("msq1")) { throw new IOException("No MSQ block header found at stream"); } // Get the starting offset startOffset = stream.tell(); // Read/Decrypt beginning of the MSQ block body bytes = new byte[6189]; xorStream = new RotatingXorInputStream(stream); xorStream.read(bytes); // Determine the map size and initialize the map with it mapSize = determineMapSize(bytes); // Determine the encryption size encSize = determineEncryptionSize(bytes, mapSize); // Read/Decrypt the whole block bytes = new byte[msqBlockSize - 6]; stream.seek(startOffset); xorStream = new RotatingXorInputStream(stream); xorStream.read(bytes, 0, encSize); stream.read(bytes, encSize, bytes.length - encSize); // Determine the tiles offset tilemapOffset = determineTilesOffset(bytes, mapSize); // Create the byte array stream and begin parsing the input final SeekableInputStream blockStream = new SeekableInputStream(new ByteArrayInputStream(bytes)); try { // Create the Game Map gameMap = new GameMap(mapSize, msqBlockSize, tilemapOffset); // Read the map data gameMap.readMapData(blockStream, tilemapOffset, mapSize, true); // Return the created map return gameMap; } finally { blockStream.close(); } } /** * Reads the map data from the given stream. This method is internally * called by the read and read and readHacked method. * * @param stream * The input stream * @param tilemapOffset * The offset of the tilemap * @param mapSize * The size of the map * @param compressedTileMap * Defines if the tilemap is compressed and therefor must be * decompressed first * @throws IOException * When file operation fails. */ private void readMapData(final SeekableInputStream stream, final int tilemapOffset, final int mapSize, final boolean compressedTileMap) throws IOException { CentralDirectory centralDirectory; SpecialActionTable specialActionTable; int monsterDataOffset; // Read the action map this.actionClassMap = ActionClassMap.read(stream, mapSize); // Read the action map this.actionMap = ActionMap.read(stream, mapSize); // Read the central directory centralDirectory = CentralDirectory.read(stream); // Read the map info stream.skip(1); this.info = Info.read(stream); // Read the battle strings this.battleStrings = BattleStrings.read(stream); // Read the tiles map stream.seek(tilemapOffset); this.tileMap = TileMap.read(stream, compressedTileMap ? 0 : mapSize); // Read the strings stream.seek(centralDirectory.getStringsOffset()); this.strings = Strings.read(stream, tilemapOffset); // Read the NPCs stream.seek(centralDirectory.getNpcOffset()); this.npcs = NPCs.read(stream); // Read the monsters if present monsterDataOffset = centralDirectory.getMonsterDataOffset(); if (monsterDataOffset != 0) { int quantity; quantity = (centralDirectory.getStringsOffset() - monsterDataOffset) / 8; stream.seek(centralDirectory.getMonsterNamesOffset()); this.monsters = Monsters.read(stream, monsterDataOffset, quantity); } else { this.monsters = new Monsters(); } // Sanitizes the central directory centralDirectory.sanitizeCentralDirectory(this); // Read the special action table stream.seek(centralDirectory.getNibble6Offset()); specialActionTable = SpecialActionTable.read(stream, 128); // Read the actions for (int i = 1; i < 16; i++) { final int offset = centralDirectory.getActionClassOffset(i); if (offset != 0) { stream.seek(offset); this.actions.put(i, Actions.read(i, stream, specialActionTable)); } else { this.actions.put(i, new Actions()); } } } /** * Reads a hacked map (For Displacer's hacked EXE) from the given input * stream. * * @param stream * The input stream * @return The map * @throws IOException * When file operation fails. */ public static GameMap readHacked(final InputStream stream) throws IOException { int tilemapOffset, mapSize; SeekableInputStream gameStream; GameMap map; gameStream = new SeekableInputStream(stream); tilemapOffset = gameStream.readWord(); mapSize = gameStream.read(); if (tilemapOffset == -1 || mapSize == -1) { throw new IOException("Unexpected end of stream while reading map"); } gameStream = new SeekableInputStream(stream); map = new GameMap(mapSize, 0, 0); map.readMapData(gameStream, tilemapOffset, mapSize, false); return map; } /** * Writes the map data to the specified output stream. This method is used * internally by the write and writeHacked methods. * * @param stream * The output stream * @param compressTilemap * If the tile map should be compressed * @return The central directory * @throws IOException * When file operation fails. */ private CentralDirectory writeMapData(final OutputStream stream, final boolean compressTilemap) throws IOException { SeekableOutputStream plainStream; CentralDirectory centralDirectory; int stringsOffset; long directoryOffset; SpecialActionTable specialActionTable; plainStream = new SeekableOutputStream(stream); // Write the action class map this.actionClassMap.write(plainStream); // Write the action map this.actionMap.write(plainStream); // Create the central directory and skip the space for it in the map centralDirectory = new CentralDirectory(); directoryOffset = plainStream.tell(); plainStream.skip(44); // Write the map size plainStream.writeByte(this.mapSize); // Write the map info this.info.write(plainStream); // Write the battle strings this.battleStrings.write(plainStream); // Build the special action table specialActionTable = buildSpecialActionTable(); // Write the actions for (int i = 1; i < 16; i++) { Actions actions; actions = this.actions.get(i); if (actions == null || actions.countActions() == 0) { continue; } centralDirectory.setActionClassOffset(i, (int) plainStream.tell()); actions.write(plainStream, specialActionTable); } // Write the special action table if (specialActionTable.size() > 0) { centralDirectory.setNibble6Offset((int) plainStream.tell()); specialActionTable.write(plainStream); } // Write the NPCs if (this.npcs.size() > 0) { centralDirectory.setNpcOffset((int) plainStream.tell()); this.npcs.write(plainStream); } // Write the monster names centralDirectory.setMonsterNamesOffset((int) plainStream.tell()); this.monsters.writeNames(plainStream); // Write the monster data centralDirectory.setMonsterDataOffset((int) plainStream.tell()); this.monsters.writeData(plainStream); // Write the strings stringsOffset = (int) plainStream.tell(); centralDirectory.setStringsOffset(stringsOffset); this.strings.write(plainStream); // Add padding if (compressTilemap) { if (plainStream.tell() > this.tilemapOffset) { log.warn("Too much data before tile map. Fixing " + "offsets in wl.exe is needed to run this game file"); } else { plainStream.skip(this.tilemapOffset - plainStream.tell()); } } // Write the tile map centralDirectory.setTilemapOffset((int) plainStream.tell()); this.tileMap.write(plainStream, compressTilemap); // Add padding if (compressTilemap) { if (plainStream.tell() > this.msqSize - 6) { log.warn("Tilemap too large. Fixing offsets in wl.exe is needed " + "to run this game file"); } else { plainStream.skip(this.msqSize - 6 - plainStream.tell()); } } // Write the central directory plainStream.seek(directoryOffset); centralDirectory.write(plainStream); // Flush the stream, it's complete now plainStream.flush(); return centralDirectory; } /** * Writes the map to the specified output stream. * * @param stream * The output stream * @param disk * The disk id (0 or 1) * @throws IOException * When file operation fails. */ public void write(final OutputStream stream, final int disk) throws IOException { ByteArrayOutputStream byteStream; RotatingXorOutputStream xorStream; byte[] bytes; CentralDirectory centralDirectory; int stringsOffset; byteStream = new ByteArrayOutputStream(); centralDirectory = writeMapData(byteStream, true); bytes = byteStream.toByteArray(); stringsOffset = centralDirectory.getStringsOffset(); // Write the MSQ header stream.write("msq".getBytes()); stream.write('0' + disk); // Write the encrypted data xorStream = new RotatingXorOutputStream(stream); xorStream.write(bytes, 0, stringsOffset); xorStream.flush(); // Write the unencrypted data stream.write(bytes, stringsOffset, bytes.length - stringsOffset); } /** * Writes an external map file compatible to Displacer's hacked EXE file. * * @param stream * The stream to write the map to * @throws IOException * When file operation fails. */ public void writeHacked(final OutputStream stream) throws IOException { ByteArrayOutputStream byteStream; byte[] bytes; CentralDirectory centralDirectory; int tilemapOffset; byteStream = new ByteArrayOutputStream(); centralDirectory = writeMapData(byteStream, false); tilemapOffset = centralDirectory.getTilemapOffset(); bytes = byteStream.toByteArray(); // Write the data stream.write(tilemapOffset & 255); stream.write(tilemapOffset >> 8); stream.write(this.mapSize); stream.write(bytes); } /** * Builds the special action table by looking at the actions in action class * 6. * * @return The special action table */ private SpecialActionTable buildSpecialActionTable() { Actions actions; SpecialActionTable specialActionTable; specialActionTable = new SpecialActionTable(); actions = this.actions.get(6); if (actions != null) { for (int i = 0, max = actions.countActions(); i < max; i++) { final Action specialAction = actions.getAction(i); if (specialAction instanceof SpecialAction) { final SpecialAction action = (SpecialAction) actions.getAction(i); if (action != null) { final int id = action.getAction(); if (!specialActionTable.contains(id)) { specialActionTable.add(id); } } } } } return specialActionTable; } /** * Creates and returns a new game map from XML. * * @param element * The XML root element * @return The Game Map */ public static GameMap read(final Element element) { GameMap gameMap; int mapSize; int msqSize; int tilemapOffset; // Read map configuration mapSize = StringUtils.toInt(element.attributeValue("mapSize")); msqSize = StringUtils.toInt(element.attributeValue("msqSize", "0")); tilemapOffset = StringUtils.toInt(element.attributeValue("tilemapOffset", "0")); // Create the new map gameMap = new GameMap(mapSize, msqSize, tilemapOffset); // Parse the action map gameMap.actionClassMap = ActionClassMap.read(element.element("actionClassMap"), mapSize); // Parse the action map gameMap.actionMap = ActionMap.read(element.element("actionMap"), mapSize); // Parse the map info gameMap.info = Info.read(element.element("info")); // Parse the battle strings gameMap.battleStrings = BattleStrings.read(element.element("battleStrings")); // Read the actions for (final Object item : element.elements("actions")) { final Element subElement = (Element) item; int actionClass; actionClass = StringUtils.toInt(subElement.attributeValue("actionClass")); gameMap.actions.put(actionClass, Actions.read(subElement)); } // Parse the tile map gameMap.tileMap = TileMap.read(element.element("tileMap"), mapSize, gameMap.info.getBackgroundTile()); // Parse the strings gameMap.strings = Strings.read(element.element("strings")); // Parse the monsters gameMap.monsters = Monsters.read(element.element("monsters")); // Parse the NPCs gameMap.npcs = NPCs.read(element.element("npcs")); return gameMap; } /** * Reads a game map from the specified XML stream. * * @param stream * The input stream * @return The game map */ public static GameMap readXml(final InputStream stream) { Document document; Element element; document = XmlUtils.readDocument(stream); element = document.getRootElement(); return read(element); } /** * @see de.ailis.wlandsuite.game.blocks.GameBlock#toXml() */ @Override public Element toXml() { Element element; // Create the root element element = XmlUtils.createElement("map"); element.addAttribute("mapSize", Integer.toString(this.mapSize)); if (this.msqSize != 0) { element.addAttribute("msqSize", Integer.toString(this.msqSize)); } if (this.tilemapOffset != 0) { element.addAttribute("tilemapOffset", Integer.toString(this.tilemapOffset)); } // Add the action map element.add(this.actionClassMap.toXml()); // Add the action map element.add(this.actionMap.toXml(this.actionClassMap)); // Add the map info element.add(this.info.toXml()); // Add the battle strings element.add(this.battleStrings.toXml()); // Add the actions for (int i = 1; i < 16; i++) { Actions actions; actions = this.actions.get(i); if (actions != null && actions.countActions() > 0) { element.add(actions.toXml(i)); } } // Add the NPCs element.add(this.npcs.toXml()); // Add the monsters element.add(this.monsters.toXml()); // Add the strings element.add(this.strings.toXml()); // Add the tiles map element.add(this.tileMap.toXml(this.info.getBackgroundTile())); // Return the XMl element return element; } /** * Returns the size of the encrypted part in the map block. To do this it * needs at least 6146 decrypted bytes from the map block. * * @param bytes * The (decrypted) block data * @param mapSize * The map size * @return The size of the encrypted part */ private static int determineEncryptionSize(final byte[] bytes, final int mapSize) { int offset; offset = mapSize * mapSize * 3 / 2; return ((bytes[offset] & 0xff) | ((bytes[offset + 1] & 0xff) << 8)); } /** * Determines the map size by just looking at the MSQ block bytes. For this * it needs at least 6189 unencrypted bytes. Throws a GameException if it * was not able to determine the map size. * * @param bytes * The MSQ block bytes * @return The map size. */ private static int determineMapSize(final byte[] bytes) { int offset; boolean is32, is64; // Check if map can be size 64 is64 = false; offset = 64 * 64 * 3 / 2; if ((offset + 44 < bytes.length) && (bytes[offset + 44] == 64 && bytes[offset + 6] == 0 && bytes[offset + 7] == 0)) { is64 = true; } // Check if map can be size 3 is32 = false; offset = 32 * 32 * 3 / 2; if ((offset + 44 < bytes.length && bytes[offset + 6] == 0 && bytes[offset + 7] == 0) && (bytes[offset + 44] == 32)) { is32 = true; } // Complain if map can be both sizes if (!is32 && !is64) { throw new GameException("Cannot determine map size: Map is not a 32 or 64 size map"); } if (is32 && is64) { throw new GameException("Cannot determine map size: Map could be a 32 or 64 size map"); } return is32 ? 32 : 64; } /** * Determines the tiles offset by just looking at the MSQ block bytes. * * @param bytes * The MSQ block bytes * @param mapSize * The map size * @return The tiles offset */ private static int determineTilesOffset(final byte[] bytes, final int mapSize) { int i = bytes.length - 9; while (i > 0) { if ((bytes[i] == 0) && (bytes[i + 1] == ((mapSize * mapSize) >> 8)) && (bytes[i + 2] == 0) && (bytes[i + 3] == 0) && (bytes[i + 6] == 0) && (bytes[i + 7] == 0)) { return i; } i--; } throw new GameException("Unable to find tiles offset for size " + mapSize + " map"); } /** * Returns the map size. This is normally 64 or 32. Maps are always * quadratic. It's not possible to have different widths and heights. * * @return The map size */ public int getMapSize() { return this.mapSize; } /** * Returns the MSQ block size. * * @return The MSQ block size */ public int getMsqSize() { return this.msqSize; } /** * Returns the action class map. * * @return The action class map */ public ActionClassMap getActionClassMap() { return this.actionClassMap; } /** * Sets the action class map. * * @param actionClassMap * The action class map to set */ public void setActionClassMap(final ActionClassMap actionClassMap) { this.actionClassMap = actionClassMap; } /** * Returns the actionMap. * * @return The actionMap */ public ActionMap getActionMap() { return this.actionMap; } /** * Sets the actionMap. * * @param actionMap * The actionMap to set */ public void setActionMap(final ActionMap actionMap) { this.actionMap = actionMap; } /** * Returns the battleStrings. * * @return The battleStrings */ public BattleStrings getBattleStrings() { return this.battleStrings; } /** * Sets the battleStrings. * * @param battleStrings * The battleStrings to set */ public void setBattleStrings(final BattleStrings battleStrings) { this.battleStrings = battleStrings; } /** * Returns the info. * * @return The info */ public Info getInfo() { return this.info; } /** * Sets the info. * * @param info * The info to set */ public void setInfo(final Info info) { this.info = info; } /** * Returns the monsters. * * @return The monsters */ public Monsters getMonsters() { return this.monsters; } /** * Sets the monsters. * * @param monsters * The monsters to set */ public void setMonsters(final Monsters monsters) { this.monsters = monsters; } /** * Returns the npcs. * * @return The npcs */ public NPCs getNpcs() { return this.npcs; } /** * Sets the npcs. * * @param npcs * The npcs to set */ public void setNpcs(final NPCs npcs) { this.npcs = npcs; } /** * Returns the strings. * * @return The strings */ public Strings getStrings() { return this.strings; } /** * Sets the strings. * * @param strings * The strings to set */ public void setStrings(final Strings strings) { this.strings = strings; } /** * Returns the tileMap. * * @return The tileMap */ public TileMap getTileMap() { return this.tileMap; } /** * Sets the tileMap. * * @param tileMap * The tileMap to set */ public void setTileMap(final TileMap tileMap) { this.tileMap = tileMap; } /** * Returns the tilemapOffset. * * @return The tilemapOffset */ public int getTilemapOffset() { return this.tilemapOffset; } /** * Sets the tilemapOffset. * * @param tilemapOffset * The tilemapOffset to set */ public void setTilemapOffset(final int tilemapOffset) { this.tilemapOffset = tilemapOffset; } /** * Sets the mapSize. * * @param mapSize * The mapSize to set */ public void setMapSize(final int mapSize) { this.mapSize = mapSize; } /** * Sets the msqSize. * * @param msqSize * The msqSize to set */ public void setMsqSize(final int msqSize) { this.msqSize = msqSize; } }