Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Virtual Boy Image Converter

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.

Usage (CLI)

./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.

Usage (in applications)

Entry points

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.

Input

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.

Output

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:

{
  "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": []
}

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.

Tile data

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).

Map cells

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.

Which name goes where

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

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

Compression

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.

Warnings

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.

Options

ConvertRequest.config:

tileset

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.

map

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.

animation

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.

imageProcessingSettings

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.

colorMode

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.

Tileset optimization

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/tilesAfter report the largest one.
  • Optimization is skipped where the tiling stage does not deduplicate, i.e. without map.generate, without map.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 in warnings.
  • matchFlips should match map.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.

Converting off the main thread

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.

Bundled hosts

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.

Other exports

  • 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 a number[][] 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.

Tests

npm run build && npm test

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages