The VSP image format

VSP is a 4-bit planar image format designed for the graphics hardware of the PC98. It was used in pre-System 3.5 games, and continues to be supported in System 3.x (mostly to support ports of older games).

struct vsp_file {
    LE16 x0;          // x-coordinate (/8) of the top-left corner
    LE16 y0;          // y-coordinate of the top-left corner
    LE16 x1;          // x-coordinate (/8) of the bottom-left corner
    LE16 y1;          // y-coordinate of the bottom-left corner
    BYTE r;           // ???
    BYTE bank;        // default palette bank
    BYTE palette[48]; // the palette
    BYTE image[];     // the image data
};

The structure of a VSP file is as above. Note that the x-coordinates in the header should be multiplied by 8 to get the real display coordinates.

The image data itself is structured in 8 pixel wide columns, where each column contains the data for 4 planes, one after another. The reason each column is 8 pixels wide is that each bit in every byte represents part of the color index for a single pixel.

struct column {
    BYTE planes[4][?];
};

The data within a column-plane is compressed using a run-length encoding scheme and a few other tricks. Refer to the commented source code below (taken from xsystem35) to see how it works. The functions vsp_extract and vsp_get_palette convert the VSP image data to an 8-bit indexed bitmap.

struct cg_palette {
    uint8_t red[16];
    uint8_t green[16];
    uint8_t blue[16];
};

struct cg {
    int width;             // width of the CG
    int height;            // height of the CG
    uint8_t *pic;          // the pixel data
    struct cg_palette pal; // the color palette
};

struct vsp_header {
    int x;       // display location x
    int y;       // display location y
    int width;   // width
    int height;  // height
    int bank;    // default palette bank
    int palette; // pointer to palette
    int pixel;   // pointer to pixel data
};

static void vsp_read_header(struct vsp_header *vsp, uint8_t *data)
{
    vsp->x = LittleEndian_getW(data, 0);
    vsp->y = LittleEndian_getW(data, 2);
    vsp->width = LittleEndian_getW(data, 4) - vsp->x;
    vsp->height = LittleEndian_getW(data, 6) - vsp->y;
    vsp->bank = data[9];
    vsp->palette = 0x0a;
    vsp->pixel = 0x3a;
}

static void vsp_get_palette(struct cg_palette *pal, uint8_t *b)
{
    for (int i = 0; i < 16; i++) {
        pal->blue[i]  = b[i * 3 + 0] << 4;
        pal->red[i]   = b[i * 3 + 1] << 4;
        pal->green[i] = b[i * 3 + 2] << 4;
    }
}

/*
 * Extraction buffers. The planar image data is decompressed and read into
 * these buffers before being converted to a chunky format.
 */
static uint8_t _bc[4][480];
static uint8_t _bp[4][480];
static uint8_t *bc[4]; // the current buffer
static uint8_t *bp[4]; // the previous buffer

/*
 * Convert VSP planar image data to 8-bit indexed bitmap.
 */
