ZX-V Documentation Español Try it in your browser

Graphics

ZX-V has no sprite or tile hardware. Instead, the ROM contains a graphics engine: routines that run on the main processor and draw sprites, tile maps, shapes and textured floors into video memory, very fast. The video hardware adds what software cannot do well: two screen pages, hardware scrolling, per-line effects and colour remapping.

This page explains all of it with complete programs. Each function is described in detail in the API reference. To get started with C on ZX-V, read the SDK guide first.

Contents

How the engine draws

Include zxv_gfx.h. Every drawing function draws on the current target:

Some rules apply to everything:

The simple routines zxv_cls, zxv_plot and the text routines also draw on the drawing page, but not on bitmaps.

The video memory

Each page is a normal block of memory: page 0 starts at ZXV_VRAM0_BASE (0x2000_4000) and page 1 at ZXV_VRAM1_BASE (0x2001_C000). Pixels take 4 bits, two per byte, line after line:

Mode Pixels Bytes per line
0 256 × 192 128
1 512 × 384 256

Pixel (x, y) is in byte y × bytes_per_line + x / 2: in the low 4 bits if x is even, in the high 4 bits if x is odd. Seen as 32-bit words, each word holds 8 consecutive pixels, pixel x in bits 4·(x mod 8) to 4·(x mod 8)+3.

#include "zxv_api.h"

/* Set pixel (x, y) of page 0 in mode 0: 128 bytes per line, two pixels per
 * byte, the left one (even x) in the low 4 bits. */
static void pset(int x, int y, int c)
{
    volatile uint8_t *p = (volatile uint8_t *)(uintptr_t)ZXV_VRAM0_BASE + y * 128 + x / 2;
    if (x & 1)
        *p = (uint8_t)((*p & 0x0F) | c << 4);
    else
        *p = (uint8_t)((*p & 0xF0) | c);
}

int main(void)
{
    zxv_wait_vblank();                          /* a frame has just started... */
    while (*(volatile uint32_t *)(uintptr_t)ZXV_SV_SYSFLAGS & ZXV_SYSF_LINK_BUSY)
        ;                                       /* ...wait until it has been sent */
    for (int y = 0; y < 192; y++)
        for (int x = 0; x < 256; x++)
            pset(x, y, (x ^ y) & 15);           /* a pattern in all the colours */
    while (zxv_key_get() < 0)
        zxv_wait_vblank();
    return 0;
}

In mode 0 only the first 24 KB of each 96 KB page are shown; the rest is free memory for your program (except with the large scrolling fields of Hardware scrolling).

If you know the Spectrum: there are no attributes and no thirds: every pixel has its own colour and the lines are in order.

Pages and double buffering

At the start of each frame (the VBLANK), the system takes the visible page and sends it to the screen, together with the video registers. That copy is shown during the following frame. Two consequences:

For animation, the clean way is double buffering: draw the next frame on the hidden page, make it visible, and wait for the VBLANK.

int visible = 0;
for (;;) {
    int hidden = 1 - visible;
    zxv_gfx_draw_page(hidden);          /* 1. draw on the page nobody sees */
    draw_everything();
    zxv_set_page(hidden);               /* 2. it will be visible from the next frame */
    zxv_wait_vblank();                  /* 3. wait for that frame */
    visible = hidden;
}

The move-a-sprite program of the guide is a complete example. Before returning to BASIC, leave page 0 visible and as the drawing page (zxv_set_page(0); zxv_gfx_draw_page(0);).

zxv_set_page changes the ZXV_V_PAGE register. All video registers work the same way: you write them whenever you like, and they take effect at the next VBLANK.

Drawing in memory

A bitmap (zxv_bitmap) describes an image in memory, in the same format as the video memory:

typedef struct {
    uint8_t *pix;           /* first pixel, aligned to 4 bytes */
    uint16_t stride;        /* bytes per line, a multiple of 4 */
    uint16_t w, h;          /* size in pixels */
    uint16_t reserved;
} zxv_bitmap;

