package ru.dbotthepony.kstarbound.defs.image import com.github.benmanes.caffeine.cache.AsyncLoadingCache import com.github.benmanes.caffeine.cache.CacheLoader import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.Scheduler import com.google.common.collect.ImmutableList import com.google.gson.JsonArray import com.google.gson.JsonNull import com.google.gson.JsonObject import com.google.gson.JsonSyntaxException import com.google.gson.TypeAdapter import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonWriter import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap import org.apache.logging.log4j.LogManager import org.lwjgl.opengl.GL45 import org.lwjgl.stb.STBIEOFCallback import org.lwjgl.stb.STBIIOCallbacks import org.lwjgl.stb.STBIReadCallback import org.lwjgl.stb.STBIReadCallbackI import org.lwjgl.stb.STBISkipCallback import org.lwjgl.stb.STBImage import org.lwjgl.system.MemoryUtil import ru.dbotthepony.kommons.vector.Vector2i import ru.dbotthepony.kommons.vector.Vector4i import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNIT import ru.dbotthepony.kstarbound.world.PIXELS_IN_STARBOUND_UNITi import ru.dbotthepony.kstarbound.Starbound import ru.dbotthepony.kstarbound.IStarboundFile import ru.dbotthepony.kstarbound.client.StarboundClient import ru.dbotthepony.kstarbound.client.gl.GLTexture2D import ru.dbotthepony.kommons.gson.consumeNull import ru.dbotthepony.kommons.gson.contains import ru.dbotthepony.kommons.gson.get import ru.dbotthepony.kommons.gson.getObject import java.io.BufferedInputStream import java.io.FileNotFoundException import java.lang.ref.Reference import java.lang.ref.WeakReference import java.nio.ByteBuffer import java.time.Duration import java.util.Collections import java.util.Optional import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.locks.ReentrantLock class Image private constructor( val source: IStarboundFile, val path: String, val width: Int, val height: Int, val amountOfChannels: Int, spritesData: Pair, IStarboundFile>? ) { init { check(width >= 0) { "Invalid width $width" } check(height >= 0) { "Invalid height $height" } check(amountOfChannels in 1 .. 4) { "Unknown number of channels $amountOfChannels" } } private val spritesInternal = LinkedHashMap() private var dataRef: WeakReference? = null private val lock = Any() //private val _texture = ThreadLocal>() init { if (spritesData == null) { this.spritesInternal["default"] = Sprite("default", 0, 0, width, height) } else { val (sprites, origin) = spritesData for (data in sprites) { var sX = data.coordinates.x % width var sY = data.coordinates.y % height if (sX !in 0 .. width) { //LOGGER.warn("Sprite X offset ${data.name} is out of bounds: $sX, clamping to 0 .. $width. (image: $source; frames: $origin)") sX = sX.coerceIn(0, width) } if (sY !in 0 .. height) { //LOGGER.warn("Sprite Y offset ${data.name} is out of bounds: $sY, clamping to 0 .. $height. (image: $source; frames: $origin)") sY = sY.coerceIn(0, height) } var sWidth = data.coordinates.z - sX var sHeight = data.coordinates.w - sY if (sWidth !in 0 .. width) { //LOGGER.warn("Sprite width ${data.name} is out of bounds: $sWidth, clamping to 0 .. $width. (image: $source; frames: $origin)") sWidth = sWidth.coerceIn(0, width) } if (sHeight !in 0 .. height) { //LOGGER.warn("Sprite height ${data.name} is out of bounds: $sHeight, clamping to 0 .. $height. (image: $source; frames: $origin)") sHeight = sHeight.coerceIn(0, height) } this.spritesInternal[data.name] = Sprite(data.name, sX, sY, sWidth, sHeight) } } } val data: CompletableFuture get() { var get = dataRef?.get() if (get != null) return CompletableFuture.completedFuture(get) synchronized(lock) { get = dataRef?.get() if (get != null) return CompletableFuture.completedFuture(get) val f = dataCache.get(source) if (f.isDone) dataRef = WeakReference(f.get()) return f.copy() } } val texture: GLTexture2D get() { //val get = _texture.get()?.get() val client = StarboundClient.current() /*if (get != null) { // update access time client.named2DTextures0.getIfPresent(this) client.named2DTextures1.getIfPresent(this) return get }*/ val value = client.named2DTextures0.get(this) { client.named2DTextures1.get(this) { val (memFormat, fileFormat) = when (amountOfChannels) { 1 -> GL45.GL_R8 to GL45.GL_RED 3 -> GL45.GL_RGB8 to GL45.GL_RGB 4 -> GL45.GL_RGBA8 to GL45.GL_RGBA else -> throw IllegalArgumentException("Unknown amount of channels in $it: $amountOfChannels") } val tex = GLTexture2D(width, height, memFormat) data.thenApplyAsync({ tex.upload(fileFormat, GL45.GL_UNSIGNED_BYTE, it) tex.textureMinFilter = GL45.GL_NEAREST tex.textureMagFilter = GL45.GL_NEAREST }, client) tex } } //_texture.set(WeakReference(value)) return value } val size = Vector2i(width, height) val sprites: Map = Collections.unmodifiableMap(this.spritesInternal) val first: Sprite = this.spritesInternal.values.first() val whole = Sprite("this", 0, 0, width, height) val nonEmptyRegion get() = whole.nonEmptyRegion /** * returns integer in ABGR format */ operator fun get(x: Int, y: Int): Int { return whole[x, y] } /** * returns integer in ABGR format */ operator fun get(x: Int, y: Int, flip: Boolean): Int { return whole[x, y, flip] } operator fun get(name: String): Sprite? { return spritesInternal[name] } fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean { return whole.isTransparent(x, y, flip) } fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List { return whole.worldSpaces(pixelOffset, spaceScan, flip) } private data class DataSprite(val name: String, val coordinates: Vector4i) inner class Sprite(val name: String, val x: Int, val y: Int, val width: Int, val height: Int) : IUVCoordinates { // flip coordinates to account for opengl override val u0: Float = x.toFloat() / this@Image.width override val v1: Float = y.toFloat() / this@Image.height override val u1: Float = (x.toFloat() + this.width.toFloat()) / this@Image.width override val v0: Float = (y.toFloat() + this.height.toFloat()) / this@Image.height /** * returns integer in ABGR format if it is RGB or RGBA picture, * otherwise returns pixels as-is */ operator fun get(x: Int, y: Int): Int { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } val offset = (this.y + y) * this@Image.width * amountOfChannels + (this.x + x) * amountOfChannels val data = data.join() when (amountOfChannels) { 4 -> return data[offset].toInt().and(0xFF) or data[offset + 1].toInt().and(0xFF).shl(8) or data[offset + 2].toInt().and(0xFF).shl(16) or data[offset + 3].toInt().and(0xFF).shl(24) 3 -> return data[offset].toInt().and(0xFF) or data[offset + 1].toInt().and(0xFF).shl(8) or data[offset + 2].toInt().and(0xFF).shl(16) or -0x1000000 // leading alpha as 255 2 -> return data[offset].toInt().and(0xFF) or data[offset + 1].toInt().and(0xFF).shl(8) 1 -> return data[offset].toInt() else -> throw IllegalStateException() } } /** * returns integer in ABGR format */ operator fun get(x: Int, y: Int, flip: Boolean): Int { if (flip) { require(x in 0 until width && y in 0 until height) { "Position out of bounds: $x $y" } return this[width - x - 1, y] } else { return this[x, y] } } fun isTransparent(x: Int, y: Int, flip: Boolean): Boolean { if (x !in 0 until width) return true if (y !in 0 until height) return true if (amountOfChannels != 4) return false return this[x, y, flip] and 0xFF != 0x0 } val nonEmptyRegion by lazy { if (amountOfChannels == 4) { var x0 = 0 var y0 = 0 search@for (y in 0 until height) { for (x in 0 until width) { if (this[x, y] and 0xFF != 0x0) { x0 = x y0 = y break@search } } } var x1 = x0 var y1 = y0 search@for (y in height - 1 downTo y0) { for (x in width - 1 downTo x0) { if (this[x, y] and 0xFF != 0x0) { x1 = x y1 = y break@search } } } return@lazy Vector4i(x0, y0, x1, y1) } Vector4i(0, 0, width, height) } fun worldSpaces(pixelOffset: Vector2i, spaceScan: Double, flip: Boolean): List { if (amountOfChannels != 3 && amountOfChannels != 4) throw IllegalStateException("Can not check world space taken by image with $amountOfChannels color channels") val minX = pixelOffset.x / PIXELS_IN_STARBOUND_UNITi val minY = pixelOffset.y / PIXELS_IN_STARBOUND_UNITi val maxX = (width + pixelOffset.x + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi val maxY = (height + pixelOffset.y + PIXELS_IN_STARBOUND_UNITi - 1) / PIXELS_IN_STARBOUND_UNITi val result = ArrayList() // this is weird, but that's how original game handles this // also we don't cache this info since that's a waste of precious ram for (yspace in minY until maxY) { for (xspace in minX until maxX) { var fillRatio = 0.0 for (y in 0 until PIXELS_IN_STARBOUND_UNITi) { val ypixel = (yspace * PIXELS_IN_STARBOUND_UNITi + y - pixelOffset.y) if (ypixel !in 0 until height) continue for (x in 0 until PIXELS_IN_STARBOUND_UNITi) { val xpixel = (xspace * PIXELS_IN_STARBOUND_UNITi + x - pixelOffset.x) if (xpixel !in 0 until width) continue if (isTransparent(xpixel, ypixel, flip)) { fillRatio += 1.0 / (PIXELS_IN_STARBOUND_UNIT * PIXELS_IN_STARBOUND_UNIT) } } } if (fillRatio >= spaceScan) { result.add(Vector2i(xspace, yspace)) } } } return result } } companion object : TypeAdapter() { private val LOGGER = LogManager.getLogger() private val objects by lazy { Starbound.gson.getAdapter(JsonObject::class.java) } private val vectors by lazy { Starbound.gson.getAdapter(Vector4i::class.java) } private val vectors2 by lazy { Starbound.gson.getAdapter(Vector2i::class.java) } private val configCache = ConcurrentHashMap, IStarboundFile>>>() private val imageCache = ConcurrentHashMap>() private val logger = LogManager.getLogger() data class ReadDirectData(val data: ByteBuffer, val width: Int, val height: Int, val channels: Int) fun readImageDirect(file: IStarboundFile): ReadDirectData { val getWidth = intArrayOf(0) val getHeight = intArrayOf(0) val components = intArrayOf(0) val idata = file.readDirect() val data = STBImage.stbi_load_from_memory( idata, getWidth, getHeight, components, 0 ) ?: throw IllegalArgumentException("File $file is not an image or it is corrupted") Reference.reachabilityFence(idata) val address = MemoryUtil.memAddress(data) Starbound.CLEANER.register(data) { STBImage.nstbi_image_free(address) } return ReadDirectData(data, getWidth[0], getHeight[0], components[0]) } private val dataCache: AsyncLoadingCache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofMinutes(1)) .weigher { key, value -> value.capacity() } .maximumWeight((Runtime.getRuntime().maxMemory() / 4L).coerceIn(1_024L * 1_024L * 32L /* 32 МиБ */, 1_024L * 1_024L * 256L /* 256 МиБ */)) .scheduler(Starbound) .executor(Starbound.EXECUTOR) .buildAsync(CacheLoader { readImageDirect(it).data }) @JvmStatic fun get(path: String): Image? { return imageCache.computeIfAbsent(path) { try { val file = Starbound.locate(it) if (!file.exists) { throw FileNotFoundException("No such file $it") } if (!file.isFile) { throw FileNotFoundException("File $it is a directory") } val getWidth = intArrayOf(0) val getHeight = intArrayOf(0) val components = intArrayOf(0) val stream = BufferedInputStream(file.open()) val callback = STBIIOCallbacks.malloc() val readCallback = STBIReadCallback.create(STBIReadCallbackI { _, buf, size -> val readBuf = ByteArray(size) val read = stream.read(readBuf) for (i in 0 until read) { MemoryUtil.memPutByte(buf + i, readBuf[i]) } return@STBIReadCallbackI read }) val skipCallback = STBISkipCallback.create { _, n -> stream.skip(n.toLong()) } val eofCallback = STBIEOFCallback.create { stream.mark(1) val empty = stream.read() == -1 stream.reset() if (empty) 1 else 0 } callback.set(readCallback, skipCallback, eofCallback) val status = STBImage.stbi_info_from_callbacks( callback, 0L, getWidth, getHeight, components ) readCallback.free() skipCallback.free() eofCallback.free() callback.free() if (!status) throw IllegalArgumentException("File $file is not an image or it is corrupted") Optional.of(Image(file, it, getWidth[0], getHeight[0], components[0], getConfig(it))) } catch (err: Exception) { logger.error("Failed to load image at path $it", err) Optional.empty() } }.orElse(null) } override fun write(out: JsonWriter, value: Image?) { if (value == null) out.nullValue() else out.value(value.path) } override fun read(`in`: JsonReader): Image? { if (`in`.consumeNull()) return null else return get(`in`.nextString()) } private fun generateFakeNames(dimensions: Vector2i): JsonArray { return JsonArray(dimensions.y).also { var stripElem = 0 for (stripNum in 0 until dimensions.y) { val strip = JsonArray(dimensions.x) for (i in 0 until dimensions.x) { strip.add(stripElem.toString()) stripElem++ } it.add(strip) } } } private fun parseFrames(read: JsonObject): List { val begin = read.get("begin", vectors2) { Vector2i.ZERO } val sprites = LinkedHashMap() if ("frameGrid" in read) { val frameGrid = read.getObject("frameGrid") val size = vectors2.fromJsonTree(frameGrid["size"] ?: throw JsonSyntaxException("Missing frameGrid.size")) val dimensions = vectors2.fromJsonTree(frameGrid["dimensions"] ?: throw JsonSyntaxException("Missing frameGrid.dimensions")) require(size.x >= 0 && size.y >= 0) { "Invalid size: $size" } require(dimensions.x > 0 && dimensions.y > 0) { "Invalid dimensions: $dimensions" } val names = (frameGrid.get("names") { generateFakeNames(dimensions) }) .map { (it as? JsonArray)?.map { if (it == JsonNull.INSTANCE) null else it.asString } }.toList() for ((y, strip) in names.withIndex()) { // разрешаем вставлять null как ленту кадров, что означает что мы должны пропустить её полностью if (strip == null) continue for ((x, spriteName) in strip.withIndex()) { // если кадр не имеет имени... if (spriteName == null) continue require(y < dimensions.y && x < dimensions.x) { "Sprite at $x $y is out of bounds for frame grid with dimensions of $dimensions" } sprites[spriteName] = DataSprite(spriteName, Vector4i(begin.x + x * size.x, begin.y + y * size.y, begin.x + (x + 1) * size.x, begin.y + (y + 1) * size.y)) } } } if ("frameList" in read) { for ((spriteName, coords) in read.getObject("frameList").entrySet()) { sprites[spriteName] = DataSprite(spriteName, vectors.fromJsonTree(coords)) } } val aliases = Object2ObjectArrayMap() for ((k, v) in read.get("aliases") { JsonObject() }.entrySet()) aliases[k] = v.asString var changes = true while (aliases.isNotEmpty() && changes) { changes = false val i = aliases.entries.iterator() for ((k, v) in i) { require(k !in sprites) { "Sprite with name '$k' already defined" } val sprite = sprites[v] ?: continue sprites[k] = sprite.copy(name = k) changes = true i.remove() } } for ((k, v) in aliases.entries) throw JsonSyntaxException("Alias '$k' want to refer to sprite '$v', but it does not exist") return ImmutableList.copyOf(sprites.values) } private fun compute(it: String): Optional, IStarboundFile>> { val find = Starbound.locate("$it.frames") if (!find.exists) { return Optional.empty() } else { return Optional.of(parseFrames(objects.read(JsonReader(find.reader()).also { it.isLenient = true })) to find) } } private fun getConfig(path: String): Pair, IStarboundFile>? { var folder = path.substringBefore(':').substringBeforeLast('/') val name = path.substringBefore(':').substringAfterLast('/').substringBefore('.') while (true) { val find = configCache.computeIfAbsent("$folder/$name", ::compute).or { configCache.computeIfAbsent("$folder/default", ::compute) } if (find.isPresent) return find.get() folder = folder.substringBeforeLast('/') if (folder.isEmpty()) return null } } } }