/* ** 2013 April 20 ** ** The author disclaims copyright to this source code. In place of ** a legal notice, here is a blessing: ** May you do good and not evil. ** May you find forgiveness for yourself and forgive others. ** May you share freely, never taking more than you give. */ package info.ata4.vpk; import info.ata4.io.DataInputReader; import info.ata4.util.io.ByteBufferInput; import info.ata4.util.io.NIOFileUtils; import java.io.DataInput; import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.apache.commons.io.FilenameUtils; /** * VPK archive class. * * @author Nico Bergemann <barracuda415 at yahoo.de> */ public class VPKArchive { public static final int SIGNATURE = 0x55aa1234; public static final int VERS_MIN = 1; public static final int VERS_MAX = 2; private List<VPKEntry> entries = new ArrayList<>(); private Map<String, List<VPKEntry>> typeEntries = new HashMap<>(); private Map<String, List<VPKEntry>> dirEntries = new HashMap<>(); private Map<String, VPKEntry> pathEntries = new HashMap<>(); private int version = 1; private boolean multiChunk; /** * Loads all entries from a VPK archive file. * * @param file VPK archive file. For multichunk archives, this must be the * "_dir" index file. * @throws IOException when the archive can't be read correctly * @throws VPKException when a VPK file format error occured */ public void load(File file) throws VPKException, IOException { File baseDir = file.getParentFile(); String vpkName = FilenameUtils.getBaseName(file.getName()); // it must be a multichunk VPK if it ends with _dir multiChunk = vpkName.endsWith("_dir"); // strip "_dir" if (multiChunk) { vpkName = vpkName.substring(0, vpkName.length() - 4); } ByteBuffer bb = NIOFileUtils.openReadOnly(file); bb.order(ByteOrder.LITTLE_ENDIAN); DataInput di1 = new ByteBufferInput(bb); DataInputReader di2 = new DataInputReader(di1); int sig = di1.readInt(); if (sig != SIGNATURE) { throw new VPKException(String.format("Unknown signature: 0x%06x (expected: 0x%06x)", sig, SIGNATURE)); } version = di1.readInt(); int headerSize; switch (version) { case 1: headerSize = 12; break; case 2: headerSize = 28; // TODO: unknown fields int v1 = di1.readInt(); // footer offset int v2 = di1.readInt(); // always 0? int v3 = di1.readInt(); // footer size? int v4 = di1.readInt(); // always 48? // System.out.printf("%d %d %d %d\n", v1, v2, v3, v4); break; default: throw new VPKException("Unsupported version: " + version); } // dictionary size in v1 (something else in v2?) int dictSize = di1.readInt(); for (String type; !(type = di2.readStringNull(1024)).isEmpty();) { if (!typeEntries.containsKey(type)) { typeEntries.put(type, new ArrayList<VPKEntry>()); } for (String dir; !(dir = di2.readStringNull(1024)).isEmpty();) { // separator should always be "/" dir = dir.replace('\\', '/'); // fix root dir if (dir.equals(" ")) { dir = ""; } // add missing slash unless it's the root dir if (!dir.isEmpty() && !dir.endsWith("/")) { dir = dir + "/"; } if (!dirEntries.containsKey(dir)) { dirEntries.put(dir, new ArrayList<VPKEntry>()); } for (String name; !(name = di2.readStringNull(1024)).isEmpty();) { long crc32 = di2.readUnsignedInt(); byte[] preload = new byte[di1.readUnsignedShort()]; int chunkIndex = di1.readUnsignedShort(); int offset = di1.readInt(); int size = di1.readInt(); int term = di1.readUnsignedShort(); if (term != 0xffff) { throw new VPKException("Unexpected terminator: " + term); } if (preload.length > 0) { di1.readFully(preload); } File entryFile; if (multiChunk) { String entryName = String.format("%s_%03d.vpk", vpkName, chunkIndex); entryFile = new File(baseDir, entryName); } else { entryFile = file; if (version == 1) { // offset is relative to the header/dictionary, fix it offset += headerSize + dictSize; } // TODO: how to fix offsets for v2? } VPKEntry entry = new VPKEntry(entryFile, true); entry.setType(type); entry.setName(name); entry.setDir(dir); entry.setCRC32(crc32); entry.setOffset(offset); entry.setSize(size); entry.setPreloadData(preload); entries.add(entry); typeEntries.get(type).add(entry); dirEntries.get(dir).add(entry); pathEntries.put(entry.getPath(), entry); } } } // check the current position if (version == 1) { long dictSizeActual = bb.position() - headerSize; if (dictSize != 0 && dictSizeActual != dictSize) { throw new VPKException(String.format("Incorrect dictionary size %d (expected %d)", dictSizeActual, dictSize)); } } } /** * Returns a list of all VPK entries. * * @return VPK entry list */ public List<VPKEntry> getEntries() { return Collections.unmodifiableList(entries); } /** * Returns a list of all VPK entries for the given directory. If the directory * isn't used, {@code null} will be returned. * * @param dir directory path * @return VPK entry list inside the given directory */ public List<VPKEntry> getEntriesForDir(String dir) { List<VPKEntry> result = dirEntries.get(dir); return result == null ? result : Collections.unmodifiableList(result); } /** * Returns a list of all VPK entries for the given file type/extension. If * the type isn't used, {@code null} will be returned. * * @param type file type * @return VPK entry list of the given type */ public List<VPKEntry> getEntriesForType(String type) { List<VPKEntry> result = typeEntries.get(type); return result == null ? result : Collections.unmodifiableList(result); } /** * Returns the VPK entry for the given path. If no entry with the path exists, * {@code null} will be returned. * * @param path full file path * @return VPK entry for this path */ public VPKEntry getEntry(String path) { return pathEntries.get(path); } /** * Returns the version number of this VPK archive. * * @return VPK version */ public int getVersion() { return version; } /** * Sets a new version number for this VPK archive. * * @param version new version number * @throws IllegalArgumentException if the version number is outside the * allowed range */ public void setVersion(int version) { if (version > VERS_MAX || version < VERS_MIN) { throw new IllegalArgumentException("Unsupported version: " + version); } this.version = version; } /** * Returns true if this archive is split up into multiple chunk files. * * @return true if this is a multi-chunk archive */ public boolean isMultiChunk() { return multiChunk; } /** * Sets if this archive should split up into multiple chunk files. * * @param multiChunk multi-chunk flag */ public void setMultiChunk(boolean multiChunk) { this.multiChunk = multiChunk; } /** * Clears all loaded entries from this archive instance. This won't effect any data. */ public void clear() { entries.clear(); dirEntries.clear(); typeEntries.clear(); pathEntries.clear(); } }