You can make one target and draw on it with all the functions of the engine, then copy parts of it to the screen with zxv_gfx_blit or turn them into sprites. It is the easy way to make graphics without image files: Estela draws its rocks, mines, explosions and bosses this way when it starts. Declare the pixels as a uint32_t array to get the alignment:

static uint32_t pix[32 * 16 / 8];                   /* 32x16 pixels: 16 bytes per line */
zxv_bitmap b = { (uint8_t *)pix, 16, 32, 16, 0 };

zxv_gfx_blit(&b, sx, sy, w, h, x, y, flags) copies the rectangle (sx, sy, w, h) of a bitmap to (x, y) on the target. flags can mirror it (ZXV_FLIP_H, ZXV_FLIP_V) and skip colour 8 (ZXV_BLIT_TRANSPARENT).

Sprites

A sprite is a piece of a bitmap converted with zxv_spr_prepare into a format that draws fast, with its transparency mask already computed. You prepare each sprite once and draw it as often as you like:

#include "zxv_gfx.h"

static uint32_t sheet_pix[32 * 16 / 8];         /* a 32x16 sheet: two 16x16 frames */
static uint32_t face[(8 + 16 * 2 * 8) / 4];     /* 8 + h * ceil(w/8) * 8 bytes */
static uint32_t ball[(8 + 16 * 2 * 8) / 4];

static void make_sheet(void)
{
    zxv_bitmap b = { (uint8_t *)sheet_pix, 16, 32, 16, 0 };    /* stride 16 bytes = 32 px */
    zxv_gfx_target(&b);
    zxv_gfx_rect(0, 0, 32, 16, ZXV_TRANSPARENT);
    zxv_gfx_circle(7, 7, 7, 6, 1);              /* frame 0: a face looking right */
    zxv_gfx_rect(9, 4, 2, 3, 0);
    zxv_gfx_line(8, 11, 12, 10, 2);
    zxv_gfx_circle(23, 7, 6, 10, 1);            /* frame 1: a ball */
    zxv_gfx_target(0);
    zxv_spr_prepare(&b, 0, 0, 16, 16, face);
    zxv_spr_prepare(&b, 16, 0, 16, 16, ball);
}

