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.
 
 
 
 
openrs2/archive/src/main/kotlin/org/openrs2/archive/map/MapRenderer.kt

502 lines
17 KiB

package org.openrs2.archive.map
import io.netty.buffer.ByteBuf
import io.netty.buffer.Unpooled
import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap
import jakarta.inject.Inject
import org.openrs2.buffer.use
import org.openrs2.cache.Group
import org.openrs2.cache.Js5Archive
import org.openrs2.cache.Js5Compression
import org.openrs2.cache.Js5ConfigGroup
import org.openrs2.cache.Js5Index
import org.openrs2.db.Database
import java.awt.Color
import java.awt.Graphics2D
import java.awt.image.BufferedImage
import java.sql.Connection
import kotlin.math.max
import kotlin.math.min
public class MapRenderer @Inject constructor(
private val database: Database
) {
private enum class MapSquareState(
val outlineColor: Color
) {
UNKNOWN(Color.RED),
EMPTY(Color.GRAY),
VALID(Color.GREEN);
val fillColor = Color(outlineColor.red, outlineColor.green, outlineColor.blue, 128)
}
public suspend fun render(scope: String, masterIndexId: Int): BufferedImage {
return database.execute { connection ->
val scopeId = connection.prepareStatement(
"""
SELECT id
FROM scopes
WHERE name = ?
""".trimIndent()
).use { stmt ->
stmt.setString(1, scope)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
throw IllegalArgumentException("Invalid scope")
}
rows.getInt(1)
}
}
// read config index
val configIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.CONFIG)
?: throw IllegalArgumentException("Config index missing")
// read FluType group
val underlayColors = mutableMapOf<Int, Int>()
val underlayGroup = configIndex[Js5ConfigGroup.FLUTYPE]
?: throw IllegalArgumentException("FluType group missing in index")
val underlayFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.CONFIG, underlayGroup)
?: throw IllegalArgumentException("FluType group missing")
try {
for ((id, file) in underlayFiles) {
underlayColors[id] = FluType.read(file).color
}
} finally {
underlayFiles.values.forEach(ByteBuf::release)
}
// read FloType group
val overlays = mutableMapOf<Int, FloType>()
val overlayGroup = configIndex[Js5ConfigGroup.FLOTYPE]
?: throw IllegalArgumentException("FloType group missing in index")
val overlayFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.CONFIG, overlayGroup)
?: throw IllegalArgumentException("FloType group missing")
try {
for ((id, file) in overlayFiles) {
overlays[id] = FloType.read(file)
}
} finally {
overlayFiles.values.forEach(ByteBuf::release)
}
// read textures
val textures = mutableMapOf<Int, Int>()
val materialsIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.MATERIALS)
if (materialsIndex != null) {
val materialsGroup = materialsIndex[0]
?: throw IllegalArgumentException("Materials group missing in index")
val materialsFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.MATERIALS, materialsGroup)
?: throw IllegalArgumentException("Materials group missing")
try {
val metadata = materialsFiles[0]
val len = metadata.readUnsignedShort()
val ids = mutableSetOf<Int>()
for (id in 0 until len) {
if (metadata.readBoolean()) {
ids += id
}
}
val use = mutableSetOf<Int>()
for (id in ids) {
if (metadata.readBoolean()) {
use += id
}
}
// the number of booleans to skip varies in different builds
outer@ while (true) {
val start = metadata.readerIndex()
for (i in 0 until ids.size) {
if (metadata.getUnsignedByte(start + i) > 1) {
break@outer
}
}
metadata.skipBytes(ids.size)
}
metadata.skipBytes(ids.size * 4)
for (id in ids) {
textures[id] = metadata.readUnsignedShort()
if (id !in use) {
textures.remove(id)
}
}
} finally {
materialsFiles.values.forEach(ByteBuf::release)
}
} else {
val textureIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.TEXTURES)
?: throw IllegalArgumentException("Textures index missing")
val textureGroup = textureIndex[0]
?: throw IllegalArgumentException("Textures group missing from index")
val textureFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.TEXTURES, textureGroup)
?: throw IllegalArgumentException("Textures group missing")
try {
for ((id, file) in textureFiles) {
textures[id] = file.readUnsignedShort()
}
} finally {
textureFiles.values.forEach(ByteBuf::release)
}
}
// create overlay colors
val overlayColors = createOverlayColors(overlays, textures)
// read loc encrypted/empty loc flags and keys and determine bounds of the map
var x0 = Int.MAX_VALUE
var x1 = Int.MIN_VALUE
var z0 = Int.MAX_VALUE
var z1 = Int.MIN_VALUE
val states = mutableMapOf<Int, MapSquareState>()
connection.prepareStatement(
"""
SELECT n.name, g.encrypted, g.empty_loc, g.key_id
FROM resolved_groups g
JOIN names n ON n.hash = g.name_hash
WHERE g.scope_id = ? AND g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND
n.name ~ '^[lm](?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
""".trimIndent()
).use { stmt ->
stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.executeQuery().use { rows ->
while (rows.next()) {
val name = rows.getString(1)
val encrypted = rows.getBoolean(2)
val empty = rows.getBoolean(3)
var keyId: Long? = rows.getLong(4)
if (rows.wasNull()) {
keyId = null
}
val (x, z) = getMapCoordinates(name)
x0 = min(x0, x)
x1 = max(x1, x)
z0 = min(z0, z)
z1 = max(z1, z)
if (name.startsWith('l')) {
val mapSquare = getMapSquare(x, z)
states[mapSquare] = if (!encrypted || keyId != null) {
MapSquareState.VALID
} else if (empty) {
MapSquareState.EMPTY
} else {
MapSquareState.UNKNOWN
}
}
}
}
}
if (x0 == Int.MAX_VALUE) {
throw IllegalArgumentException("Map empty")
}
// read and render maps
val image = BufferedImage(
((x1 - x0) + 1) * MAP_SIZE,
((z1 - z0) + 1) * MAP_SIZE,
BufferedImage.TYPE_INT_RGB
)
connection.prepareStatement(
"""
SELECT n.name, g.data
FROM resolved_groups g
JOIN names n ON n.hash = g.name_hash
WHERE g.scope_id = ? AND g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND
n.name ~ '^m(?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
""".trimIndent()
).use { stmt ->
stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.executeQuery().use { rows ->
while (rows.next()) {
val name = rows.getString(1)
val bytes = rows.getBytes(2)
val (x, z) = getMapCoordinates(name)
Unpooled.wrappedBuffer(bytes).use { compressed ->
Js5Compression.uncompress(compressed).use { uncompressed ->
renderMap(image, x - x0, z - z0, uncompressed, underlayColors, overlayColors)
}
}
}
}
}
// render state overlay
val graphics = image.createGraphics()
for (x in x0..x1) {
for (z in z0..z1) {
val mapSquare = getMapSquare(x, z)
val state = states[mapSquare] ?: continue
val label = "${x}_$z"
renderStateOverlay(image, graphics, x - x0, z - z0, state, label)
}
}
return@execute image
}
}
private fun readIndex(connection: Connection, scopeId: Int, masterIndexId: Int, archiveId: Int): Js5Index? {
connection.prepareStatement(
"""
SELECT data
FROM resolved_indexes
WHERE scope_id = ? AND master_index_id = ? AND archive_id = ?
""".trimIndent()
).use { stmt ->
stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.setInt(3, archiveId)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
return null
}
val bytes = rows.getBytes(1)
Unpooled.wrappedBuffer(bytes).use { compressed ->
Js5Compression.uncompress(compressed).use { uncompressed ->
return Js5Index.read(uncompressed)
}
}
}
}
}
private fun readGroup(
connection: Connection,
scopeId: Int,
masterIndexId: Int,
archiveId: Int,
group: Js5Index.Group<*>
): Int2ObjectSortedMap<ByteBuf>? {
connection.prepareStatement(
"""
SELECT data
FROM resolved_groups
WHERE scope_id = ? AND master_index_id = ? AND archive_id = ? AND group_id = ?
""".trimIndent()
).use { stmt ->
stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.setInt(3, archiveId)
stmt.setInt(4, group.id)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
return null
}
val bytes = rows.getBytes(1)
Unpooled.wrappedBuffer(bytes).use { compressed ->
Js5Compression.uncompress(compressed).use { uncompressed ->
return Group.unpack(uncompressed, group)
}
}
}
}
}
private fun createOverlayColors(overlays: Map<Int, FloType>, textures: Map<Int, Int>): Map<Int, Int> {
return overlays.mapValues { (_, type) ->
if (type.blendColor != -1) {
type.blendColor
} else if (type.texture != -1 && type.texture in textures) {
val averageColor = textures[type.texture]!!
Colors.hslToRgb(Colors.multiplyLightness(averageColor, 96))
} else if (type.color == 0xFF00FF) {
0
} else {
type.color
}
}
}
private fun isShortCode(buf: ByteBuf): Boolean {
for (plane in 0 until LEVELS) {
for (dx in 0 until MAP_SIZE) {
for (dz in 0 until MAP_SIZE) {
while (true) {
if (buf.readableBytes() < 2) {
return false
}
val code = buf.readUnsignedShort()
if (code == 0) {
break
} else if (code == 1) {
if (!buf.isReadable) {
return false
}
buf.skipBytes(1)
break
} else if (code <= 49) {
if (buf.readableBytes() < 2) {
return false
}
buf.skipBytes(2)
}
}
}
}
}
return !buf.isReadable
}
private fun renderMap(
image: BufferedImage,
x: Int,
z: Int,
buf: ByteBuf,
underlayColors: Map<Int, Int>,
overlayColors: Map<Int, Int>
) {
val readCode = if (isShortCode(buf.slice())) {
buf::readUnsignedShort
} else {
{ buf.readUnsignedByte().toInt() }
}
for (plane in 0 until LEVELS) {
for (dx in 0 until MAP_SIZE) {
for (dz in 0 until MAP_SIZE) {
var overlay = 0
var shape = 0
var underlay = 0
while (true) {
val code = readCode()
if (code == 0) {
break
} else if (code == 1) {
buf.skipBytes(1)
break
} else if (code <= 49) {
overlay = readCode()
shape = (code - 2) shr 2
} else if (code <= 81) {
// empty
} else {
underlay = code - 81
}
}
var color = 0
if (underlay != 0) {
color = underlayColors[underlay - 1]!!
}
if (overlay != 0 && shape == 0) {
color = overlayColors[overlay - 1]!!
}
if (color > 0) {
image.setRGB(x * MAP_SIZE + dx, image.height - (z * MAP_SIZE + dz) - 1, color)
}
}
}
}
}
private fun renderStateOverlay(
image: BufferedImage,
graphics: Graphics2D,
mapX: Int,
mapZ: Int,
state: MapSquareState,
label: String
) {
val x = mapX * MAP_SIZE
val y = image.height - (mapZ + 1) * MAP_SIZE
if (state != MapSquareState.VALID) {
graphics.color = state.fillColor
graphics.fillRect(
x,
y,
MAP_SIZE,
MAP_SIZE
)
}
graphics.color = state.outlineColor
graphics.drawRect(
x,
y,
MAP_SIZE - 1,
MAP_SIZE - 1
)
val labelWidth = graphics.fontMetrics.stringWidth(label)
val labelHeight = graphics.fontMetrics.height
val labelAscent = graphics.fontMetrics.ascent
val labelX = x + (MAP_SIZE - labelWidth) / 2
val labelY = y + (MAP_SIZE - labelHeight) / 2 + labelAscent
graphics.color = Color.BLACK
graphics.drawString(label, labelX + 1, labelY)
graphics.color = Color.BLACK
graphics.drawString(label, labelX, labelY + 1)
graphics.color = Color.WHITE
graphics.drawString(label, labelX, labelY)
}
private companion object {
private val LOC_OR_MAP_NAME_REGEX = Regex("[lm](\\d+)_(\\d+)")
private const val MAP_SIZE = 64
private const val LEVELS = 4
private fun getMapCoordinates(name: String): Pair<Int, Int> {
val match = LOC_OR_MAP_NAME_REGEX.matchEntire(name)
require(match != null)
val x = match.groupValues[1].toInt()
val z = match.groupValues[2].toInt()
return Pair(x, z)
}
private fun getMapSquare(x: Int, z: Int): Int {
return (x shl 8) or z
}
}
}