Converts one or multiple image(s) into Nintendo Virtual Boy compatible character (tile) and BGMap (tilemap) data, optionally applying quantization, tileset optimization and/or data compression.
Supported source formats are png and jpeg.
Source Image(s)
─▶ quantize
─▶ [optimize tileset]
─▶ cut tiles
─▶ deduplicate
─▶ build map
─▶ [compress]
─▶ ✨ result
Any JPG or PNG the decoder understands is accepted: truecolor, grayscale, or palette, with or without alpha, 8 or 16 bits per channel, interlaced or not. Source images do not have to be drawn in the target palette; quantization handles that.
Along the way, some adjustments are silently applied to pixel data:
- Width is cropped to 512 px, the widest the hardware supports. Height is never cropped to support vertical spritesheets.
- Both axes are padded up to a whole number of 8 px tiles.
- Fully transparent pixels become black, which would be rendered transparent on a Virtual Boy. This wins over the RGB behind them and over
invert. Partially transparent pixels are quantized by their color like any other.
./vb-image-converter.js <files-or-folder>... [options]
Releases ship a single self-contained vb-image-converter.js. Runs on Node 18 or newer:
node ./vb-image-converter.js <files-or-folder>... [options]
Downloads are not executable, so to run it directly, set the executable bit:
chmod +x vb-image-converter.js
./vb-image-converter.js <files-or-folder>... [options]
Folders contribute their .png files sorted by name. Options mirror the config detailed further below (--no-map, --no-reduce, --no-flip, --shared, --color-mode, --dither, --distance, --invert, --compress-tiles, --max-tiles, --animation, --individual-files, --workers). Output goes to stdout, or to --out <file>; warnings go to stderr. --help for more details.
By default it emits a C file ready to compile against the engine:
vb-image-converter hero.png --name Hero --out Hero.c
//——————————————————————————————————————————————————————————————————————————
//
// Hero
// * 7 tiles, reduced by non-unique and flipped tiles, not compressed
// * 4x3 map
// Size: 116 + 24 = 140 byte
//
//——————————————————————————————————————————————————————————————————————————
const uint32 HeroTiles[] __attribute__((aligned(4))) =
{
0x00000000,
0x00000000,0x00FF00FF,0xAA55AA55,0x55005500,0xFFAAFFAA,[…]
};
const uint16 HeroMap[] __attribute__((aligned(4))) =
{
0x0000,0x0001,0x0002,0x0000,0x0000,0x0003,[…]
};The first word of the tileset is the compression flag an engine should read: 1 when the tileset is RLE compressed, 0 otherwise.
--section <rom|data|exp> adds the matching __attribute((section(...))). Animations split across files also emit a …TilesFrameOffsets array.
--format json returns the raw ConversionResult instead.
import { convert, ImageConverterPool } from 'vb-image-converter';The main entry needs fs and child_process. Bundlers targeting the browser resolve the browser field instead, which exposes only the parts that are pure computation — the types and constants, the tile format helpers (packTile, unpackTile, flipTilePixels), the RLE codec, and renderToPixels. Convert on the server or in a worker; use the browser entry to read the result.
ConvertRequest.images is a list of images, each given either as a filesystem path or as bytes:
// pass an image's filesystem path
{ name: 'frame0', path: '/path/to/frame0.png' }
// ...or its content as an Uint8Array
{ name: 'frame0', data: someUint8Array }name identifies the image in the result and, for animations, orders the frames — results are sorted by it, not by the order you pass them in.
Details further below.
interface ConversionResult {
tiles: {
name: string // name of the tileset
count: number // total amount of 8x8 tiles
data: string[] // 8-digit uppercase hex, 4 words per tile
compressionRatio?: number // by how much percent data was RLE compressed
frameOffsets?: number[] // for animations, offsets of each frame in tile data
}
maps: {
name: string // name of the tilemap
data: string[] // 4-digit uppercase hex, one per cell, row major
width: number // width of the tile map, in tiles
height: number // height of the tile map, in tiles
}[]
animation: {
frames?: number, // amount of frames the animation has
largestFrame?: number // file count of the largest frame in an animation
},
warnings: string[] // warnings thrown during conversion
optimization?: {
tolerance: number, // the tolerance value applied in tileset optimization
tilesBefore: number, // tiles count before tileset optimization
tilesAfter: number // tiles count after tileset optimization
}
}Example, a still image with a 4x3 tile map:
data is always a string[]. The declared string[] | string union exists for callers that store the arrays compressed elsewhere; nothing here returns a bare string.
Four uint32 words per tile, in order. A word holds two rows of 8 pixels at 2 bits each: the upper row in the low half, and within a row the leftmost pixel in the least significant bits.
row 0 = 3,3,0,0,1,1,2,2 -> word 0 = 0x0000A50F
row 1 = 1,1,1,1,0,0,0,0 (same tile) -> word 0 = 0x0055A50F
Palette indices run 0 (black) to 3 (brightest).
One uint16 per cell, matching the VIP's BGMap cell layout:
| bits | meaning |
|---|---|
| 0–10 | character index (0–2047) |
| 11 | unused |
| 12 | vertical flip |
| 13 | horizontal flip |
| 14–15 | unused (palette select on hardware) |
tile 5, unflipped -> 0x0005
tile 5, horizontal flip -> 0x2005
tile 5, both -> 0x3005
Only 2048 characters are addressable. Exceeding that is reported in warnings rather than silently truncated — see Warnings.
| config | tiles.name |
maps |
|---|---|---|
| still image | the image's name | one, named after the image |
| shared tileset | the request's name |
one per image, named after each image |
| animation, individual files | the request's name |
one merged map, named after the request |
| animation, spritesheet | the image's name | one, named after the image |
animation.frames is config.animation.frames when set, otherwise the number of maps produced. largestFrame is only present for individual-file animations, where it reports the biggest per-frame tile count.
tiles.frameOffsets gives the index into tiles.data at which each frame's tiles begin, 1-based to leave room for the compression flag. It is present for individual-file animations, and for RLE-compressed spritesheets:
{ "count": 16, "data": [ …64 words… ], "frameOffsets": [1, 17, 33, 49] }compressionRatio is the size change as a percentage, negative when the data got smaller. RLE that would inflate the data is discarded: the original uncompressed data is returned, frameOffsets is dropped, and the ratio is left positive so you can see it was rejected.
{ "count": 8, "data": ["F0F0F0F0", "F5F5F5F5", "FAFAFAFA", "FFFFFFFF"], "compressionRatio": -87.5 }RLE runs over 4-bit nibbles: each byte of a compressed word is a run length minus one in the high nibble, and the pixel-pair value in the low nibble. decompressTiles reverses it, and renderToPixels takes the compression type directly so previews need no manual step.
Non-fatal problems, as human-readable strings. Conversion still returns a result. Currently raised for a tileset larger than the 2048 addressable characters, and for optimization that was skipped or could not meet its budget.
ConvertRequest.config:
| option | type | meaning |
|---|---|---|
shared |
boolean |
Build one tileset spanning every input image, with a map each. false means each image gets its own tileset. |
compression |
'none' | 'rle' |
Optionally compress the tile data. |
optimization |
see below | Optional lossy reduction to a tile budget. |
| option | type | meaning |
|---|---|---|
generate |
boolean |
Produce map data. false emits every tile in order with no map and no deduplication. |
reduce.unique |
boolean |
Collapse identical tiles. |
reduce.flipped |
boolean |
Also collapse tiles that match once flipped, setting the cell's flip bits. Implies unique. |
compression |
'none' | 'rle' |
Reserved; map compression is not implemented yet. |
Deduplication is skipped entirely for spritesheet animations (isAnimation without individualFiles), because frames have to stay at fixed tile offsets.
| option | type | meaning |
|---|---|---|
isAnimation |
boolean |
Treat the input as animated. |
individualFiles |
boolean |
One file per frame. false means a single spritesheet. |
frames |
number |
Frame count. Used to split spritesheets and reported back in the result. |
| option | type | default | meaning |
|---|---|---|---|
imageQuantizationAlgorithm |
see below | 'nearest' |
'nearest' for no dithering, or an error-diffusion kernel. |
distanceCalculator |
see below | 'euclidean' |
How color distance is measured when choosing a shade. |
minimumColorDistanceToDither |
number |
0 |
Below this distance, dithering is suppressed. |
serpentine |
boolean |
false |
Alternate scan direction between rows while dithering. |
invert |
boolean |
false |
Mirror the palette, so black becomes brightest. Applies to the source area only, not to padding. |
Quantization algorithms: nearest, floyd-steinberg, false-floyd-steinberg, stucki, atkinson, jarvis, burkes, sierra, two-sierra, sierra-lite, riemersma (falls back to floyd-steinberg).
Distance formulas: euclidean, euclidean-bt709, euclidean-bt709-noalpha, manhattan, manhattan-bt709, manhattan-nommyde, color-metric, cie94-graphic-arts, cie94-textiles, ciede2000, pngquant.
The first six take a fast path that skips the quantization library entirely and are several times quicker. The perceptual formulas do pick different shades, so they are not interchangeable — but on a palette that only varies in one channel the difference rarely earns its cost. Prefer a euclidean or manhattan variant unless you have compared them on your own art.
| value | meaning |
|---|---|
ColorMode.Default (0) |
4 shades. |
ColorMode.FrameBlend (1) |
"HiColor": quantizes to 7 shades, then emits two stacked frames the hardware alternates between. The output is twice as tall as the source. |
Reduces a group of images to at most maxTiles unique tiles by treating near-identical tiles as duplicates and redrawing the images from the reduced set. Lossy, and a no-op when the images already fit.
For an animation held in individual files the budget is applied to individual frames, since every frame carries its own tileset and only one of them is in character memory at a time. The emitted tileset still holds all frames back to back, with tiles.frameOffsets marking where each one starts.
config.tileset.optimization = { maxTiles: 512 };| option | type | default | meaning |
|---|---|---|---|
maxTiles |
number |
— | Tile budget. Ignored when <= 0. |
maxTolerance |
number |
128 |
Ceiling for the tolerance search. |
toleranceStep |
number |
0.05 |
Granularity of the search. |
matchFlips |
boolean |
map.reduce.flipped |
Treat flipped tiles as duplicates while optimizing. |
Tolerance is summed absolute channel difference per tile, divided by 1024. The search raises it until the budget is met and reports what it settled on:
"optimization": { "tolerance": 3.1, "tilesBefore": 361, "tilesAfter": 63 }Three things to know:
- The budget applies to the whole group - a shared tileset is optimized against its combined tile count - except for animations in individual files, where it applies per frame and
tilesBefore/tilesAfterreport the largest one. - Optimization is skipped where the tiling stage does not deduplicate, i.e. without
map.generate, withoutmap.reduce, and for spritesheet animations. Merging tiles cannot lower a count of one tile per cell, and the redraw would be lossy for nothing. The reason lands inwarnings. matchFlipsshould matchmap.reduce.flipped. Optimizing with flips but converting without them can land above the budget again.- The tolerance found is the lowest on the search grid that fits, not necessarily the lowest that exists.
If the budget cannot be met within maxTolerance, the images are left untouched
and the reason lands in warnings.
convert() is synchronous and CPU bound; a large spritesheet takes a while. ImageConverterPool runs it in forked worker processes instead:
const pool = new ImageConverterPool({ size: 3 });
const result = await pool.convert(request);
const previewPng = await pool.quantize(source, settings, colorMode);
await pool.dispose();Workers spawn on demand, handle one conversion at a time, and shut down after idleTimeoutMs. A worker that dies mid-conversion rejects that conversion and is replaced; the pool stays usable.
| option | default | meaning |
|---|---|---|
size |
min(4, cpus - 1) |
Number of worker processes. |
workerPath |
next to the package | Path to the worker entry. Needed when the host bundles this package — also settable through VUENGINE_IMAGE_CONVERTER_WORKER. |
execPath / execArgv |
current node | Interpreter to fork. |
idleTimeoutMs |
30000 |
Shut down an idle worker after this long. 0 keeps them alive. |
taskTimeoutMs |
0 |
Fail a conversion that has not returned in time. 0 disables. |
env |
inherited | Extra environment for workers. |
ELECTRON_RUN_AS_NODE is set automatically, so forking works when the host application is Electron.
If you bundle this package, the worker needs to survive as a real file for child_process.fork to point at. Emit lib/worker.js as its own bundle entry and pass its output path as workerPath.
quantizeImage(source, settings, colorMode)— runs only the pre-processing stage, returning{ name, width, height, pixels }with one byte per pixel.quantizeToIndexedPng(source, settings, colorMode)— the same as an indexed PNG, for previews.renderToPixels(tiles, map, compression?)— expands a result back into anumber[][]pixel matrix, for drawing to a canvas.compressTiles/decompressTiles— the RLE codec on its own.packTile/unpackTile/flipTilePixels— the tile format on its own.- Constants:
TILE_WIDTH,TILE_HEIGHT,WORDS_PER_TILE,MAX_IMAGE_WIDTH,MAX_CHARS,V_FLIP_MASK,H_FLIP_MASK,PALETTE_R_VALUES, etc.
npm run build && npm test
{ "tiles": { "name": "logo", "count": 4, "data": ["00000000", "00000000", "00000000", "00000000", "55FF55FF", […]] }, "maps": [ { "name": "logo", "width": 4, "height": 3, "data": ["0001", "2001", "0002", "3002", […]] } ], "animation": { "frames": 1 }, "warnings": [] }