···11+package util
22+33+import ar.com.hjg.pngj.ImageInfo
44+import ar.com.hjg.pngj.ImageLineHelper
55+import ar.com.hjg.pngj.ImageLineInt
66+import ar.com.hjg.pngj.PngReader
77+import java.io.File
88+import java.nio.ByteBuffer
99+import java.nio.ByteOrder
1010+import kotlin.math.floor
1111+import kotlin.math.round
1212+1313+/**
1414+ * Converts PNG images to N64 texture formats.
1515+ * Ported from papermario/tools/build/img/build.py
1616+ */
1717+1818+enum class N64ImageFormat {
1919+ RGBA32, RGBA16, CI8, CI4, PALETTE, IA4, IA8, IA16, I4, I8, PARTY, BG
2020+}
2121+2222+data class N64ImageConversionResult(
2323+ val bytes: ByteArray,
2424+ val width: Int,
2525+ val height: Int
2626+)
2727+2828+fun convertN64Image(
2929+ format: N64ImageFormat,
3030+ infile: File,
3131+ flipY: Boolean = false
3232+): N64ImageConversionResult {
3333+ val reader = PngReader(infile)
3434+ val imgInfo = reader.imgInfo
3535+ val width = imgInfo.cols
3636+ val height = imgInfo.rows
3737+3838+ val context = ConversionContext(infile, reader, imgInfo, flipY)
3939+4040+ val outBytes = when (format) {
4141+ N64ImageFormat.RGBA32 -> context.convertRgba32()
4242+ N64ImageFormat.RGBA16 -> context.convertRgba16()
4343+ N64ImageFormat.CI8 -> context.convertCi8()
4444+ N64ImageFormat.CI4 -> context.convertCi4()
4545+ N64ImageFormat.PALETTE -> context.convertPalette()
4646+ N64ImageFormat.IA4 -> context.convertIa4()
4747+ N64ImageFormat.IA8 -> context.convertIa8()
4848+ N64ImageFormat.IA16 -> context.convertIa16()
4949+ N64ImageFormat.I4 -> context.convertI4()
5050+ N64ImageFormat.I8 -> context.convertI8()
5151+ N64ImageFormat.PARTY -> context.convertParty()
5252+ N64ImageFormat.BG -> context.convertBg()
5353+ }
5454+5555+ reader.end()
5656+ return N64ImageConversionResult(outBytes, width, height)
5757+}
5858+5959+private class ConversionContext(
6060+ val infile: File,
6161+ val reader: PngReader,
6262+ val imgInfo: ImageInfo,
6363+ val flipY: Boolean
6464+) {
6565+ private var warned = false
6666+6767+ fun warn(msg: String) {
6868+ if (!warned) {
6969+ warned = true
7070+ Logger.logWarning("${infile.name}: $msg")
7171+ }
7272+ }
7373+7474+ fun packColor(r: Int, g: Int, b: Int, a: Int): Int {
7575+ val r5 = r shr 3
7676+ val g5 = g shr 3
7777+ val b5 = b shr 3
7878+ val a1 = a shr 7
7979+ return (r5 shl 11) or (g5 shl 6) or (b5 shl 1) or a1
8080+ }
8181+8282+ fun rgbToIntensity(r: Int, g: Int, b: Int): Int {
8383+ return round(r * 0.2126 + g * 0.7152 + b * 0.0722).toInt()
8484+ }
8585+8686+ fun convertRgba32(): ByteArray {
8787+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 4)
8888+ val rows = readAllRows()
8989+9090+ for (row in if (flipY) rows.reversed() else rows) {
9191+ buffer.put(row)
9292+ }
9393+9494+ return buffer.array()
9595+ }
9696+9797+ fun convertRgba16(): ByteArray {
9898+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 2)
9999+ buffer.order(ByteOrder.BIG_ENDIAN)
100100+ val rows = readAllRows()
101101+102102+ for (row in if (flipY) rows.reversed() else rows) {
103103+ for (i in 0 until row.size step 4) {
104104+ val r = row[i].toInt() and 0xFF
105105+ val g = row[i + 1].toInt() and 0xFF
106106+ val b = row[i + 2].toInt() and 0xFF
107107+ val a = row[i + 3].toInt() and 0xFF
108108+109109+ if (a !in listOf(0, 0xFF)) {
110110+ warn("alpha mask mode but translucent pixels used")
111111+ }
112112+113113+ val color = packColor(r, g, b, a)
114114+ buffer.putShort(color.toShort())
115115+ }
116116+ }
117117+118118+ return buffer.array()
119119+ }
120120+121121+ fun convertCi8(): ByteArray {
122122+ require(imgInfo.indexed) { "ci8 mode requires indexed PNG" }
123123+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows)
124124+ val rows = readAllRowsIndexed()
125125+126126+ for (row in if (flipY) rows.reversed() else rows) {
127127+ buffer.put(row)
128128+ }
129129+130130+ return buffer.array()
131131+ }
132132+133133+ fun convertCi4(): ByteArray {
134134+ require(imgInfo.indexed) { "ci4 mode requires indexed PNG" }
135135+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2)
136136+ val rows = readAllRowsIndexed()
137137+138138+ for (row in if (flipY) rows.reversed() else rows) {
139139+ for (i in 0 until row.size step 2) {
140140+ val a = row[i].toInt() and 0xFF
141141+ val b = row[i + 1].toInt() and 0xFF
142142+ val byte = ((a shl 4) or b) and 0xFF
143143+ buffer.put(byte.toByte())
144144+ }
145145+ }
146146+147147+ return buffer.array()
148148+ }
149149+150150+ fun convertPalette(): ByteArray {
151151+ require(imgInfo.indexed) { "palette mode requires indexed PNG" }
152152+ val palette = reader.metadata.plte
153153+ val trans = reader.metadata.trns
154154+155155+ val buffer = ByteBuffer.allocate(palette.nentries * 2)
156156+ buffer.order(ByteOrder.BIG_ENDIAN)
157157+158158+ for (i in 0 until palette.nentries) {
159159+ val entry = palette.getEntry(i)
160160+ val r = (entry shr 16) and 0xFF
161161+ val g = (entry shr 8) and 0xFF
162162+ val b = entry and 0xFF
163163+ val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255
164164+165165+ if (a !in listOf(0, 255)) {
166166+ warn("alpha mask mode but translucent pixels used")
167167+ }
168168+169169+ val color = packColor(r, g, b, a)
170170+ buffer.putShort(color.toShort())
171171+ }
172172+173173+ return buffer.array()
174174+ }
175175+176176+ fun convertIa4(): ByteArray {
177177+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2)
178178+ val rows = readAllRows()
179179+180180+ for (row in if (flipY) rows.reversed() else rows) {
181181+ var i = 0
182182+ while (i < row.size) {
183183+ val r1 = row[i].toInt() and 0xFF
184184+ val g1 = row[i + 1].toInt() and 0xFF
185185+ val b1 = row[i + 2].toInt() and 0xFF
186186+ val a1 = row[i + 3].toInt() and 0xFF
187187+188188+ val r2 = row[i + 4].toInt() and 0xFF
189189+ val g2 = row[i + 5].toInt() and 0xFF
190190+ val b2 = row[i + 6].toInt() and 0xFF
191191+ val a2 = row[i + 7].toInt() and 0xFF
192192+193193+ val i1 = rgbToIntensity(r1, g1, b1) shr 5
194194+ val i2 = rgbToIntensity(r2, g2, b2) shr 5
195195+196196+ if (a1 !in listOf(0, 0xFF) || a2 !in listOf(0, 0xFF)) {
197197+ warn("alpha mask mode but translucent pixels used")
198198+ }
199199+ if (r1 != g1 || g1 != b1) warn("grayscale mode but image is not")
200200+ if (r2 != g2 || g2 != b2) warn("grayscale mode but image is not")
201201+202202+ val a1bit = if (a1 > 128) 1 else 0
203203+ val a2bit = if (a2 > 128) 1 else 0
204204+205205+ val h = (i1 shl 1) or a1bit
206206+ val l = (i2 shl 1) or a2bit
207207+ val byte = ((h shl 4) or l) and 0xFF
208208+ buffer.put(byte.toByte())
209209+210210+ i += 8
211211+ }
212212+ }
213213+214214+ return buffer.array()
215215+ }
216216+217217+ fun convertIa8(): ByteArray {
218218+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows)
219219+ val rows = readAllRows()
220220+221221+ for (row in if (flipY) rows.reversed() else rows) {
222222+ for (i in 0 until row.size step 4) {
223223+ val r = row[i].toInt() and 0xFF
224224+ val g = row[i + 1].toInt() and 0xFF
225225+ val b = row[i + 2].toInt() and 0xFF
226226+ val a = row[i + 3].toInt() and 0xFF
227227+228228+ val intensity = floor(15.0 * (rgbToIntensity(r, g, b) / 255.0)).toInt()
229229+ val alpha = floor(15.0 * (a / 255.0)).toInt()
230230+231231+ if (r != g || g != b) warn("grayscale mode but image is not")
232232+233233+ val byte = ((intensity shl 4) or alpha) and 0xFF
234234+ buffer.put(byte.toByte())
235235+ }
236236+ }
237237+238238+ return buffer.array()
239239+ }
240240+241241+ fun convertIa16(): ByteArray {
242242+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows * 2)
243243+ val rows = readAllRows()
244244+245245+ for (row in if (flipY) rows.reversed() else rows) {
246246+ for (i in 0 until row.size step 4) {
247247+ val r = row[i].toInt() and 0xFF
248248+ val g = row[i + 1].toInt() and 0xFF
249249+ val b = row[i + 2].toInt() and 0xFF
250250+ val a = row[i + 3].toInt() and 0xFF
251251+252252+ if (r != g || g != b) warn("grayscale mode but image is not")
253253+254254+ buffer.put(rgbToIntensity(r, g, b).toByte())
255255+ buffer.put(a.toByte())
256256+ }
257257+ }
258258+259259+ return buffer.array()
260260+ }
261261+262262+ fun convertI4(): ByteArray {
263263+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows / 2)
264264+ val rows = readAllRows()
265265+266266+ for (row in if (flipY) rows.reversed() else rows) {
267267+ var i = 0
268268+ while (i < row.size) {
269269+ val r1 = row[i].toInt() and 0xFF
270270+ val g1 = row[i + 1].toInt() and 0xFF
271271+ val b1 = row[i + 2].toInt() and 0xFF
272272+ val a1 = row[i + 3].toInt() and 0xFF
273273+274274+ val r2 = row[i + 4].toInt() and 0xFF
275275+ val g2 = row[i + 5].toInt() and 0xFF
276276+ val b2 = row[i + 6].toInt() and 0xFF
277277+ val a2 = row[i + 7].toInt() and 0xFF
278278+279279+ if (a1 != 0xFF || a2 != 0xFF) warn("discarding alpha channel")
280280+ if (r1 != g1 || g1 != b1) warn("grayscale mode but image is not")
281281+ if (r2 != g2 || g2 != b2) warn("grayscale mode but image is not")
282282+283283+ val i1 = floor(15.0 * (rgbToIntensity(r1, g1, b1) / 255.0)).toInt()
284284+ val i2 = floor(15.0 * (rgbToIntensity(r2, g2, b2) / 255.0)).toInt()
285285+286286+ val byte = ((i1 shl 4) or i2) and 0xFF
287287+ buffer.put(byte.toByte())
288288+289289+ i += 8
290290+ }
291291+ }
292292+293293+ return buffer.array()
294294+ }
295295+296296+ fun convertI8(): ByteArray {
297297+ val buffer = ByteBuffer.allocate(imgInfo.cols * imgInfo.rows)
298298+ val rows = readAllRows()
299299+300300+ for (row in if (flipY) rows.reversed() else rows) {
301301+ for (i in 0 until row.size step 4) {
302302+ val r = row[i].toInt() and 0xFF
303303+ val g = row[i + 1].toInt() and 0xFF
304304+ val b = row[i + 2].toInt() and 0xFF
305305+ val a = row[i + 3].toInt() and 0xFF
306306+307307+ if (a != 0xFF) warn("discarding alpha channel")
308308+ if (r != g || g != b) warn("grayscale mode but image is not")
309309+310310+ buffer.put(rgbToIntensity(r, g, b).toByte())
311311+ }
312312+ }
313313+314314+ return buffer.array()
315315+ }
316316+317317+ fun convertParty(): ByteArray {
318318+ require(imgInfo.indexed) { "party mode requires indexed PNG" }
319319+ val palette = reader.metadata.plte
320320+ val trans = reader.metadata.trns
321321+322322+ val paletteSize = palette.nentries * 2
323323+ val imageSize = imgInfo.cols * imgInfo.rows
324324+ val buffer = ByteBuffer.allocate(paletteSize + imageSize + 10)
325325+ buffer.order(ByteOrder.BIG_ENDIAN)
326326+327327+ // Write palette
328328+ for (i in 0 until palette.nentries) {
329329+ val entry = palette.getEntry(i)
330330+ val r = (entry shr 16) and 0xFF
331331+ val g = (entry shr 8) and 0xFF
332332+ val b = entry and 0xFF
333333+ val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255
334334+335335+ if (a !in listOf(0, 255)) {
336336+ warn("alpha mask mode but translucent pixels used")
337337+ }
338338+339339+ val color = packColor(r, g, b, a)
340340+ buffer.putShort(color.toShort())
341341+ }
342342+343343+ // Write ci8 data
344344+ val rows = readAllRowsIndexed()
345345+ for (row in if (flipY) rows.reversed() else rows) {
346346+ buffer.put(row)
347347+ }
348348+349349+ // Write padding
350350+ buffer.put(ByteArray(10))
351351+352352+ return buffer.array()
353353+ }
354354+355355+ fun convertBg(): ByteArray {
356356+ require(imgInfo.indexed) { "bg mode requires indexed PNG" }
357357+358358+ // Read main palette
359359+ data class PaletteData(
360360+ val plte: ar.com.hjg.pngj.chunks.PngChunkPLTE,
361361+ val trns: ar.com.hjg.pngj.chunks.PngChunkTRNS?
362362+ )
363363+364364+ val palettes = mutableListOf<PaletteData>()
365365+ val mainPalette = reader.metadata.plte
366366+ val mainTrans = reader.metadata.trns
367367+ palettes.add(PaletteData(mainPalette, mainTrans))
368368+369369+ // Read variant palettes (e.g., "background.1.png", "background.2.png")
370370+ val baseName = infile.nameWithoutExtension
371371+ val parentDir = infile.parentFile
372372+ var variantIndex = 1
373373+ while (true) {
374374+ val variantFile = File(parentDir, "$baseName.$variantIndex.png")
375375+ if (!variantFile.exists()) break
376376+377377+ val variantReader = PngReader(variantFile)
378378+ val variantPalette = variantReader.metadata.plte
379379+ val variantTrans = variantReader.metadata.trns
380380+ palettes.add(PaletteData(variantPalette, variantTrans))
381381+ variantReader.end()
382382+ variantIndex++
383383+ }
384384+385385+ val baseAddr = 0x80200000 // gBackgroundImage
386386+ val headersLen = 0x10 * palettes.size
387387+ val palettesLen = 0x200 * palettes.size
388388+ val imageSize = imgInfo.cols * imgInfo.rows
389389+390390+ val buffer = ByteBuffer.allocate(headersLen + palettesLen + imageSize)
391391+ buffer.order(ByteOrder.BIG_ENDIAN)
392392+393393+ // Write headers (struct BackgroundHeader)
394394+ for (i in palettes.indices) {
395395+ buffer.putInt((baseAddr + palettesLen + headersLen).toInt()) // raster offset
396396+ buffer.putInt((baseAddr + headersLen + 0x200 * i).toInt()) // palette offset
397397+ buffer.putShort(12) // startX
398398+ buffer.putShort(20) // startY
399399+ buffer.putShort(imgInfo.cols.toShort()) // width
400400+ buffer.putShort(imgInfo.rows.toShort()) // height
401401+ }
402402+403403+ // Write palettes
404404+ for (paletteData in palettes) {
405405+ val palette = paletteData.plte
406406+ val trans = paletteData.trns
407407+408408+ for (i in 0 until 256) {
409409+ if (i < palette.nentries) {
410410+ val entry = palette.getEntry(i)
411411+ val r = (entry shr 16) and 0xFF
412412+ val g = (entry shr 8) and 0xFF
413413+ val b = entry and 0xFF
414414+ val a = if (trans != null && i < trans.palletteAlpha.size) trans.palletteAlpha[i] else 255
415415+416416+ if (a !in listOf(0, 255)) {
417417+ warn("alpha mask mode but translucent pixels used")
418418+ }
419419+420420+ val color = packColor(r, g, b, a)
421421+ buffer.putShort(color.toShort())
422422+ } else {
423423+ buffer.putShort(0)
424424+ }
425425+ }
426426+ }
427427+428428+ // Write ci8 data
429429+ val rows = readAllRowsIndexed()
430430+ for (row in if (flipY) rows.reversed() else rows) {
431431+ buffer.put(row)
432432+ }
433433+434434+ return buffer.array()
435435+ }
436436+437437+ private fun readAllRows(): List<ByteArray> {
438438+ val rows = mutableListOf<ByteArray>()
439439+ for (row in 0 until imgInfo.rows) {
440440+ val line = reader.readRow() as ImageLineInt
441441+ val rgba = ByteArray(imgInfo.cols * 4)
442442+ val scanline = line.scanline
443443+444444+ for (col in 0 until imgInfo.cols) {
445445+ val idx = col * 4
446446+ val srcIdx = col * imgInfo.channels
447447+448448+ rgba[idx] = scanline[srcIdx].toByte() // R
449449+ rgba[idx + 1] = scanline[srcIdx + 1].toByte() // G
450450+ rgba[idx + 2] = scanline[srcIdx + 2].toByte() // B
451451+ rgba[idx + 3] = if (imgInfo.alpha) scanline[srcIdx + 3].toByte() else 0xFF.toByte() // A
452452+ }
453453+ rows.add(rgba)
454454+ }
455455+ return rows
456456+ }
457457+458458+ private fun readAllRowsIndexed(): List<ByteArray> {
459459+ val rows = mutableListOf<ByteArray>()
460460+ for (row in 0 until imgInfo.rows) {
461461+ val line = reader.readRow() as ImageLineInt
462462+ val indices = ByteArray(imgInfo.cols)
463463+ val scanline = line.scanline
464464+ for (i in 0 until imgInfo.cols) {
465465+ indices[i] = scanline[i].toByte()
466466+ }
467467+ rows.add(indices)
468468+ }
469469+ return rows
470470+ }
471471+}