diff --git a/src/main/java/assets/ui/BackgroundAsset.kt b/src/main/java/assets/ui/BackgroundAsset.kt index 8c383da..4c51c13 100644 --- a/src/main/java/assets/ui/BackgroundAsset.kt +++ b/src/main/java/assets/ui/BackgroundAsset.kt @@ -2,11 +2,15 @@ package assets.ui import assets.Asset import assets.AssetsDir +import project.build.BuildCtx +import util.N64ImageFormat +import util.convertN64Image import java.awt.Image import java.awt.image.BufferedImage import java.io.IOException import java.nio.file.Path import javax.imageio.ImageIO +import kotlin.io.path.writeBytes class BackgroundAsset(assetsDir: AssetsDir, relativePath: Path) : Asset(assetsDir, relativePath) { @JvmField @@ -21,4 +25,12 @@ class BackgroundAsset(assetsDir: AssetsDir, relativePath: Path) : Asset(assetsDi } override fun loadThumbnail(): Image? = bimg + + override fun getArtifacts(ctx: BuildCtx): List = listOf(ctx.artifact(this)) + + override suspend fun build(ctx: BuildCtx) { + val output = ctx.artifact(this) + val result = util.convertN64Image(util.N64ImageFormat.BG, getFile(), flipY = true) + output.writeBytes(result.bytes) + } } diff --git a/src/main/java/util/N64ImageConverter.kt b/src/main/java/util/N64ImageConverter.kt new file mode 100644 index 0000000..44b32fe --- /dev/null +++ b/src/main/java/util/N64ImageConverter.kt @@ -0,0 +1,471 @@ +package util + +import ar.com.hjg.pngj.ImageInfo +import ar.com.hjg.pngj.ImageLineHelper +import ar.com.hjg.pngj.ImageLineInt +import ar.com.hjg.pngj.PngReader +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.floor +import kotlin.math.round + +/** + * Converts PNG images to N64 texture formats. + * Ported from papermario/tools/build/img/build.py + */ + +enum class N64ImageFormat { + RGBA32, RGBA16, CI8, CI4, PALETTE, IA4, IA8, IA16, I4, I8, PARTY, BG +} + +data class N64ImageConversionResult( + val bytes: ByteArray, + val width: Int, + val height: Int +) + +fun convertN64Image( + format: N64ImageFormat, + infile: File, + flipY: Boolean = false +): N64ImageConversionResult { + val reader = PngReader(infile) + val imgInfo = reader.imgInfo + val width = imgInfo.cols + val height = imgInfo.rows + + val context = ConversionContext(infile, reader, imgInfo, flipY) + + val outBytes = when (format) { + N64ImageFormat.RGBA32 -> context.convertRgba32() + N64ImageFormat.RGBA16 -> context.convertRgba16() + N64ImageFormat.CI8 -> context.convertCi8() + N64ImageFormat.CI4 -> context.convertCi4() + N64ImageFormat.PALETTE -> context.convertPalette() + N64ImageFormat.IA4 -> context.convertIa4() + N64ImageFormat.IA8 -> context.convertIa8() + N64ImageFormat.IA16 -> context.convertIa16() + N64ImageFormat.I4 -> context.convertI4() + N64ImageFormat.I8 -> context.convertI8() + N64ImageFormat.PARTY -> context.convertParty() + N64ImageFormat.BG -> context.convertBg() + } + + reader.end() + return N64ImageConversionResult(outBytes, width, height) +} + +private class ConversionContext( + val infile: File, + val reader: PngReader, + val imgInfo: ImageInfo, + val flipY: Boolean +) { + private var warned = false + + fun warn(msg: String) { + if (!warned) { + warned = true + Logger.logWarning("${infile.name}: $msg") + } + } + + fun packColor(r: Int, g: Int, b: Int, a: Int): Int { + val r5 = r shr 3 + val g5 = g shr 3 + val b5 = b shr 3 + val a1 = a shr 7 + return (r5 shl 11) or (g5 shl 6) or (b5 shl 1) or a1 + } + + fun rgbToIntensity(r: Int, g: Int, b: Int): Int { + return round(r * 0.2126 + g * 0.7152 + b * 0.0722).toInt() + } + + fun convertRgba32(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 4) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + buffer.put(row) + } + + return buffer.array() + } + + fun convertRgba16(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 2) + buffer.order(ByteOrder.BIG_ENDIAN) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + for (i in 0 until row.size step 4) { + val r = row[i].toInt() and 0xFF + val g = row[i + 1].toInt() and 0xFF + val b = row[i + 2].toInt() and 0xFF + val a = row[i + 3].toInt() and 0xFF + + if (a !in listOf(0, 0xFF)) { + warn("alpha mask mode but translucent pixels used") + } + + val color = packColor(r, g, b, a) + buffer.putShort(color.toShort()) + } + } + + return buffer.array() + } + + fun convertCi8(): ByteArray { + require(imgInfo.indexed) { "ci8 mode requires indexed PNG" } + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows) + val rows = readAllRowsIndexed() + + for (row in if (flipY) rows.reversed() else rows) { + buffer.put(row) + } + + return buffer.array() + } + + fun convertCi4(): ByteArray { + require(imgInfo.indexed) { "ci4 mode requires indexed PNG" } + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2) + val rows = readAllRowsIndexed() + + for (row in if (flipY) rows.reversed() else rows) { + for (i in 0 until row.size step 2) { + val a = row[i].toInt() and 0xFF + val b = row[i + 1].toInt() and 0xFF + val byte = ((a shl 4) or b) and 0xFF + buffer.put(byte.toByte()) + } + } + + return buffer.array() + } + + fun convertPalette(): ByteArray { + require(imgInfo.indexed) { "palette mode requires indexed PNG" } + val palette = reader.metadata.plte + val trans = reader.metadata.trns + + val buffer = ByteBuffer.allocate(palette.nentries * 2) + buffer.order(ByteOrder.BIG_ENDIAN) + + for (i in 0 until palette.nentries) { + val entry = palette.getEntry(i) + val r = (entry shr 16) and 0xFF + val g = (entry shr 8) and 0xFF + val b = entry and 0xFF + val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255 + + if (a !in listOf(0, 255)) { + warn("alpha mask mode but translucent pixels used") + } + + val color = packColor(r, g, b, a) + buffer.putShort(color.toShort()) + } + + return buffer.array() + } + + fun convertIa4(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + var i = 0 + while (i < row.size) { + val r1 = row[i].toInt() and 0xFF + val g1 = row[i + 1].toInt() and 0xFF + val b1 = row[i + 2].toInt() and 0xFF + val a1 = row[i + 3].toInt() and 0xFF + + val r2 = row[i + 4].toInt() and 0xFF + val g2 = row[i + 5].toInt() and 0xFF + val b2 = row[i + 6].toInt() and 0xFF + val a2 = row[i + 7].toInt() and 0xFF + + val i1 = rgbToIntensity(r1, g1, b1) shr 5 + val i2 = rgbToIntensity(r2, g2, b2) shr 5 + + if (a1 !in listOf(0, 0xFF) || a2 !in listOf(0, 0xFF)) { + warn("alpha mask mode but translucent pixels used") + } + if (r1 != g1 || g1 != b1) warn("grayscale mode but image is not") + if (r2 != g2 || g2 != b2) warn("grayscale mode but image is not") + + val a1bit = if (a1 > 128) 1 else 0 + val a2bit = if (a2 > 128) 1 else 0 + + val h = (i1 shl 1) or a1bit + val l = (i2 shl 1) or a2bit + val byte = ((h shl 4) or l) and 0xFF + buffer.put(byte.toByte()) + + i += 8 + } + } + + return buffer.array() + } + + fun convertIa8(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + for (i in 0 until row.size step 4) { + val r = row[i].toInt() and 0xFF + val g = row[i + 1].toInt() and 0xFF + val b = row[i + 2].toInt() and 0xFF + val a = row[i + 3].toInt() and 0xFF + + val intensity = floor(15.0 * (rgbToIntensity(r, g, b) / 255.0)).toInt() + val alpha = floor(15.0 * (a / 255.0)).toInt() + + if (r != g || g != b) warn("grayscale mode but image is not") + + val byte = ((intensity shl 4) or alpha) and 0xFF + buffer.put(byte.toByte()) + } + } + + return buffer.array() + } + + fun convertIa16(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 2) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + for (i in 0 until row.size step 4) { + val r = row[i].toInt() and 0xFF + val g = row[i + 1].toInt() and 0xFF + val b = row[i + 2].toInt() and 0xFF + val a = row[i + 3].toInt() and 0xFF + + if (r != g || g != b) warn("grayscale mode but image is not") + + buffer.put(rgbToIntensity(r, g, b).toByte()) + buffer.put(a.toByte()) + } + } + + return buffer.array() + } + + fun convertI4(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + var i = 0 + while (i < row.size) { + val r1 = row[i].toInt() and 0xFF + val g1 = row[i + 1].toInt() and 0xFF + val b1 = row[i + 2].toInt() and 0xFF + val a1 = row[i + 3].toInt() and 0xFF + + val r2 = row[i + 4].toInt() and 0xFF + val g2 = row[i + 5].toInt() and 0xFF + val b2 = row[i + 6].toInt() and 0xFF + val a2 = row[i + 7].toInt() and 0xFF + + if (a1 != 0xFF || a2 != 0xFF) warn("discarding alpha channel") + if (r1 != g1 || g1 != b1) warn("grayscale mode but image is not") + if (r2 != g2 || g2 != b2) warn("grayscale mode but image is not") + + val i1 = floor(15.0 * (rgbToIntensity(r1, g1, b1) / 255.0)).toInt() + val i2 = floor(15.0 * (rgbToIntensity(r2, g2, b2) / 255.0)).toInt() + + val byte = ((i1 shl 4) or i2) and 0xFF + buffer.put(byte.toByte()) + + i += 8 + } + } + + return buffer.array() + } + + fun convertI8(): ByteArray { + val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows) + val rows = readAllRows() + + for (row in if (flipY) rows.reversed() else rows) { + for (i in 0 until row.size step 4) { + val r = row[i].toInt() and 0xFF + val g = row[i + 1].toInt() and 0xFF + val b = row[i + 2].toInt() and 0xFF + val a = row[i + 3].toInt() and 0xFF + + if (a != 0xFF) warn("discarding alpha channel") + if (r != g || g != b) warn("grayscale mode but image is not") + + buffer.put(rgbToIntensity(r, g, b).toByte()) + } + } + + return buffer.array() + } + + fun convertParty(): ByteArray { + require(imgInfo.indexed) { "party mode requires indexed PNG" } + val palette = reader.metadata.plte + val trans = reader.metadata.trns + + val paletteSize = palette.nentries * 2 + val imageSize = imgInfo.cols * imgInfo.rows + val buffer = ByteBuffer.allocate(paletteSize + imageSize + 10) + buffer.order(ByteOrder.BIG_ENDIAN) + + // Write palette + for (i in 0 until palette.nentries) { + val entry = palette.getEntry(i) + val r = (entry shr 16) and 0xFF + val g = (entry shr 8) and 0xFF + val b = entry and 0xFF + val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255 + + if (a !in listOf(0, 255)) { + warn("alpha mask mode but translucent pixels used") + } + + val color = packColor(r, g, b, a) + buffer.putShort(color.toShort()) + } + + // Write ci8 data + val rows = readAllRowsIndexed() + for (row in if (flipY) rows.reversed() else rows) { + buffer.put(row) + } + + // Write padding + buffer.put(ByteArray(10)) + + return buffer.array() + } + + fun convertBg(): ByteArray { + require(imgInfo.indexed) { "bg mode requires indexed PNG" } + + // Read main palette + data class PaletteData( + val plte: ar.com.hjg.pngj.chunks.PngChunkPLTE, + val trns: ar.com.hjg.pngj.chunks.PngChunkTRNS? + ) + + val palettes = mutableListOf() + val mainPalette = reader.metadata.plte + val mainTrans = reader.metadata.trns + palettes.add(PaletteData(mainPalette, mainTrans)) + + // Read variant palettes (e.g., "background.1.png", "background.2.png") + val baseName = infile.nameWithoutExtension + val parentDir = infile.parentFile + var variantIndex = 1 + while (true) { + val variantFile = File(parentDir, "$baseName.$variantIndex.png") + if (!variantFile.exists()) break + + val variantReader = PngReader(variantFile) + val variantPalette = variantReader.metadata.plte + val variantTrans = variantReader.metadata.trns + palettes.add(PaletteData(variantPalette, variantTrans)) + variantReader.end() + variantIndex++ + } + + val baseAddr = 0x80200000 // gBackgroundImage + val headersLen = 0x10 * palettes.size + val palettesLen = 0x200 * palettes.size + val imageSize = imgInfo.cols * imgInfo.rows + + val buffer = ByteBuffer.allocate(headersLen + palettesLen + imageSize) + buffer.order(ByteOrder.BIG_ENDIAN) + + // Write headers (struct BackgroundHeader) + for (i in palettes.indices) { + buffer.putInt((baseAddr + palettesLen + headersLen).toInt()) // raster offset + buffer.putInt((baseAddr + headersLen + 0x200 * i).toInt()) // palette offset + buffer.putShort(12) // startX + buffer.putShort(20) // startY + buffer.putShort(imgInfo.cols.toShort()) // width + buffer.putShort(imgInfo.rows.toShort()) // height + } + + // Write palettes + for (paletteData in palettes) { + val palette = paletteData.plte + val trans = paletteData.trns + + for (i in 0 until 256) { + if (i < palette.nentries) { + val entry = palette.getEntry(i) + val r = (entry shr 16) and 0xFF + val g = (entry shr 8) and 0xFF + val b = entry and 0xFF + val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255 + + if (a !in listOf(0, 255)) { + warn("alpha mask mode but translucent pixels used") + } + + val color = packColor(r, g, b, a) + buffer.putShort(color.toShort()) + } else { + buffer.putShort(0) + } + } + } + + // Write ci8 data + val rows = readAllRowsIndexed() + for (row in if (flipY) rows.reversed() else rows) { + buffer.put(row) + } + + return buffer.array() + } + + private fun readAllRows(): List { + val rows = mutableListOf() + for (row in 0 until imgInfo.rows) { + val line = reader.readRow() as ImageLineInt + val rgba = ByteArray(imgInfo.cols * 4) + val scanline = line.scanline + + for (col in 0 until imgInfo.cols) { + val idx = col * 4 + val srcIdx = col * imgInfo.channels + + rgba[idx] = scanline[srcIdx].toByte() // R + rgba[idx + 1] = scanline[srcIdx + 1].toByte() // G + rgba[idx + 2] = scanline[srcIdx + 2].toByte() // B + rgba[idx + 3] = if (imgInfo.alpha) scanline[srcIdx + 3].toByte() else 0xFF.toByte() // A + } + rows.add(rgba) + } + return rows + } + + private fun readAllRowsIndexed(): List { + val rows = mutableListOf() + for (row in 0 until imgInfo.rows) { + val line = reader.readRow() as ImageLineInt + val indices = ByteArray(imgInfo.cols) + val scanline = line.scanline + for (i in 0 until imgInfo.cols) { + indices[i] = scanline[i].toByte() + } + rows.add(indices) + } + return rows + } +}