mirror of
https://github.com/lscharen/iigs-game-engine.git
synced 2024-11-21 20:30:50 +00:00
353 lines
10 KiB
JavaScript
353 lines
10 KiB
JavaScript
|
// Utility to convert fatdog's palette-encoded images
|
||
|
//
|
||
|
// The image has an extra 17 columns on the right=hand side.
|
||
|
//
|
||
|
// 1. The first column encodes SCB bytes values using a mapping from the Dreamgrafix palette
|
||
|
// (black) $000000 -> $0
|
||
|
// (red) $FF0000 -> $1
|
||
|
// (dk. blue) $001177 -> $2
|
||
|
// (purple) $AA11DD -> $3
|
||
|
// (dk. green) $007711 -> $4
|
||
|
// (dk. grey) $554444 -> $5
|
||
|
// (blue) $0000FF -> $6
|
||
|
// (lt. blue) $3399EE -> $7
|
||
|
// (brown) $664400 -> $8
|
||
|
// (orange) $FF6600 -> $9
|
||
|
// (lt. grey) $AA9999 -> $A
|
||
|
// (pink) $FF9988 -> $B
|
||
|
// (green) $00FF00 -> $C
|
||
|
// (yellow) $FFDD00 -> $D
|
||
|
// (lt. green) $44FF99 -> $E
|
||
|
// (white) $FFFFFF -> $F
|
||
|
//
|
||
|
// 2. The 16 columns of the top row encode the mapping of pcture colors to palette indexes
|
||
|
// 3. A 16x16 block of color in the lower-right represents the actual IIgs palette data
|
||
|
|
||
|
const fs = require('fs').promises;
|
||
|
const PNG = require("pngjs").PNG;
|
||
|
const process = require('process');
|
||
|
const { Buffer } = require('buffer');
|
||
|
const StringBuilder = require('string-builder');
|
||
|
|
||
|
const DreamgraphixPalette = [
|
||
|
// Red, Green, Blue
|
||
|
[0x00, 0x00, 0x00],
|
||
|
[0xF0, 0x00, 0x00],
|
||
|
[0x00, 0x10, 0x70],
|
||
|
[0xB0, 0x10, 0xE0],
|
||
|
[0x00, 0x70, 0x10],
|
||
|
[0x50, 0x40, 0x40],
|
||
|
[0x00, 0x00, 0xF0],
|
||
|
[0x30, 0xA0, 0xF0],
|
||
|
[0x60, 0x40, 0x00],
|
||
|
[0xF0, 0x60, 0x00],
|
||
|
[0xB0, 0xA0, 0xA0],
|
||
|
[0xF0, 0xA0, 0x80],
|
||
|
[0x00, 0xF0, 0x00],
|
||
|
[0xF0, 0xE0, 0x00],
|
||
|
[0x40, 0xF0, 0xA0],
|
||
|
[0xF0, 0xF0, 0xF0]
|
||
|
];
|
||
|
|
||
|
const DreamgraphixPalette2 = [
|
||
|
// Red, Green, Blue
|
||
|
[0x00, 0x00, 0x00],
|
||
|
[0xFF, 0x00, 0x00],
|
||
|
[0x00, 0x11, 0x77],
|
||
|
[0xAA, 0x11, 0xDD],
|
||
|
[0x00, 0x77, 0x11],
|
||
|
[0x55, 0x44, 0x44],
|
||
|
[0x00, 0x00, 0xFF],
|
||
|
[0x33, 0x99, 0xEE],
|
||
|
[0x66, 0x44, 0x00],
|
||
|
[0xFF, 0x66, 0x00],
|
||
|
[0xAA, 0x99, 0x99],
|
||
|
[0xFF, 0x99, 0x88],
|
||
|
[0x00, 0xFF, 0x00],
|
||
|
[0xFF, 0xDD, 0x00],
|
||
|
[0x44, 0xFF, 0x99],
|
||
|
[0xFF, 0xFF, 0xFF]
|
||
|
];
|
||
|
|
||
|
main(process.argv.slice(2)).then(
|
||
|
() => process.exit(0),
|
||
|
(e) => {
|
||
|
console.error(e);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
);
|
||
|
|
||
|
function findColorIndexInPalette(pixel, palette) {
|
||
|
for (let i = 0; i < palette.length; i += 1) {
|
||
|
const bands = 3;
|
||
|
const color = palette[i].slice(0, bands); // Handle RGB or RGBA
|
||
|
if (color[0] === pixel.red && color[1] === pixel.green && color[2] === pixel.blue) {
|
||
|
return i;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
function findColorIndex(png, pixel) {
|
||
|
const index = findColorIndexInPalette(pixel, png.palette);
|
||
|
return index + (index > -1) ? startIndex : 0;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Convert PNG to IIgs memory order; arbitrary size
|
||
|
*/
|
||
|
function pngRectToIIgsBuff(png, x0, y0, width, height, colorTable) {
|
||
|
const buff = Buffer.alloc(height * (width / 2), 0);
|
||
|
for (let y = 0; y < height; y += 1) {
|
||
|
for (let x = 0; x < width; x += 1) {
|
||
|
// Index into the IIgs memory buffer
|
||
|
const j = y * (width / 2) + Math.floor(x / 2);
|
||
|
|
||
|
let index = 0;
|
||
|
|
||
|
// Make sure the source pixel is in bounds
|
||
|
if ((y + y0) < png.height && (x + x0) < png.width) {
|
||
|
const pixel = getPixel(png, x + x0, y + y0);
|
||
|
index = findColorIndexInPalette(pixel, colorTable);
|
||
|
}
|
||
|
|
||
|
if (index > 15) {
|
||
|
console.warn('; Pixel index greater than 15. Skipping...');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (x % 2 === 0) {
|
||
|
buff[j] = 16 * index;
|
||
|
}
|
||
|
else {
|
||
|
buff[j] = buff[j] | index;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return buff;
|
||
|
}
|
||
|
|
||
|
function paletteToIIgs(palette) {
|
||
|
const r = Math.round(palette[0] / 17);
|
||
|
const g = Math.round(palette[1] / 17);
|
||
|
const b = Math.round(palette[2] / 17);
|
||
|
|
||
|
return '0' + r.toString(16).toUpperCase() + g.toString(16).toUpperCase() + b.toString(16).toUpperCase();
|
||
|
}
|
||
|
|
||
|
function getArg(argv, arg, fn, defaultValue) {
|
||
|
for (let i = 0; i < argv.length; i += 1) {
|
||
|
if (argv[i] === arg) {
|
||
|
if (fn) {
|
||
|
return fn(argv[i+1]);
|
||
|
}
|
||
|
return true; // Return true if the argument was found
|
||
|
}
|
||
|
}
|
||
|
return defaultValue;
|
||
|
}
|
||
|
|
||
|
async function readPNG(filename) {
|
||
|
const data = await fs.readFile(filename);
|
||
|
return PNG.sync.read(data);
|
||
|
}
|
||
|
|
||
|
function getPixel(png, x, y) {
|
||
|
if (x < 0 || x >= png.width) throw new Error(`x is out of range`);
|
||
|
if (y < 0 || y >= png.height) throw new Error(`y is out of range`);
|
||
|
|
||
|
const index = 4 * (png.width * y + x);
|
||
|
const rgba = png.data.slice(index, index + 4);
|
||
|
return {
|
||
|
red: rgba[0],
|
||
|
green: rgba[1],
|
||
|
blue: rgba[2],
|
||
|
toString: function() {
|
||
|
return [this.red, this.green, this.blue].map(c => toHex(c).toUpperCase());
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function extractScanControlBytes(png) {
|
||
|
const data = png.data;
|
||
|
const column = png.width - 17;
|
||
|
const controlBytes = [];
|
||
|
|
||
|
const size = png.width * png.height;
|
||
|
comment(`Image size: ${size} pixels`);
|
||
|
comment(`Data size: ${data.length} bytes`);
|
||
|
for (let row = 0; row < png.height; row += 1) {
|
||
|
const pixel = getPixel(png, column, row);
|
||
|
const index = findColorIndexInPalette(pixel, DreamgraphixPalette);
|
||
|
|
||
|
if (index == -1) {
|
||
|
console.warn(`Could not find match for color: ${pixel.toString()}`);
|
||
|
}
|
||
|
|
||
|
controlBytes.push(index);
|
||
|
}
|
||
|
|
||
|
return controlBytes;
|
||
|
}
|
||
|
|
||
|
function extractColorToIndexMap(png) {
|
||
|
const column = png.width - 16;
|
||
|
const color2index = {};
|
||
|
|
||
|
for (let i = 0; i < 16; i += 1) {
|
||
|
const pixel = getPixel(png, column + i, 0);
|
||
|
const color = (pixel.red << 16) + (pixel.green << 8) + pixel.blue;
|
||
|
color2index[color] = i;
|
||
|
}
|
||
|
|
||
|
return color2index;
|
||
|
}
|
||
|
|
||
|
function extractPalettes(png) {
|
||
|
const column = png.width - 16;
|
||
|
const row = png.height - 16;
|
||
|
const palettes = [];
|
||
|
|
||
|
for (let y = 0; y < 16; y += 1) {
|
||
|
const palette = [];
|
||
|
for (let x = 0; x < 16; x += 1) {
|
||
|
const pixel = getPixel(png, column + x, row + y);
|
||
|
const { red, green, blue } = pixel;
|
||
|
const fourBitColor = ((red & 0xF0) << 4) | (green & 0xF0) | ((blue & 0xF0) >> 4);
|
||
|
palette.push(fourBitColor);
|
||
|
}
|
||
|
palettes.push(palette);
|
||
|
}
|
||
|
|
||
|
return palettes;
|
||
|
}
|
||
|
|
||
|
const PNGColorTypes = {
|
||
|
0: 'grayscale, no alpha',
|
||
|
2: 'color, no alpha',
|
||
|
4: 'grayscale, w/alpha',
|
||
|
6: 'color w/alpha'
|
||
|
}
|
||
|
|
||
|
function dumpPNGInfo(filename, png) {
|
||
|
comment(`Loaded PNG file from ${filename}`);
|
||
|
comment(` Width: ${png.width}`);
|
||
|
comment(` Height: ${png.height}`);
|
||
|
comment(` Color Type : ${PNGColorTypes[png.colorType] || png.colorType}`);
|
||
|
comment(` Bit Depth: ${png.bitDepth}`);
|
||
|
comment(` Palette: ${png.palette ? 'Yes' : 'No'}`);
|
||
|
}
|
||
|
|
||
|
async function main(argv) {
|
||
|
try {
|
||
|
const filename = argv[0];
|
||
|
|
||
|
const outputFile = getArg(argv, '--output', x => x, 'output.bin');
|
||
|
|
||
|
const png = await readPNG(filename);
|
||
|
dumpPNGInfo(filename, png);
|
||
|
|
||
|
// Get the SCB encoded bytes
|
||
|
const SCBs = extractScanControlBytes(png);
|
||
|
writeScanControlBytes(SCBs);
|
||
|
|
||
|
// Get the greyscale map
|
||
|
const color2index = extractColorToIndexMap(png);
|
||
|
writeIndexMap(color2index);
|
||
|
|
||
|
// Get the palette data
|
||
|
const iigsPalettes = extractPalettes(png);
|
||
|
writePalettes(iigsPalettes);
|
||
|
|
||
|
// Run through the actual PNG image data and map using the colo2index map
|
||
|
const targetWidth = png.width - 17;
|
||
|
const targetHeight = png.height;
|
||
|
const buffer = pngRectToIIgsBuff(png, 0, 0, targetWidth, targetHeight, color2index);
|
||
|
|
||
|
await writeBinaryImageOutput(outputFile, buffer, targetWidth, targetHeight);
|
||
|
} catch (e) {
|
||
|
console.log(`; ${e}`);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function writePalettes(iigsPalettes) {
|
||
|
console.log('palettes');
|
||
|
for (let i = 0; i < iigsPalettes.length; i += 1) {
|
||
|
console.log(`palette_${i} dw ${iigsPalettes[i].map(p => '$' + toHex(p, 4))}`);
|
||
|
}
|
||
|
}
|
||
|
function writeIndexMap(color2index) {
|
||
|
comment('Color to 4-bit color index mapping');
|
||
|
for (const color of Object.keys(color2index)) {
|
||
|
comment('$' + Number(color).toString(16).padStart(6, '0') + ' -> ' + color2index[color]);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function writeScanControlBytes(SCBs) {
|
||
|
console.log('SCB');
|
||
|
for (let i = 0; i < SCBs.length; i += 8) {
|
||
|
console.log('\tdw\t' + SCBs.slice(i, i+8).map(n => '$' + toHex(n)).join(','));
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
function comment(str) {
|
||
|
console.log(`; ${str}`);
|
||
|
}
|
||
|
|
||
|
function reverse(str) {
|
||
|
return [...str].reverse().join(''); // use [...str] instead of split as it is unicode-aware.
|
||
|
}
|
||
|
|
||
|
function toHex(h, len=2) {
|
||
|
return h.toString(16).padStart(len, '0');
|
||
|
}
|
||
|
|
||
|
function swap(hex) {
|
||
|
const high = hex & 0xF0;
|
||
|
const low = hex & 0x0F;
|
||
|
|
||
|
return (high >> 4) | (low << 4);
|
||
|
}
|
||
|
|
||
|
function toMask(hex, transparentIndex) {
|
||
|
if (transparentIndex === -1) {
|
||
|
return 0;
|
||
|
}
|
||
|
|
||
|
const indexHigh = (transparentIndex & 0xF) << 4;
|
||
|
const indexLow = (transparentIndex & 0xF);
|
||
|
let mask = 0;
|
||
|
if ((hex & 0xF0) === indexHigh) {
|
||
|
mask = mask | 0xF0;
|
||
|
}
|
||
|
if ((hex & 0x0F) === indexLow) {
|
||
|
mask = mask | 0x0F;
|
||
|
}
|
||
|
return mask;
|
||
|
}
|
||
|
|
||
|
async function writeBinaryImageOutput(filename, buff, width, height) {
|
||
|
// Write a small header. This is useful and avoids triggering a sparse file load
|
||
|
// bug when the first block of the file on the GS/OS drive is sparse.
|
||
|
|
||
|
// Put the ASCII text of "GTERAW" in the first 6 bytes followed by a transparency
|
||
|
// indicator and then the width of the image (in bytes) and the height (in lines)
|
||
|
const header = Buffer.alloc(12);
|
||
|
header.write('GTERAW', 'latin1');
|
||
|
|
||
|
// Use the special value $A5A5 to identify no transparency
|
||
|
if (typeof transparentColor !== 'number') {
|
||
|
header.writeUInt16LE(0xA5A5);
|
||
|
} else {
|
||
|
header.writeUInt16LE(0x1111 * transparentColor, 6);
|
||
|
}
|
||
|
header.writeUInt16LE(width);
|
||
|
header.writeUInt16LE(height);
|
||
|
|
||
|
await fs.writeFile(filename, Buffer.concat([header, buff]));
|
||
|
}
|