package org.openrs2.cache import io.netty.buffer.ByteBuf import io.netty.buffer.ByteBufAllocator import io.netty.buffer.ByteBufInputStream import io.netty.buffer.ByteBufOutputStream import it.unimi.dsi.fastutil.ints.Int2ObjectLinkedOpenHashMap import org.openrs2.buffer.use import org.openrs2.compress.bzip2.Bzip2 import org.openrs2.util.jagHashCode import java.io.Closeable import java.io.FileNotFoundException /** * An interface for reading and writing `.jag` archives, which are used by * RuneScape Classic and early versions of RuneScape 2. * * Unlike the client, this implementation is case-sensitive. Entry names should * therefore be supplied in uppercase for compatibility. * * This class is not thread safe. */ public class JagArchive : Closeable { private val entries = Int2ObjectLinkedOpenHashMap() /** * The number of entries in the archive. */ public val size: Int get() = entries.size /** * Lists all entries in the archive. * @return a sorted list of entry name hashes. */ public fun list(): Iterator { return entries.keys.iterator() } /** * Checks whether an entry exists. * @param name the entry's name. * @return `true` if so, `false` otherwise. */ public fun exists(name: String): Boolean { return existsNamed(name.jagHashCode()) } /** * Checks whether an entry exists. * @param nameHash the entry's name hash. * @return `true` if so, `false` otherwise. */ public fun existsNamed(nameHash: Int): Boolean { return entries.containsKey(nameHash) } /** * Reads an entry. * * This method allocates and returns a new [ByteBuf]. It is the caller's * responsibility to release the [ByteBuf]. * @param name the entry's name. * @return the contents of the entry. * @throws FileNotFoundException if the entry does not exist. */ public fun read(name: String): ByteBuf { return readNamed(name.jagHashCode()) } /** * Reads an entry. * * This method allocates and returns a new [ByteBuf]. It is the caller's * responsibility to release the [ByteBuf]. * @param nameHash the entry's name hash. * @return the contents of the entry. * @throws FileNotFoundException if the entry does not exist. */ public fun readNamed(nameHash: Int): ByteBuf { val buf = entries[nameHash] ?: throw FileNotFoundException() return buf.retainedSlice() } /** * Writes an entry. If an entry with the same name hash already exists, it * is overwritten. * @param name the entry's name. * @param buf the new contents of the entry. */ public fun write(name: String, buf: ByteBuf) { writeNamed(name.jagHashCode(), buf) } /** * Writes an entry. If an entry with the same name hash already exists, it * is overwritten. * @param nameHash the entry's name hash. * @param buf the new contents of the entry. */ public fun writeNamed(nameHash: Int, buf: ByteBuf) { entries.put(nameHash, buf.copy().asReadOnly())?.release() } /** * Deletes an entry. Does nothing if the entry does not exist. * @param name the entry's name. */ public fun remove(name: String) { removeNamed(name.jagHashCode()) } /** * Deletes an entry. Does nothing if the entry does not exist. * @param nameHash the entry's name hash. */ public fun removeNamed(nameHash: Int) { entries.remove(nameHash)?.release() } /** * Packs a `.jag` archive into a compressed [ByteBuf] using the given * compression method. * * This method allocates and returns a new [ByteBuf]. It is the caller's * responsibility to release the [ByteBuf]. * @param compressedArchive `true` if the archive should be compressed as a * whole, `false` if each entry should be compressed individually. * @param alloc the allocator. * @return the compressed archive. */ public fun pack(compressedArchive: Boolean, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf { alloc.buffer().use { output -> alloc.buffer().use { uncompressedArchiveBuf -> uncompressedArchiveBuf.writeShort(size) var index = uncompressedArchiveBuf.writerIndex() + size * 10 for ((nameHash, uncompressedEntryBuf) in entries) { uncompressedArchiveBuf.writeInt(nameHash) uncompressedArchiveBuf.writeMedium(uncompressedEntryBuf.readableBytes()) compress(uncompressedEntryBuf.slice(), !compressedArchive).use { entryBuf -> val entryLen = entryBuf.readableBytes() uncompressedArchiveBuf.writeMedium(entryLen) uncompressedArchiveBuf.setBytes(index, entryBuf) index += entryLen } } uncompressedArchiveBuf.writerIndex(index) val uncompressedLen = uncompressedArchiveBuf.readableBytes() output.writeMedium(uncompressedLen) compress(uncompressedArchiveBuf, compressedArchive).use { archiveBuf -> val len = archiveBuf.readableBytes() if (compressedArchive && uncompressedLen == len) { /* * This is a bit of an odd corner case. If whole * archive compression is enabled and the lengths are * equal we have to use individual entry compression * instead, as the lengths being equal signals to the * client that this mode should be used. * * If anyone finds a suitable test case for this case, * I'd love to see it! */ return pack(false, alloc) } output.writeMedium(len) output.writeBytes(archiveBuf) } return output.retain() } } } /** * Packs a `.jag` archive into a compressed [ByteBuf]. The best compression * method for minimising the size of the compressed archive is * automatically selected. Note that this does not necessarily correspond * to the minimimal amount of RAM usage at runtime. * * This method allocates and returns a new [ByteBuf]. It is the caller's * responsibility to release the [ByteBuf]. * @param alloc the allocator. * @return the compressed archive. */ public fun packBest(alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf { pack(true, alloc).use { compressedArchive -> pack(false, alloc).use { compressedEntries -> // If equal, pick the archive that only requires one // decompression instead of several to save CPU time. return if (compressedArchive.readableBytes() <= compressedEntries.readableBytes()) { compressedArchive.retain() } else { compressedEntries.retain() } } } } override fun close() { entries.values.forEach(ByteBuf::release) } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as JagArchive if (entries != other.entries) return false return true } override fun hashCode(): Int { return entries.hashCode() } public companion object { /** * Unpacks a [ByteBuf] containing a compressed `.jag` archive. * @param buf the compressed archive. * @return the unpacked archive. */ public fun unpack(buf: ByteBuf): JagArchive { val archive = JagArchive() val uncompressedLen = buf.readUnsignedMedium() val len = buf.readUnsignedMedium() val compressedArchive = len != uncompressedLen uncompress(buf, compressedArchive, len, uncompressedLen).use { archiveBuf -> val size = archiveBuf.readUnsignedShort() var index = archiveBuf.readerIndex() + size * 10 for (id in 0 until size) { val nameHash = archiveBuf.readInt() val uncompressedEntryLen = archiveBuf.readUnsignedMedium() val entryLen = archiveBuf.readUnsignedMedium() val entry = archiveBuf.slice(index, entryLen) uncompress(entry, !compressedArchive, entryLen, uncompressedEntryLen).use { entryBuf -> if (!archive.existsNamed(nameHash)) { /* * Store the first entry if there is a collision, * for compatibility with the client. */ archive.writeNamed(nameHash, entryBuf) } } index += entryLen } } return archive } private fun compress(input: ByteBuf, compressed: Boolean): ByteBuf { return if (compressed) { input.alloc().buffer().use { output -> Bzip2.createHeaderlessOutputStream(ByteBufOutputStream(output)).use { stream -> input.readBytes(stream, input.readableBytes()) } output.retain() } } else { input.readRetainedSlice(input.readableBytes()) } } private fun uncompress(buf: ByteBuf, compressed: Boolean, compressedLen: Int, uncompressedLen: Int): ByteBuf { return if (compressed) { buf.alloc().buffer(uncompressedLen, uncompressedLen).use { output -> Bzip2.createHeaderlessInputStream(ByteBufInputStream(buf.readSlice(compressedLen))).use { stream -> output.writeBytes(stream, uncompressedLen) } output.retain() } } else { buf.readRetainedSlice(compressedLen) } } } }