int main(void)
{
    make_sheet();
    zxv_gfx_rect(0, 0, 256, 192, 1);
    zxv_spr_draw(face, 16, 16, 0, 0);                           /* as drawn */
    zxv_spr_draw(face, 40, 16, ZXV_FLIP_H, 0);                  /* mirrored */
    zxv_spr_draw(face, 64, 16, ZXV_FLIP_V, 0);                  /* upside down */

    static const uint8_t green[16] = { 0, 1, 2, 3, 4, 5, 12, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
    zxv_spr_draw(face, 88, 16, 0, green);                       /* colour 6 drawn as 12 */

    zxv_spr_draw_scaled(face, 16, 48, 64, 64, 0, 0);            /* four times bigger */
    zxv_spr_draw_scaled(face, 96, 48, 8, 8, 0, 0);              /* half size */

    /* Pixel-perfect collision: the boxes overlap in both cases, but only
     * in the first do opaque pixels touch. */
    int a = zxv_spr_collide(face, 150, 60, ball, 158, 66);
    int b = zxv_spr_collide(face, 150, 100, ball, 163, 113);
    zxv_spr_draw(face, 150, 60, 0, 0);
    zxv_spr_draw(ball, 158, 66, 0, 0);
    zxv_spr_draw(face, 150, 100, 0, 0);
    zxv_spr_draw(ball, 163, 113, 0, 0);
    zxv_gfx_rect(190, 64, 8, 8, a ? 12 : 2);    /* green = touching, red = not */
    zxv_gfx_rect(190, 108, 8, 8, b ? 12 : 2);

    while (zxv_key_get() < 0)
        zxv_wait_vblank();
    return 0;
}

Tile maps

A tile map builds a large scene from small square pieces, tiles of 8 × 8 or 16 × 16 pixels, like a mosaic. The map is a grid of cells; each cell (16 bits) says which tile goes there and how:

Bits Meaning
0–11 (ZXV_CELL_TILE) Tile number, 0 to 4095
12 (ZXV_CELL_FLIP_H) Mirrored horizontally
13 (ZXV_CELL_FLIP_V) Mirrored vertically
14 (ZXV_CELL_TRANSP) Colour 8 is transparent (otherwise the tile is opaque and draws faster)
15 (ZXV_CELL_PRIO) Priority: the tile goes in front of the sprites
typedef struct {
    const uint16_t *cells;  /* the cells, row after row */
    uint16_t w, h;          /* size in cells */
    const uint8_t *tiles;   /* the tile graphics, one after another (32 or 128 bytes each) */
    uint8_t size;           /* 8 or 16 */
    uint8_t flags;          /* ZXV_MAP_WRAP: the map repeats in both directions */
} zxv_tilemap;

A tile is just its pixels in the video memory format, line after line: an 8 × 8 tile is 32 bytes (4 per line) and a 16 × 16 one, 128 bytes. A column of 8 × 8 tiles drawn in a bitmap 8 pixels wide (4 bytes per line) is therefore exactly the tile data the map needs.

#include "zxv_gfx.h"

#define MAP_W 64                                /* cells: 64 x 24 of 8x8 pixels */
#define MAP_H 24

static uint32_t tile_pix[3 * 8];                /* 3 tiles of 8x8: 32 bytes each */
static uint16_t cells[MAP_W * MAP_H];
static const zxv_tilemap level = { cells, MAP_W, MAP_H, (const uint8_t *)tile_pix, 8, ZXV_MAP_WRAP };

enum { SKY, BRICK, GRASS };

static void make_level(void)
{
    zxv_bitmap b = { (uint8_t *)tile_pix, 4, 8, 3 * 8, 0 };     /* the tiles, one below another */
    zxv_gfx_target(&b);
    zxv_gfx_rect(0, 0, 8, 8, 5);                                /* SKY: cyan */
    zxv_gfx_rect(0, 8, 8, 8, 2);                                /* BRICK: red with mortar */
    zxv_gfx_line(0, 11, 7, 11, 7);
    zxv_gfx_line(3, 8, 3, 11, 7);
    zxv_gfx_line(7, 12, 7, 15, 7);
    zxv_gfx_rect(0, 16, 8, 8, 4);                               /* GRASS: green */
    zxv_gfx_line(0, 16, 7, 16, 12);
    zxv_gfx_target(0);

    for (int y = 0; y < MAP_H; y++)
        for (int x = 0; x < MAP_W; x++) {
            uint16_t t = SKY;
            if (y >= 20)
                t = BRICK;
            else if (y == 19)
                t = GRASS;
            else if (y == 12 && (x / 6) % 3 == 0)
                t = BRICK;                                      /* floating platforms */
            cells[y * MAP_W + x] = t;
        }
}

int main(void)
{
    make_level();
    int scroll = 0, visible = 0;
    while (zxv_key_get() < 0) {
        int hidden = 1 - visible;
        zxv_gfx_draw_page(hidden);
        /* The 256x192 window of the map that starts at pixel (scroll, 0). */
        zxv_map_draw(&level, scroll, 0, 0, 0, 256, 192, ZXV_MAP_ALL);
        /* Is there a brick in the middle of the screen, on the platforms row? */
        uint16_t c = zxv_map_cell(&level, scroll + 128, 12 * 8 + 4);
        zxv_gfx_rect(124, 80, 8, 8, (c & ZXV_CELL_TILE) == BRICK ? 15 : 0);
        zxv_set_page(hidden);
        zxv_wait_vblank();
        visible = hidden;
        scroll++;                               /* the map wraps, so this never ends */
    }
    zxv_set_page(0);
    zxv_gfx_draw_page(0);
    return 0;
}

Redrawing the whole screen of tiles every frame is fine in mode 0 (about 11 % of the frame when the map is not aligned to whole tiles). For large scenes, and in mode 1, it is better to let the hardware scroll.

Hardware scrolling

The registers ZXV_V_SCROLL_X and ZXV_V_SCROLL_Y (16 bits each) move the picture inside the page, wrapping round: what leaves on one side comes back on the other. They cost nothing: the video hardware does it.

In mode 0, the page can also be bigger than the screen, and the screen becomes a window you move over it. The register ZXV_V_VSIZE chooses the size of both pages:

ZXV_V_VSIZE Page Bytes per line For
ZXV_VSIZE_SCREEN (0) 256 × 192 128 Normal
ZXV_VSIZE_2X2 (1) 512 × 384 256 Scrolling in both directions
ZXV_VSIZE_WIDE (2) 1024 × 192 512 Horizontal scrolling
ZXV_VSIZE_TALL (3) 256 × 768 128 Vertical scrolling

With a large page the whole 96 KB are used, and the system takes as long to send it as in mode 1 (ZXV_SYSF_LINK_BUSY lasts up to 6 ms). The drawing functions follow the new size: after changing ZXV_V_VSIZE, the drawing page is 512 × 384, 1024 × 192, etc.

#include "zxv_gfx.h"

#define REG8(a)  (*(volatile uint8_t *)(uintptr_t)(a))
#define REG16(a) (*(volatile uint16_t *)(uintptr_t)(a))

int main(void)
{
    REG8(ZXV_V_VSIZE) = ZXV_VSIZE_2X2;          /* mode 0 page of 512 x 384 */
    zxv_gfx_draw_page(0);                       /* the engine follows the new size */
    zxv_gfx_rect(0, 0, 512, 384, 1);
    for (int i = 0; i < 40; i++)                /* draw the whole field once */
        zxv_gfx_circle((i * 97) % 512, (i * 61) % 384, 10 + i % 20, 2 + i % 13, 1);

    int x = 0, y = 0;
    while (zxv_key_get() < 0) {
        zxv_wait_vblank();
        x = (x + 1) % 512;                      /* the window wraps around the field */
        y = (y + 1) % 384;
        REG16(ZXV_V_SCROLL_X) = (uint16_t)x;    /* no redrawing: the video does it */
        REG16(ZXV_V_SCROLL_Y) = (uint16_t)y;
    }
    REG16(ZXV_V_SCROLL_X) = 0;
    REG16(ZXV_V_SCROLL_Y) = 0;
    REG8(ZXV_V_VSIZE) = ZXV_VSIZE_SCREEN;
    return 0;
}

For a level longer than the page, use the trick of the consoles: because the scroll wraps round, draw only the column of tiles that is about to come into view, just outside the window, and keep moving. The scroll example does exactly that on a 1024 × 192 page: each new column of 8 × 144 pixels costs less than 1 % of a frame.

Raster effects and colour remapping

The line table

The line table (ZXV_LINE_TABLE) lets every line of the picture have its own border colour, colour table, horizontal scroll and even choose which line of the page it shows. With it you make parallax (each band of the scenery scrolls at its own speed), split screens, water, road effects and border stripes, without changing registers in the middle of a frame.

It has one 8-byte entry for each line of the whole frame including the border: 240 lines in mode 0 (the first 24 are the top border, so screen line y is entry 24 + y) and 480 in mode 1 (48 of top border). In zxv.h, ZXV_FRAME_LINES(mode) and ZXV_BORDER_LINES(mode).

Offset Size Field "As usual" value
ZXV_LT_BORDER (0) 8 bits Border colour of this line 0xFF
ZXV_LT_REMAP (1) 8 bits Colour table of this line (0–3) 0xFF
ZXV_LT_SCROLL_X (2) 16 bits Horizontal scroll of this line 0xFFFF
ZXV_LT_SRC_LINE (4) 16 bits Line of the page shown here 0xFFFF
6 16 bits Reserved: write 0

The table is used while bit ZXV_VFLAG_LINETAB of ZXV_V_FLAGS is set. Like the visible page, it is read right after each VBLANK: change it after ZXV_SYSF_LINK_BUSY clears.

Colour remapping

There are four remapping tables of 16 bytes at ZXV_REMAP_TABLES. The register ZXV_V_REMAP chooses which one the screen goes through: a pixel of colour v is shown as colour table[v]. Table 0 is normally left as it is (each colour as itself). Changing a table changes the colours of the whole screen at once, without touching the video memory: fades, flashes, colour cycling.

Each table also has its own palette: the 16 RGB565 colours shown for each index, at ZXV_PALETTE(t). They start as the usual ones; changing one gives any colour (ZXV_RGB(r, g, b), 0 to 255 per component) to everything drawn with it. With the line table, each band of the screen can have its own 16 colours:

volatile uint16_t *pal1 = (volatile uint16_t *)(uintptr_t)ZXV_PALETTE(1);
pal1[2] = ZXV_RGB(255, 128, 0);                 /* table 1's red, orange */

Index 8 is still transparent for sprites and tiles even if the palette gives it another colour.

This program shows both: border stripes with the line table, and six bands whose colours rotate through table 1:

#include "zxv_gfx.h"

#define REG8(a)  (*(volatile uint8_t *)(uintptr_t)(a))
#define REG32(a) (*(volatile uint32_t *)(uintptr_t)(a))

/* After a VBLANK the system is still reading the visible page, the line
 * table and the remap tables for a moment: wait until it has finished. */
static void wait_frame(void)
{
    zxv_wait_vblank();
    while (REG32(ZXV_SV_SYSFLAGS) & ZXV_SYSF_LINK_BUSY)
        ;
}

int main(void)
{
    volatile uint8_t *remap1 = (volatile uint8_t *)(uintptr_t)(ZXV_REMAP_TABLES + 16);
    volatile uint8_t *lines = (volatile uint8_t *)(uintptr_t)ZXV_LINE_TABLE;

    zxv_gfx_rect(0, 0, 256, 192, 0);
    for (int i = 0; i < 6; i++)                 /* six bands in colours 1 to 6 */
        zxv_gfx_rect(0, 40 + i * 16, 256, 16, 1 + i);

    /* Line table: every line of the 240-line frame (24 of top border first)
     * gets its own border colour; the rest of each entry means "as usual". */
    for (int l = 0; l < ZXV_FRAME_LINES(0); l++) {
        volatile uint8_t *e = lines + l * ZXV_LINE_ENTRY_SIZE;
        e[ZXV_LT_BORDER] = (uint8_t)(9 + (l / 8) % 7);     /* bright stripes */
        e[ZXV_LT_REMAP] = 0xFF;
        e[ZXV_LT_SCROLL_X] = e[ZXV_LT_SCROLL_X + 1] = 0xFF;
        e[ZXV_LT_SRC_LINE] = e[ZXV_LT_SRC_LINE + 1] = 0xFF;
    }
    REG8(ZXV_V_FLAGS) = ZXV_VFLAG_LINETAB;
    REG8(ZXV_V_REMAP) = 1;                      /* show the screen through table 1 */

    int frame = 0;
    while (zxv_key_get() < 0) {
        wait_frame();
        if (frame % 8 == 0)                     /* rotate colours 1..6: the bands move */
            for (int v = 1; v <= 6; v++)
                remap1[v] = (uint8_t)(1 + (v - 1 + frame / 8) % 6);
        frame++;
    }

    wait_frame();                               /* tidy up before going back */
    REG8(ZXV_V_FLAGS) = 0;
    REG8(ZXV_V_REMAP) = 0;
    for (int v = 0; v < 16; v++)
        remap1[v] = (uint8_t)v;
    return 0;
}

The scroll example uses the scroll field of the line table for parallax: the mountains at the top move at half the speed of the ground.

Mode 7

Mode 7 (named after the SNES mode that made it famous) draws a texture on each line with its own position and step: rotated, zoomed or in perspective, like a floor seen from above.

#include "zxv_gfx.h"

static uint8_t texels[64 * 64];                 /* a 64x64 texture, one byte per texel */
static zxv_m7line rows[192];                    /* one transformation per screen line */

int main(void)
{
    for (int y = 0; y < 64; y++)                /* a checkerboard */
        for (int x = 0; x < 64; x++)
            texels[y * 64 + x] = ((x / 8) ^ (y / 8)) & 1 ? 12 : 4;
    const zxv_m7tex tex = { texels, 6, 1, 0, 0 };   /* 2^6 = 64, repeats */

    int visible = 0;
    for (int frame = 0; zxv_key_get() < 0; frame++) {
        int hidden = 1 - visible;
        zxv_gfx_draw_page(hidden);
        zxv_gfx_rect(0, 0, 256, 72, 9);         /* the sky */
        zxv_m7cam cam = {
            .x = frame << 15, .y = 0,           /* 16.16 fixed point: moving along x */
            .height = 16 << 16,
            .angle = frame * 64,                /* 0x10000 is a full turn */
            .focal = 128 << 16,
            .horizon = 64,                      /* screen line of the horizon */
        };
        zxv_m7_camera(rows, 192, &cam);         /* lines 0..64 come out empty */
        zxv_m7_draw(&tex, rows + 72, 72, 120);  /* draw lines 72..191 */
        zxv_set_page(hidden);
        zxv_wait_vblank();
        visible = hidden;
    }
    zxv_set_page(0);
    zxv_gfx_draw_page(0);
    return 0;
}

Mode 7 costs about 15 instructions per pixel: a full screen in mode 0 is a quarter of the frame, and in mode 1 it does not fit in a frame, so draw only the floor. The graphics example puts objects on the floor with scaled sprites, projected with the same camera.

Images from PNG files: png2gfx.py

Drawing everything with code is fine for shapes, but for real pixel art you will want a paint program. sdk/tools/png2gfx.py converts PNG images into a .gfx file: sprites, tiles, whole levels (cut into tiles, with the repeated ones reused) and mode 7 textures. It is the format BASIC loads with LOAD "name" GRAPHICS, and your C program can read it too. It only needs Python 3.

python3 sdk/tools/png2gfx.py art.gfx --sprites 0 sheet.png 16,16 --level 0 8 level.png --wrap 0
python3 sdk/tools/png2gfx.py --list art.gfx

The first line cuts sheet.png into 16 × 16 sprites numbered from 0, and turns level.png into map 0 of 8 × 8 tiles that repeats. The second lists what is in the file: sprite 0: 16 x 16, 8 x 8 tiles: 4, map 0: 8 x 4 cells.... Its messages are in English, or in Spanish if your system is.

Command Effect
--sprite N IMAGE [X,Y,W,H] Sprite N is the image, or that rectangle of it
--sprites N IMAGE W,H A sprite sheet: sprites N, N+1... of W × H, row by row
--tiles S N IMAGE [X,Y,W,H] The image (or rectangle) cut into S × S tiles (8 or 16), numbered from N
--level M S IMAGE A whole level: cut into S × S tiles, reusing repeated ones (with --flip, also mirrored ones); map M gets the layout. Cells of tiles with transparent pixels get the transparency bit
--map M S FILE.csv Map M from a CSV file with the value of each cell
--texture N IMAGE [C] Texture N (a square, its side a power of 2 from 2 to 256); it repeats or, with C, outside it is colour C
--wrap M Map M repeats
--screen N IMAGE [B] Screen N (like those of SAVE "x" SCREEN$ and loading screens): 256 × 192 (mode 0) or 512 × 384 (mode 1), or the whole frame (320 × 240 or 640 × 480) with the border in the colour of its corner; B, the border colour. With colours other than the usual ones (16 at most), it carries its palette
--flip --level also reuses mirrored tiles
--key RRGGBB That colour is transparent (as well as pixels with alpha below 128)

Each pixel becomes the nearest ZX-V colour, with a warning if it was not exact. Paint with the 15 exact colours: each component 00, D7 or FF (for example D70000 is red and FF0000 bright red). Screens keep their colours: with colours other than the usual ones (16 at most), the screen carries its palette.

Reading a .gfx file in C

A .gfx file starts with the 4 letters ZXVG, a version byte (1), a zero byte and the number of objects (16 bits). Each object has an 8-byte header (type, 0, number in 16 bits, length of its data in 32 bits) followed by its data:

Type Object Data
1 Sprite width, height (16 bits each), then its pixels: ⌈width / 2⌉ bytes per line
2 Tiles of S × S (S is the object number) count (16 bits), 0 (16 bits), then the tiles (32 or 128 bytes each)
3 Map width, height (16 bits each), tile size, repeat (bytes), 0 (16 bits), then the cells (16 bits each)
4 Texture log2 of the side, repeat, colour outside, 0 (bytes), then the texels (one byte each)
5 Screen mode (0 or 1), border colour, flags (bit 0: it has a palette), 0 (bytes); with a palette, its 16 RGB565 colours (32 bytes); then the pixels of the page, as in VRAM (24,576 bytes in mode 0, 98,304 in mode 1)

All numbers are little-endian. To read them, the SDK has a small library, zxv_gfxfile.h (with gfxfile.c). The file can be anywhere: loaded from the card with zxv_file_load or inside the program. As the objects inside the file are not aligned, each function copies its object to a buffer of the program, aligned the way the engine wants it, and returns the bytes that buffer needs (0 if the object is not there; with a NULL buffer, it only tells):

Function What it does
zxv_gfxf_open(&f, data, size) 1 if it is a well-formed .gfx (it checks every object); f is then ready
zxv_gfxf_sprite(&f, n, &bitmap, buf, room) Sprite n as a bitmap, for zxv_spr_prepare, zxv_gfx_blit or zxv_gfx_target
zxv_gfxf_tiles(&f, side, buf, room, &count) The 8 or 16 pixel tiles
zxv_gfxf_map(&f, n, &map, tiles, buf, room) Map n: its cells in buf and, in the zxv_tilemap, the tiles given
zxv_gfxf_texture(&f, n, &texture, buf, room) Mode 7 texture n
zxv_gfxf_screen(&f, n, &mode, &border, buf, room) Screen n: its pixels in buf, like a VRAM page (for example, the hidden one, to flip pages at VBLANK)
zxv_gfxf_screen_palette(&f, n, pal) The 16 RGB565 colours screen n was shown with, to put in a palette (ZXV_PALETTE(t)): 1 if it carries them; otherwise 0 and the usual ones
zxv_gfxf_find(&f, kind, n, &length) An object's data as it is in the file (kind: ZXV_GFXF_SPRITE 1, ZXV_GFXF_TILES 2, ZXV_GFXF_MAP 3, ZXV_GFXF_TEXTURE 4, ZXV_GFXF_SCREEN 5)

This program loads art.gfx from the current folder, prepares the two sprites and the map (with 8 pixel tiles), and scrolls them:

#include "zxv_gfxfile.h"

static uint8_t file[16 * 1024];                 /* the whole .gfx file */
static uint32_t pix[4096];                      /* a sprite, aligned, to prepare it */
static uint32_t face[1024], ball[1024];         /* the prepared sprites */
static uint32_t tiles[64 * 32 / 4];             /* up to 64 8x8 tiles */
static uint16_t cells[1024];

/* Sprite num of the file, prepared in out. */
static int load_sprite(const zxv_gfxfile *f, int num, void *out, uint32_t room)
{
    zxv_bitmap b;
    uint32_t n = zxv_gfxf_sprite(f, num, &b, pix, sizeof(pix));
    if (!n || n > sizeof(pix) || zxv_spr_prepare(&b, 0, 0, b.w, b.h, NULL) > room)
        return 0;
    zxv_spr_prepare(&b, 0, 0, b.w, b.h, out);
    return 1;
}

int main(void)
{
    zxv_gfxfile f;
    zxv_tilemap level;
    int n = zxv_file_load("art.gfx", file, sizeof(file));     /* if it fits in 'file' */
    if (n < 0 || !zxv_gfxf_open(&f, file, (uint32_t)n) ||
        !load_sprite(&f, 0, face, sizeof(face)) || !load_sprite(&f, 1, ball, sizeof(ball)))
        zxv_report_error(0, "Cannot load art.gfx");      /* back to BASIC with this report */
    uint32_t t = zxv_gfxf_tiles(&f, 8, tiles, sizeof(tiles), NULL);
    uint32_t c = zxv_gfxf_map(&f, 0, &level, tiles, cells, sizeof(cells));
    if (!t || t > sizeof(tiles) || !c || c > sizeof(cells))
        zxv_report_error(0, "The map of art.gfx does not fit");
    int visible = 0;
    for (int frame = 0; zxv_key_get() < 0; frame++) {
        int hidden = 1 - visible;
        zxv_gfx_draw_page(hidden);
        zxv_map_draw(&level, frame, 0, 0, 0, 256, 192, ZXV_MAP_ALL);
        zxv_spr_draw(face, 60, 120, 0, NULL);
        zxv_spr_draw(ball, 100 + frame % 100, 60, 0, NULL);
        zxv_set_page(hidden);
        zxv_wait_vblank();
        visible = hidden;
    }
    zxv_set_page(0);
    zxv_gfx_draw_page(0);
    return 0;
}

To build it, add $(SDK)/gfxfile.c to the program's files (in the Makefile above, SOURCES := hello.c $(SDK)/gfxfile.c), and put art.gfx in the same folder of the SD card as the program. The Route 128 and Estela games of the examples read their graphics like this: they carry their .gfx inside the program and, if there is another one on the card, they use that one.

The graphics editor for the PC: zxv-gfx

To draw on the PC, the emulator packages come with zxv-gfx, an editor for the same .gfx files for Windows and Linux (in the AppImage, ./ZX-V-x86_64.AppImage zxv-gfx). It is free software under the MIT licence: its code is in sdk/tools/gfx, and it builds on its own with Qt 6 (cmake -S sdk/tools/gfx -B build-gfx). It speaks Spanish or English, the system's language (or the one you ask for: zxv-gfx --lang es).

zxv-gfx with the level of the platform game in the examples

The graphics editor on the machine: gfxedit

Without leaving the machine: LOAD "ROM:gfxedit" (or gfxedit.zxv from sdk/build) opens an editor for the same .gfx files, used with the mouse. At the top there is a tab for each kind of graphic:

Tab What it edits
Sprites The 256 sprites, from 1 × 1 to 1024 × 1024 pixels (colour 8 is the transparent one)
Tiles 8, Tiles 16 The two tile sets: the tile enlarged, the same tile repeated 3 × 3 to see whether it matches its neighbours, and below the whole set, to pick from
Maps The 16 maps: painted with the chosen tile and its attributes (both mirrors, 8 transparent and in front), as with MAP POKE
Textures The 4 mode 7 textures: their side, whether they wrap and the colour outside

Screens (those of SAVE "x" SCREEN$ and loading screens) are not edited here but in zxv-gfx: loading a file skips them, and saving it leaves them out.

About 112 KB of graphics fit at once. Its code is in sdk/examples/gfxedit: store.c reads and writes .gfx files, and your program can use it as it is.

How fast is it?

At 60 frames per second the processor has about 2.4 million cycles per frame. These figures were measured on the real machine:

Operation Mode 0 (256 × 192) Mode 1 (512 × 384)
Clear the page 1.2 % of a frame 2.7 %
Full screen of 8 × 8 tiles, aligned 3.9 % 11.7 %
Full screen of 8 × 8 tiles, scrolled by any number of pixels 13.1 % 48 %
Full screen of 16 × 16 tiles, aligned 2.2 % 6.4 %
100 sprites of 16 × 16 6.1 % 5.6 %
100 sprites of 16 × 16 scaled to 32 × 32 63 % 62 %
100 sprites of 16 × 16 scaled to 12 × 12 28 % 27 %
Mode 7, full screen 31 % 121 %
100 triangles of about 470 pixels 29 % 28 %
100 random lines 14 % 25 %