iigs-game-engine/tools/fatdog2iigs.js

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]));
}