static void vsp_extract(struct vsp_header *vsp, uint8_t *pic, uint8_t *b)
{
    uint8_t b0, b1, b2, b3, mask = 0;
    uint8_t *bt;
    int n;

    bp[0] = _bp[0]; bc[0] = _bc[0];
    bp[1] = _bp[1]; bc[1] = _bc[1];
    bp[2] = _bp[2]; bc[2] = _bc[2];
    bp[3] = _bp[3]; bc[3] = _bc[3];

    // for each column...
    // NOTE: Every byte contains 8 pixels worth of data for single plane, so
    //       each column is actually 8 pixels wide.
    for (int x = 0; x < vsp->width; x++) {
        // for each plane...
        for (int pl = 0; pl < 4; pl++) {
            // for each row...
            for (int y = 0; y < vsp->height;) {
                // read a byte; if it's < 0x08, it's a command byte,
                // otherwise it's image data
                int c0 = *b++;
                // copy byte into buffer
                if (c0 >= 0x08) {
                    *(bc[pl] + y) = c0;
                    y++;
                }
                // copy n bytes from previous buffer to current buffer
                // (compression for horizontal repetition)
                else if (c0 == 0x00) {
                    n = (*b++) + 1;
                    memcpy(bc[pl] + y, bp[pl] + y, n);
                    y += n;
                }
                // b0 * n (1-byte RLE compression)
                else if (c0 == 0x01) {
                    n = (*b++) + 1;
                    b0 = *b++;
                    memset(bc[pl] + y, b0, n);
                    y += n;
                }
                // b0,b1 * n (2-byte RLE compression)
                else if (c0 == 0x02) {
                    n = (*b++) + 1;
                    b0 = *b++;
                    b1 = *b++;
                    for (int i = 0; i < n; i++) {
                        *(bc[pl] + y) = b0;
                        y++;
                        *(bc[pl] + y) = b1;
                        y++;
                    }
                }
                // copy n bytes from plane 0 XOR'd by the current mask
                else if (c0 == 0x03) {
                    n = (*b++) + 1;
                    for (int i = 0; i < n; i++) {
                        *(bc[pl] + y) = (*(bc[0] + y) ^ mask);
                        y++;
                    }
                    mask = 0;
                }
                // copy n bytes from plane 1 XOR'd by the current mask
                else if (c0 == 0x04) {
                    n = (*b++) + 1;
                    for (int i = 0; i < n; i++) {
                        *(bc[pl] + y) = (*(bc[1] + y) ^ mask);
                        y++;
                    }
                    mask = 0;
                }
                // copy n bytes from plane 2 XOR'd by the current mask
                else if (c0 == 0x05) {
                    n = (*b++) + 1;
                    for (int i = 0; i < n; i++) {
                        *(bc[pl] + y) = (*(bc[2] + y) ^ mask);
                        y++;
                    }
                    mask = 0;
                }
                // set mask
                else if (c0 == 0x06) {
                    mask = 0xff;
                }
                // not sure why this exists, padding maybe?
                else if (c0 == 0x07) {
                    *(bc[pl] + y) = *b++ ;
                    y++;
                }
            }
        }
        // planar -> chunky (bitmap) conversion
        for (int y = 0; y < vsp->height; y++) {
            int loc = (y * vsp->width + x) * 8;
            b0 = bc[0][y];
            b1 = bc[1][y];
            b2 = bc[2][y];
            b3 = bc[3][y];
            // NOTE: Half of every byte is wasted, since VSP is actually
            //       a 4-bit format.
            pic[loc+0] = ((b0>>7)&1)|((b1>>6)&2)|((b2>>5)&4)|((b3>>4)&8);
            pic[loc+1] = ((b0>>6)&1)|((b1>>5)&2)|((b2>>4)&4)|((b3>>3)&8);
            pic[loc+2] = ((b0>>5)&1)|((b1>>4)&2)|((b2>>3)&4)|((b3>>2)&8);
            pic[loc+3] = ((b0>>4)&1)|((b1>>3)&2)|((b2>>2)&4)|((b3>>1)&8);
            pic[loc+4] = ((b0>>3)&1)|((b1>>2)&2)|((b2>>1)&4)|((b3   )&8);
            pic[loc+5] = ((b0>>2)&1)|((b1>>1)&2)|((b2   )&4)|((b3<<1)&8);
            pic[loc+6] = ((b0>>1)&1)|((b1   )&2)|((b2<<1)&4)|((b3<<2)&8);
            pic[loc+7] = ((b0   )&1)|((b1<<1)&2)|((b2<<2)&4)|((b3<<3)&8);
        }
        // swap current/previous buffers
        bt = bp[0]; bp[0] = bc[0]; bc[0] = bt;
        bt = bp[1]; bp[1] = bc[1]; bc[1] = bt;
        bt = bp[2]; bp[2] = bc[2]; bc[2] = bt;
        bt = bp[3]; bp[3] = bc[3]; bc[3] = bt;
    }
}

struct cg *vsp_load(uint8_t *data, int *error)
{
    struct vsp_header vsp;
    struct cg *cg = calloc(1, sizeof(struct cg));

    // read header
    vsp_read_header(&vsp, data);

    // read palette
    vsp_get_palette(&cg->pal, data + vsp.palette);

    // allocate buffer for pixel data (+10: margin for broken CG)
    cg->pic = malloc((vsp.width * 8 + 10) * (vsp.height + 10));
    // convert VSP image data to 8-bit indexed bitmap
    vsp_extract(&vsp, cg->pic, data + vsp.pixel);

    cg->width = vsp.width * 8;
    cg->height = vsp.height;

    return cg;
}