import { inflateSync } from 'node:zlib' import * as jpeg from 'jpeg-js' export interface SpriteImage { id: number name: string width: number height: number pixels: Buffer // RGBA } // ── DefineBitsLossless2 Parser (with alpha) ────────────────────── export function parseBitsLossless2(data: Buffer): { id: number width: number height: number pixels: Buffer } { let offset = 0 const id = data.readUInt16LE(offset) offset += 2 const format = data.readUInt8(offset) offset += 1 const width = data.readUInt16LE(offset) offset += 2 const height = data.readUInt16LE(offset) offset += 2 let colorTableSize = 0 if (format === 3) { colorTableSize = data.readUInt8(offset) + 1 offset += 1 } const compressed = data.subarray(offset) let decompressed: Buffer try { decompressed = inflateSync(compressed) } catch { return { id, width, height, pixels: Buffer.alloc(width * height * 4) } } const pixels = Buffer.alloc(width * height * 4) if (format === 5) { let srcOff = 0 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (srcOff + 3 >= decompressed.length) break const a = decompressed[srcOff] let r = decompressed[srcOff + 1] let g = decompressed[srcOff + 2] let b = decompressed[srcOff + 3] srcOff += 4 // Un-premultiply alpha if (a > 0 && a < 255) { r = Math.min(255, Math.round((r * 255) / a)) g = Math.min(255, Math.round((g * 255) / a)) b = Math.min(255, Math.round((b * 255) / a)) } const dstOff = (y * width + x) * 4 pixels[dstOff] = r pixels[dstOff + 1] = g pixels[dstOff + 2] = b pixels[dstOff + 3] = a } } } else if (format === 3) { const palette: number[][] = [] let palOff = 0 for (let i = 0; i < colorTableSize; i++) { const a = decompressed[palOff] let r = decompressed[palOff + 1] let g = decompressed[palOff + 2] let b = decompressed[palOff + 3] // Un-premultiply alpha for palette entries (same as format 5) if (a > 0 && a < 255) { r = Math.min(255, Math.round((r * 255) / a)) g = Math.min(255, Math.round((g * 255) / a)) b = Math.min(255, Math.round((b * 255) / a)) } palette.push([r, g, b, a]) palOff += 4 } const rowPadded = Math.ceil(width / 4) * 4 let srcOff = colorTableSize * 4 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = decompressed[srcOff + x] const color = palette[idx] || [0, 0, 0, 0] const dstOff = (y * width + x) * 4 pixels[dstOff] = color[0] pixels[dstOff + 1] = color[1] pixels[dstOff + 2] = color[2] pixels[dstOff + 3] = color[3] } srcOff += rowPadded } } return { id, width, height, pixels } } // ── DefineBitsLossless Parser (without alpha) ────────────────────── export function parseBitsLossless(data: Buffer): { id: number width: number height: number pixels: Buffer } { let offset = 0 const id = data.readUInt16LE(offset) offset += 2 const format = data.readUInt8(offset) offset += 1 const width = data.readUInt16LE(offset) offset += 2 const height = data.readUInt16LE(offset) offset += 2 let colorTableSize = 0 if (format === 3) { colorTableSize = data.readUInt8(offset) + 1 offset += 1 } const compressed = data.subarray(offset) let decompressed: Buffer try { decompressed = inflateSync(compressed) } catch { return { id, width, height, pixels: Buffer.alloc(width * height * 4) } } const pixels = Buffer.alloc(width * height * 4) if (format === 5) { let srcOff = 0 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (srcOff + 3 >= decompressed.length) break srcOff++ // skip padding byte const r = decompressed[srcOff++] const g = decompressed[srcOff++] const b = decompressed[srcOff++] const dstOff = (y * width + x) * 4 pixels[dstOff] = r pixels[dstOff + 1] = g pixels[dstOff + 2] = b pixels[dstOff + 3] = 255 } } } else if (format === 3) { const palette: number[][] = [] let palOff = 0 for (let i = 0; i < colorTableSize; i++) { const r = decompressed[palOff] const g = decompressed[palOff + 1] const b = decompressed[palOff + 2] palette.push([r, g, b, 255]) palOff += 3 } const rowPadded = Math.ceil(width / 4) * 4 let srcOff = colorTableSize * 3 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = decompressed[srcOff + x] const color = palette[idx] || [0, 0, 0, 255] const dstOff = (y * width + x) * 4 pixels[dstOff] = color[0] pixels[dstOff + 1] = color[1] pixels[dstOff + 2] = color[2] pixels[dstOff + 3] = color[3] } srcOff += rowPadded } } return { id, width, height, pixels } } // ── DefineBitsJpeg2 Parser (JPEG without alpha) ─────────────────── export function parseBitsJpeg2( data: Buffer, ): { id: number; width: number; height: number; pixels: Buffer } | null { if (data.length < 6) return null const id = data.readUInt16LE(0) let jpegData = data.subarray(2) // Strip erroneous JPEG header (FF D9 FF D1) if present if ( jpegData.length >= 4 && jpegData[0] === 0xff && jpegData[1] === 0xd9 && jpegData[2] === 0xff && jpegData[3] === 0xd1 ) { jpegData = jpegData.subarray(4) } const dims = parseJpegDimensions(jpegData) if (!dims) return null const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height) return { id, width: dims.width, height: dims.height, pixels } } // ── DefineBitsJpeg3 Parser (JPEG with alpha channel) ────────────── export function parseBitsJpeg3( data: Buffer, ): { id: number; width: number; height: number; pixels: Buffer } | null { if (data.length < 6) return null const id = data.readUInt16LE(0) const alphaDataOffset = data.readUInt32LE(2) let jpegData = data.subarray(6, 6 + alphaDataOffset) // Strip erroneous JPEG header (FF D9 FF D1) if present if ( jpegData.length >= 4 && jpegData[0] === 0xff && jpegData[1] === 0xd9 && jpegData[2] === 0xff && jpegData[3] === 0xd1 ) { jpegData = jpegData.subarray(4) } const dims = parseJpegDimensions(jpegData) if (!dims) return null const pixels = decodeJpegToRGBA(jpegData, dims.width, dims.height) // Apply zlib-compressed alpha channel const compressedAlpha = data.subarray(6 + alphaDataOffset) if (compressedAlpha.length > 0) { try { const alpha = inflateSync(compressedAlpha) const pixelCount = dims.width * dims.height for (let i = 0; i < pixelCount && i < alpha.length; i++) { pixels[i * 4 + 3] = alpha[i] } } catch { // Alpha decompression failed, keep fully opaque } } return { id, width: dims.width, height: dims.height, pixels } } // ── JPEG Dimension Parser (reads SOF0/SOF2 markers) ─────────────── export function parseJpegDimensions(jpeg: Buffer): { width: number; height: number } | null { let offset = 0 while (offset < jpeg.length - 1) { if (jpeg[offset] !== 0xff) { offset++ continue } const marker = jpeg[offset + 1] offset += 2 // SOF0 (Baseline), SOF1 (Extended), SOF2 (Progressive) if (marker >= 0xc0 && marker <= 0xc2) { if (offset + 7 > jpeg.length) return null // Skip length (2 bytes) + precision (1 byte) const height = jpeg.readUInt16BE(offset + 3) const width = jpeg.readUInt16BE(offset + 5) return { width, height } } // Skip non-SOF markers with length field if (marker === 0xd8 || marker === 0xd9) continue // SOI / EOI if (marker >= 0xd0 && marker <= 0xd7) continue // RST markers if (offset + 2 > jpeg.length) break const segLen = jpeg.readUInt16BE(offset) offset += segLen } return null } // ── JPEG to RGBA decoder using jpeg-js ────────────────────────────── export function decodeJpegToRGBA(jpegData: Buffer, width: number, height: number): Buffer { try { const decoded = jpeg.decode(jpegData, { useTArray: true, formatAsRGBA: true }) // jpeg-js returns RGBA data directly return Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength) } catch { // Fallback: transparent pixels if JPEG decode fails return Buffer.alloc(width * height * 4) } }