forked from openrs2/openrs2
Signed-off-by: Graham <gpe@openrs2.org>
parent
dbd169aa1a
commit
93ee863e20
@ -0,0 +1,107 @@ |
||||
package org.openrs2.archive.map |
||||
|
||||
import kotlin.math.max |
||||
import kotlin.math.min |
||||
import kotlin.math.pow |
||||
|
||||
public object Colors { |
||||
private val HSL_TO_RGB = IntArray(65536) |
||||
private const val BRIGHTNESS = 0.8 |
||||
|
||||
init { |
||||
var i = 0 |
||||
for (h in 0 until 64) { |
||||
for (s in 0 until 8) { |
||||
for (l in 0 until 128) { |
||||
val hue = h.toDouble() / 64 + 0.0078125 |
||||
val saturation = s.toDouble() / 8 + 0.0625 |
||||
val lightness = l.toDouble() / 128 |
||||
HSL_TO_RGB[i++] = hslToRgb(hue, saturation, lightness) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun hslToRgb(h: Double, s: Double, l: Double): Int { |
||||
var r = l |
||||
var g = l |
||||
var b = l |
||||
|
||||
if (s != 0.0) { |
||||
val q = if (l * 2 < 1) { |
||||
l * (s + 1) |
||||
} else { |
||||
l + s - (l * s) |
||||
} |
||||
|
||||
val p = l * 2 - q |
||||
|
||||
var tr = h + (1.0 / 3) |
||||
if (tr > 1) { |
||||
tr-- |
||||
} |
||||
|
||||
var tb = h - (1.0 / 3) |
||||
if (tb < 0) { |
||||
tb++ |
||||
} |
||||
|
||||
r = if (tr * 6 < 1) { |
||||
tr * (q - p) * 6 + p |
||||
} else if (tr * 2 < 1) { |
||||
q |
||||
} else if (tr * 3 < 2) { |
||||
(2.0 / 3 - tr) * (q - p) * 6 + p |
||||
} else { |
||||
p |
||||
} |
||||
|
||||
g = if (h * 6 < 1) { |
||||
h * (q - p) * 6 + p |
||||
} else if (h * 2 < 1) { |
||||
q |
||||
} else if (h * 3 < 2) { |
||||
(2.0 / 3 - h) * (q - p) * 6 + p |
||||
} else { |
||||
p |
||||
} |
||||
|
||||
b = if (tb * 6 < 1) { |
||||
tb * (q - p) * 6 + p |
||||
} else if (tb * 2 < 1) { |
||||
q |
||||
} else if (tb * 3 < 2) { |
||||
(2.0 / 3 - tb) * (q - p) * 6 + p |
||||
} else { |
||||
p |
||||
} |
||||
} |
||||
|
||||
val red = (r.pow(BRIGHTNESS) * 256).toInt() |
||||
val green = (g.pow(BRIGHTNESS) * 256).toInt() |
||||
val blue = (b.pow(BRIGHTNESS) * 256).toInt() |
||||
|
||||
var rgb = (red shl 16) or (green shl 8) or blue |
||||
if (rgb == 0) { |
||||
rgb = 1 |
||||
} |
||||
|
||||
return rgb |
||||
} |
||||
|
||||
public fun hslToRgb(hsl: Int): Int { |
||||
return HSL_TO_RGB[hsl] |
||||
} |
||||
|
||||
public fun multiplyLightness(hsl: Int, factor: Int): Int { |
||||
return when (hsl) { |
||||
-2 -> 12345678 |
||||
-1 -> 127 - min(max(factor, 0), 127) |
||||
else -> { |
||||
var l = ((hsl and 0x7F) * factor) shr 7 |
||||
l = min(max(l, 2), 126) |
||||
(hsl and 0xFF80) or l |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,57 @@ |
||||
package org.openrs2.archive.map |
||||
|
||||
import io.netty.buffer.ByteBuf |
||||
|
||||
public data class FloType( |
||||
var color: Int = 0, |
||||
var texture: Int = -1, |
||||
var blendColor: Int = -1 |
||||
) { |
||||
public companion object { |
||||
public fun read(buf: ByteBuf): FloType { |
||||
val type = FloType() |
||||
|
||||
while (true) { |
||||
val code = buf.readUnsignedByte().toInt() |
||||
if (code == 0) { |
||||
break |
||||
} else if (code == 1) { |
||||
type.color = buf.readUnsignedMedium() |
||||
} else if (code == 2) { |
||||
type.texture = buf.readUnsignedByte().toInt() |
||||
} else if (code == 3) { |
||||
type.texture = buf.readUnsignedShort() |
||||
if (type.texture == 65535) { |
||||
type.texture = -1 |
||||
} |
||||
} else if (code == 5) { |
||||
// empty |
||||
} else if (code == 7) { |
||||
type.blendColor = buf.readUnsignedMedium() |
||||
} else if (code == 8) { |
||||
// empty |
||||
} else if (code == 9) { |
||||
buf.skipBytes(2) |
||||
} else if (code == 10) { |
||||
// empty |
||||
} else if (code == 11) { |
||||
buf.skipBytes(1) |
||||
} else if (code == 12) { |
||||
// empty |
||||
} else if (code == 13) { |
||||
buf.skipBytes(3) |
||||
} else if (code == 14) { |
||||
buf.skipBytes(1) |
||||
} else if (code == 15) { |
||||
buf.skipBytes(2) |
||||
} else if (code == 16) { |
||||
buf.skipBytes(1) |
||||
} else { |
||||
throw IllegalArgumentException("Unsupported code: $code") |
||||
} |
||||
} |
||||
|
||||
return type |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,34 @@ |
||||
package org.openrs2.archive.map |
||||
|
||||
import io.netty.buffer.ByteBuf |
||||
|
||||
public data class FluType( |
||||
var color: Int = 0 |
||||
) { |
||||
public companion object { |
||||
public fun read(buf: ByteBuf): FluType { |
||||
val type = FluType() |
||||
|
||||
while (true) { |
||||
val code = buf.readUnsignedByte().toInt() |
||||
if (code == 0) { |
||||
break |
||||
} else if (code == 1) { |
||||
type.color = buf.readUnsignedMedium() |
||||
} else if (code == 2) { |
||||
buf.skipBytes(2) |
||||
} else if (code == 3) { |
||||
buf.skipBytes(2) |
||||
} else if (code == 4) { |
||||
// empty |
||||
} else if (code == 5) { |
||||
// empty |
||||
} else { |
||||
throw IllegalArgumentException("Unsupported code: $code") |
||||
} |
||||
} |
||||
|
||||
return type |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,426 @@ |
||||
package org.openrs2.archive.map |
||||
|
||||
import io.netty.buffer.ByteBuf |
||||
import io.netty.buffer.Unpooled |
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap |
||||
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 javax.inject.Inject |
||||
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(masterIndexId: Int): BufferedImage { |
||||
return database.execute { connection -> |
||||
// read config index |
||||
val configIndex = readIndex(connection, 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, 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, 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, masterIndexId, Js5Archive.MATERIALS) |
||||
|
||||
if (materialsIndex != null) { |
||||
val materialsGroup = materialsIndex[0] |
||||
?: throw IllegalArgumentException("Materials group missing in index") |
||||
|
||||
val materialsFiles = readGroup(connection, 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 |
||||
} |
||||
} |
||||
|
||||
metadata.skipBytes(ids.size * 7) |
||||
|
||||
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, masterIndexId, Js5Archive.TEXTURES) |
||||
?: throw IllegalArgumentException("Textures index missing") |
||||
|
||||
val textureGroup = textureIndex[0] |
||||
?: throw IllegalArgumentException("Textures group missing from index") |
||||
|
||||
val textureFiles = readGroup(connection, 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.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, 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.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, 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 |
||||
|
||||
renderStateOverlay(image, graphics, x - x0, z - z0, state) |
||||
} |
||||
} |
||||
|
||||
return@execute image |
||||
} |
||||
} |
||||
|
||||
private fun readIndex(connection: Connection, masterIndexId: Int, archiveId: Int): Js5Index? { |
||||
connection.prepareStatement( |
||||
""" |
||||
SELECT data |
||||
FROM resolved_indexes |
||||
WHERE master_index_id = ? AND archive_id = ? |
||||
""".trimIndent() |
||||
).use { stmt -> |
||||
stmt.setInt(1, masterIndexId) |
||||
stmt.setInt(2, 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, |
||||
masterIndexId: Int, |
||||
archiveId: Int, |
||||
group: Js5Index.Group |
||||
): Int2ObjectSortedMap<ByteBuf>? { |
||||
connection.prepareStatement( |
||||
""" |
||||
SELECT data |
||||
FROM resolved_groups |
||||
WHERE master_index_id = ? AND archive_id = ? AND group_id = ? |
||||
""".trimIndent() |
||||
).use { stmt -> |
||||
stmt.setInt(1, masterIndexId) |
||||
stmt.setInt(2, archiveId) |
||||
stmt.setInt(3, 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 renderMap( |
||||
image: BufferedImage, |
||||
x: Int, |
||||
z: Int, |
||||
buf: ByteBuf, |
||||
underlayColors: Map<Int, Int>, |
||||
overlayColors: Map<Int, Int> |
||||
) { |
||||
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 = buf.readUnsignedByte().toInt() |
||||
if (code == 0) { |
||||
break |
||||
} else if (code == 1) { |
||||
buf.skipBytes(1) |
||||
break |
||||
} else if (code <= 49) { |
||||
overlay = buf.readUnsignedByte().toInt() |
||||
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 |
||||
) { |
||||
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 label = "${mapX}_$mapZ" |
||||
|
||||
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 |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue