diff --git a/cache-550/src/main/kotlin/org/openrs2/cache/sprite/Sprite.kt b/cache-550/src/main/kotlin/org/openrs2/cache/sprite/Sprite.kt
new file mode 100644
index 00000000..3dc0b554
--- /dev/null
+++ b/cache-550/src/main/kotlin/org/openrs2/cache/sprite/Sprite.kt
@@ -0,0 +1,427 @@
+package org.openrs2.cache.sprite
+
+import io.netty.buffer.ByteBuf
+import it.unimi.dsi.fastutil.ints.IntAVLTreeSet
+import java.awt.image.BufferedImage
+
+public class Sprite private constructor(
+ public val width: Int,
+ public val height: Int,
+ private val palette: IntArray,
+ private val frames: Array
+) {
+ private class Frame(
+ val xOffset: Int,
+ val yOffset: Int,
+ val innerWidth: Int,
+ val innerHeight: Int,
+ ) {
+ val pixels = ByteArray(innerWidth * innerHeight)
+ var alpha: ByteArray? = null
+
+ fun toImage(width: Int, height: Int, palette: IntArray): BufferedImage {
+ val rgbPixels = IntArray(width * height)
+
+ var index = 0
+ for (y in 0 until innerHeight) {
+ for (x in 0 until innerWidth) {
+ val paletteIndex = pixels[index].toInt() and 0xFF
+
+ var color = if (paletteIndex == 0) {
+ 0
+ } else {
+ palette[paletteIndex - 1]
+ }
+
+ val alpha = alpha
+ if (alpha != null) {
+ color = color or ((alpha[index].toInt() and 0xFF) shl 24)
+ } else if (paletteIndex != 0) {
+ color = color or 0xFF000000.toInt()
+ }
+
+ rgbPixels[(y + yOffset) * width + x + xOffset] = color
+ index++
+ }
+ }
+
+ val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
+ image.setRGB(0, 0, width, height, rgbPixels, 0, width)
+ return image
+ }
+
+ fun write(buf: ByteBuf) {
+ var flags = 0
+
+ val columnMajor = isColumnMajorBest()
+ if (columnMajor) {
+ flags = flags or FLAG_COLUMN_MAJOR
+ }
+
+ val alpha = alpha
+ if (alpha != null) {
+ flags = flags or FLAG_ALPHA
+ }
+
+ buf.writeByte(flags)
+
+ if (columnMajor) {
+ for (x in 0 until innerWidth) {
+ for (y in 0 until innerHeight) {
+ buf.writeByte(pixels[y * innerWidth + x].toInt())
+ }
+ }
+
+ if (alpha != null) {
+ for (x in 0 until innerWidth) {
+ for (y in 0 until innerHeight) {
+ buf.writeByte(alpha[y * innerWidth + x].toInt())
+ }
+ }
+ }
+ } else {
+ buf.writeBytes(pixels)
+
+ if (alpha != null) {
+ buf.writeBytes(alpha)
+ }
+ }
+ }
+
+ private fun isColumnMajorBest(): Boolean {
+ var rowMajorScore = 0
+ var columnMajorScore = 0
+
+ // calculate row-major score
+ var prev = 0
+ for (pixel in pixels) {
+ val current = pixel.toInt() and 0xFF
+ rowMajorScore += current - prev
+ prev = current
+ }
+
+ val alpha = alpha
+ if (alpha != null) {
+ for (a in alpha) {
+ val current = a.toInt() and 0xFF
+ rowMajorScore += current - prev
+ prev = current
+ }
+ }
+
+ // calculate column-major score
+ prev = 0
+ for (x in 0 until innerWidth) {
+ for (y in 0 until innerHeight) {
+ val current = pixels[y * innerWidth + x].toInt() and 0xFF
+ columnMajorScore += current - prev
+ prev = current
+ }
+ }
+
+ if (alpha != null) {
+ for (x in 0 until innerWidth) {
+ for (y in 0 until innerHeight) {
+ val current = alpha[y * innerWidth + x].toInt() and 0xFF
+ columnMajorScore += current - prev
+ prev = current
+ }
+ }
+ }
+
+ // if equal pick row-major, as it's faster to encode/decode
+ return columnMajorScore < rowMajorScore
+ }
+
+ companion object {
+ fun read(buf: ByteBuf, xOffset: Int, yOffset: Int, innerWidth: Int, innerHeight: Int): Frame {
+ val frame = Frame(xOffset, yOffset, innerWidth, innerHeight)
+
+ val flags = buf.readUnsignedByte().toInt()
+
+ val alpha = if ((flags and FLAG_ALPHA) != 0) {
+ ByteArray(frame.pixels.size)
+ } else {
+ null
+ }
+
+ if ((flags and FLAG_COLUMN_MAJOR) != 0) {
+ for (x in 0 until frame.innerWidth) {
+ for (y in 0 until frame.innerHeight) {
+ frame.pixels[y * frame.innerWidth + x] = buf.readByte()
+ }
+ }
+
+ if (alpha != null) {
+ for (x in 0 until frame.innerWidth) {
+ for (y in 0 until frame.innerHeight) {
+ alpha[y * frame.innerWidth + x] = buf.readByte()
+ }
+ }
+ }
+ } else {
+ buf.readBytes(frame.pixels)
+
+ if (alpha != null) {
+ buf.readBytes(alpha)
+ }
+ }
+
+ frame.alpha = alpha
+ return frame
+ }
+ }
+ }
+
+ public fun write(buf: ByteBuf) {
+ for (frame in frames) {
+ frame.write(buf)
+ }
+
+ for (color in palette) {
+ buf.writeMedium(color)
+ }
+
+ buf.writeShort(width)
+ buf.writeShort(height)
+ buf.writeByte(palette.size)
+
+ for (frame in frames) {
+ buf.writeShort(frame.xOffset)
+ }
+
+ for (frame in frames) {
+ buf.writeShort(frame.yOffset)
+ }
+
+ for (frame in frames) {
+ buf.writeShort(frame.innerWidth)
+ }
+
+ for (frame in frames) {
+ buf.writeShort(frame.innerHeight)
+ }
+
+ buf.writeShort(frames.size)
+ }
+
+ public fun toImages(): List {
+ return frames.map { frame ->
+ frame.toImage(width, height, palette)
+ }
+ }
+
+ public companion object {
+ private const val FLAG_COLUMN_MAJOR = 0x1
+ private const val FLAG_ALPHA = 0x2
+
+ public fun read(buf: ByteBuf): Sprite {
+ val framesSize = buf.getUnsignedShort(buf.writerIndex() - 2)
+ var trailerLen = framesSize * 8 + 7
+
+ buf.markReaderIndex()
+ buf.readerIndex(buf.writerIndex() - trailerLen)
+
+ val width = buf.readUnsignedShort()
+ val height = buf.readUnsignedShort()
+ val paletteSize = buf.readUnsignedByte().toInt()
+ trailerLen += paletteSize * 3
+
+ val xOffsets = IntArray(framesSize) { buf.readUnsignedShort() }
+ val yOffsets = IntArray(framesSize) { buf.readUnsignedShort() }
+ val innerWidths = IntArray(framesSize) { buf.readUnsignedShort() }
+ val innerHeights = IntArray(framesSize) { buf.readUnsignedShort() }
+
+ buf.readerIndex(buf.writerIndex() - trailerLen)
+
+ val palette = IntArray(paletteSize) { buf.readUnsignedMedium() }
+
+ buf.resetReaderIndex()
+
+ val frames = Array(framesSize) { i ->
+ Frame.read(buf, xOffsets[i], yOffsets[i], innerWidths[i], innerHeights[i])
+ }
+
+ buf.skipBytes(trailerLen)
+
+ return Sprite(width, height, palette, frames)
+ }
+
+ public fun fromImage(image: BufferedImage): Sprite {
+ return fromImages(listOf(image))
+ }
+
+ public fun fromImages(images: List): Sprite {
+ require(images.isNotEmpty())
+
+ val first = images.first()
+ val width = first.width
+ val height = first.height
+
+ val alphaChannels = BooleanArray(images.size)
+ for ((i, image) in images.withIndex()) {
+ require(image.width == width && image.height == height)
+ alphaChannels[i] = image.hasAlphaChannel()
+ }
+
+ val colors = IntAVLTreeSet()
+ for ((i, image) in images.withIndex()) {
+ val alphaChannel = alphaChannels[i]
+
+ for (y in 0 until image.height) {
+ for (x in 0 until image.width) {
+ val rgb = image.getRGB(x, y)
+
+ // transparent colours only preserved if FLAG_ALPHA set
+ val alpha = (rgb shr 24) and 0xFF
+ if (alphaChannel || alpha != 0) {
+ val color = rgb and 0xFFFFFF
+ colors += color
+ }
+ }
+ }
+ }
+
+ require(colors.size <= 255)
+
+ val palette = colors.toIntArray()
+
+ val frames = Array(images.size) { i ->
+ val image = images[i]
+ val alphaChannel = alphaChannels[i]
+
+ var xOffset = 0
+ var innerWidth = width
+
+ for (x in 0 until width) {
+ if (!image.isColumnTransparent(x, alphaChannel)) {
+ break
+ }
+
+ xOffset++
+ innerWidth--
+ }
+
+ for (x in width - 1 downTo xOffset) {
+ if (!image.isColumnTransparent(x, alphaChannel)) {
+ break
+ }
+
+ innerWidth--
+ }
+
+ var yOffset = 0
+ var innerHeight = height
+
+ for (y in 0 until height) {
+ if (!image.isRowTransparent(y, alphaChannel)) {
+ break
+ }
+
+ yOffset++
+ innerHeight--
+ }
+
+ for (y in height - 1 downTo yOffset) {
+ if (!image.isRowTransparent(y, alphaChannel)) {
+ break
+ }
+
+ innerHeight--
+ }
+
+ val frame = Frame(xOffset, yOffset, innerWidth, innerHeight)
+ if (alphaChannel) {
+ frame.alpha = ByteArray(innerWidth * innerHeight)
+ }
+
+ val alpha = frame.alpha
+ var index = 0
+ for (y in 0 until innerHeight) {
+ for (x in 0 until innerWidth) {
+ val rgb = image.getRGB(x + xOffset, y + yOffset)
+
+ val a = (rgb shr 24) and 0xFF
+ val color = rgb and 0xFFFFFF
+
+ val paletteIndex = if (alpha == null && a == 0) {
+ 0
+ } else {
+ val paletteIndex = palette.binarySearch(color)
+ check(paletteIndex >= 0)
+ paletteIndex + 1
+ }
+
+ frame.pixels[index] = paletteIndex.toByte()
+
+ if (alpha != null) {
+ alpha[index] = a.toByte()
+ }
+
+ index++
+ }
+ }
+
+ frame
+ }
+
+ return Sprite(width, height, palette, frames)
+ }
+
+ private fun BufferedImage.hasAlphaChannel(): Boolean {
+ for (y in 0 until height) {
+ for (x in 0 until width) {
+ val rgb = getRGB(x, y)
+
+ val alpha = (rgb shr 24) and 0xFF
+ val color = rgb and 0xFFFFFF
+
+ // preserve transparent colours
+ if (alpha == 0 && color != 0) {
+ return true
+ } else if (alpha != 0 && alpha != 0xFF) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ private fun BufferedImage.isColumnTransparent(x: Int, alphaChannel: Boolean): Boolean {
+ for (y in 0 until height) {
+ val rgb = getRGB(x, y)
+
+ val alpha = (rgb shr 24) and 0xFF
+ val color = rgb and 0xFFFFFF
+
+ // transparent colours only preserved if FLAG_ALPHA set
+ if (alphaChannel && color != 0) {
+ return false
+ } else if (alpha != 0) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ private fun BufferedImage.isRowTransparent(y: Int, alphaChannel: Boolean): Boolean {
+ for (x in 0 until width) {
+ val rgb = getRGB(x, y)
+
+ val alpha = (rgb shr 24) and 0xFF
+ val color = rgb and 0xFFFFFF
+
+ // transparent colours only preserved if FLAG_ALPHA set
+ if (alphaChannel && color != 0) {
+ return false
+ } else if (alpha != 0) {
+ return false
+ }
+ }
+
+ return true
+ }
+ }
+}