Open-source multiplayer game server compatible with the RuneScape client
https://www.openrs2.org/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
10 KiB
298 lines
10 KiB
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<ByteBuf>()
|
|
|
|
/**
|
|
* 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<Int> {
|
|
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.
|
|
*/
|
|
@JvmOverloads
|
|
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.
|
|
*/
|
|
@JvmOverloads
|
|
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.
|
|
*/
|
|
@JvmStatic
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|