Skip to content

Asset Pipeline

End-to-end workflow for exporting and integrating graphic assets (tilemaps, tiles, palettes, compressed tiles) into an NGPC project.


1. Export Tilemap C + Optional Tile Binary

Standard background tilemap export:

python ngpc_tilemap.py assets/level1_bg.png \
  -o GraphX/level1_bg.c -n level1_bg --header \
  --tiles-bin GraphX/level1_bg_tiles.bin

For fixed full-screen screens (intro, title) — raw byte tiles, no deduplication:

python ngpc_tilemap.py assets/title.png \
  -o GraphX/title_intro.c -n title_intro --header \
  --emit-u8-tiles --black-is-transparent --no-dedupe

Outputs: - GraphX/<name>.c + GraphX/<name>.h — tiles array, map array, palettes - GraphX/<name>_tiles.bin (if --tiles-bin) — raw tile words, little-endian - <name>_tiles_u8[] + <name>_tile_count (if --emit-u8-tiles)

Key flags:

Flag Effect
--header Generate .h file alongside .c
--tiles-bin FILE Also export raw tile binary
--emit-u8-tiles Emit tiles as u8[] instead of u16[]
--black-is-transparent Treat opaque black as palette index 0 (transparent)
--no-dedupe Disable tile deduplication (for full-screen unique tiles)

2. Compress Tiles (Optional)

Optional LZ77 compression for tile data — reduces ROM usage at the cost of decompression time at load:

python ngpc_compress.py GraphX/level1_bg_tiles.bin \
  -o GraphX/level1_bg_tiles_lz.c -n level1_bg_tiles -m lz77 --header

Resulting runtime symbols: - level1_bg_tiles_lz[] — compressed data array - level1_bg_tiles_lz_len — compressed data size

Call the appropriate decompress routine at load time before uploading to VRAM.


3. Runtime Loading

Two approaches for loading tile data at runtime:

Method A — Direct VRAM macro (safe path, recommended when NGP_FAR issues arise):

Use macros from ngpc_tilemap_blit.h. These index the generated symbol directly within the compilation unit — no pointer passed as a parameter, so no near/far issue is possible.

#include "ngpc_tilemap_blit.h"

void level_init(void)
{
    NGP_TILEMAP_BLIT_SCR1(level1_bg, TILE_BASE);
}

Method B — Helper functions (standard path):

Use ngpc_gfx_load_tiles_at() and related helpers. Requires NGP_FAR on all ROM pointer parameters.

#include "ngpc_gfx.h"
#include "GraphX/level1_bg.h"

void level_init(void)
{
    /* NGP_FAR required — data is in ROM at 0x200000+ */
    ngpc_gfx_load_tiles_at(level1_bg_tiles, level1_bg_tiles_count, TILE_BASE);
    ngpc_gfx_load_palette_scr1(0, level1_bg_pal);
    /* blit tilemap ... */
}

If you see corrupted rendering with Method B: verify NGP_FAR is present on all ROM-pointing parameters, then fall back to Method A if needed.


4. Integrate into Project

  1. Add generated GraphX/*.c files to the build object list in the Makefile.
  2. Include generated GraphX/*.h headers in game code.
  3. Call the load function in your state initialization (title_init, level_init, etc.).
  4. Ensure sprites/metasprites are linked before tilemaps — see Build Toolchain §8.2 (linker order bug).

5. Tool Notes and Limits

Constraint Value Notes
Source dimensions Multiple of 8 Width and height must divide into 8×8 tiles
Max tiles (unique) 512 Hardware tile RAM limit (after dedupe)
Max tilemap size 32×32 cells SCR1/SCR2 are 32×32 maps
Colors per tile 3 visible + 1 transparent Per 8×8 tile; palette index 0 = transparent
Max palettes 16 Per plane
Alpha threshold alpha < 128 → transparent Source pixel becomes palette index 0
Transparent color Palette index 0 Use --black-is-transparent for legacy behavior
Default black Treated as opaque Preserved unless flag specified
tiles_count Count of u16 words = num_tiles × 8, NOT number of tiles
map_tiles[] Indices 0..N into tile set Add TILE_BASE when writing to VRAM
Single-layer output When all tiles ≤ 3 visible colors Tool auto-detects; no split needed

tiles_count gotcha: the generated <name>_tiles_count constant counts u16 words, not tiles. A tile is 8 words (16 bytes). Divide by 8 to get tile count.


6. Large Map (Streaming) Export — Y-Inverted Row Layout

See also: Tilemaps and Scrolling §6.12 for runtime details.

Maps exported for streaming-style scrolling use a Y-inverted row layout. The ROM tile lookup formula is base[col - row * map_w], which means row 0 of the visual level is at the highest address in the array.

6.1 Required Export Format

# Tool must store rows in REVERSE order (bottom row first, top row last in memory)
# Visual row 0 (top of level) = last row stored = base_ptr offset (H-1)*W

map_words = []
for row in range(H - 1, -1, -1):       # bottom → top in ROM
    for col in range(W):
        map_words.append(tilewords[row][col])   # u16 tileword

# In generated C:
# const u16 my_map[H * W] = { /* row H-1, row H-2, ..., row 1, row 0 */ };
# The "base" pointer passed to mapstream = &my_map[(H-1) * W]

6.2 C Array Declaration

/* Generated by export tool: rows stored reversed (bottom first, top last) */
const uint16_t my_map[MAP_H * MAP_W] NGP_FAR = {
    /* row MAP_H-1 (bottom of level): */
    0x0100, 0x0100, 0x0101, ...,
    /* row MAP_H-2: */
    ...
    /* row 0 (top of level, visible at cam_y=0): */
    0x0102, 0x0103, ...
};

/* Pass to mapstream: base ptr = top row */
#define MY_MAP_BASE  (&my_map[(MAP_H - 1u) * MAP_W])

6.3 Why Y Is Inverted

On NGPC, SCR_Y = -cam_y + correction. As cam_y increases (camera moves down visually), the scroll register decreases, and the hardware shows tiles from earlier (lower VRAM row offset) addresses. The row-reversed ROM storage is the natural consequence: tile(row=0) at the highest address matches cam_y=0 (top of screen showing top of level).

6.4 Tile Lookup Recap (for tool verification)

def get_tileword(map_array, map_w, map_h, col, row):
    """
    map_array: flat list, stored bottom-first (row H-1 at index 0)
    base index (in the C array) for visual row 0 = (map_h-1)*map_w
    """
    base_idx = (map_h - 1) * map_w
    idx = base_idx + col - row * map_w
    return map_array[idx]
    # Equivalent to: map_array[(map_h - 1 - row) * map_w + col]

7. Custom Font Export

Converts a font PNG tilesheet into NGPC 2bpp tile data compatible with the ngpc_dialog module when NO_SYSFONT is defined.

7.1 PNG Format

Dimension Cols × Rows Tiles Default tile_base
128×48 16 × 6 96 32 (ASCII 32–127)
256×24 32 × 3 96 32 (ASCII 32–127)
256×32 32 × 4 128 0 (ASCII 0–127)
Any N×8 width N/8 × floor(H/8) auto 32

Tile order: left→right, top→bottom. ASCII code = tile slot (direct mapping, identical to the BIOS system font).

Black pixels (RGB(0,0,0)) are treated as transparent (same convention as the sprite export tool). Use near-black (e.g. RGB(1,1,1)) for character pixels if the source PNG uses black strokes.

7.2 Usage

python ngpc_font_export.py font.png -o GraphX/gen/ngpc_custom_font -n ngpc_custom_font
python ngpc_font_export.py font.png -o GraphX/gen/ngpc_custom_font --tile-base 32
python ngpc_font_export.py font.png -o GraphX/gen/ngpc_custom_font --no-outline

7.3 Outline Auto-Generation

When the source PNG has exactly 1 visible colour (body only, no baked outline), the tool automatically generates a 1-pixel black outline around each glyph:

  • Body pixels → color index 2 (RGB(15,15,15) white)
  • Transparent pixels 8-adjacent to body → color index 1 (RGB(0,0,0) black outline)
  • Remaining transparent → color index 0 (hardware transparent)

Use --outline to force, --no-outline to disable. PNGs with 2+ visible colours skip auto-generation (outline already baked in).

7.4 Generated Files

ngpc_custom_font.c — tile array const u16 NGP_FAR ngpc_custom_font_tiles[N * 8]

ngpc_custom_font.h — exposes:

#define NGPC_FONT_TILE_BASE   32u
#define NGPC_FONT_TILE_COUNT  96u
#define ngpc_custom_font_load()   /* loads tiles into VRAM */
#define ngpc_custom_font_set_palette(plane, slot)  /* applies font palette */

/* Palette extracted from PNG (or outline defaults): */
#define NGPC_CUSTOM_FONT_PAL_C0  RGB(0,0,0)    /* transparent, irrelevant */
#define NGPC_CUSTOM_FONT_PAL_C1  RGB(15,15,15) /* body (white) */
#define NGPC_CUSTOM_FONT_PAL_C2  RGB(0,0,0)    /* outline (black) */
#define NGPC_CUSTOM_FONT_PAL_C3  RGB(0,0,0)    /* unused */

7.5 Palette Slots with NO_SYSFONT

The editor dialogue tab provides 3 colour swatches per scene (slots 1–3):

Swatch Index Role
1 1 Glyph body colour
2 2 Glyph outline colour
3 3 Accent / cursor
T 0 Always transparent — hardware, read-only

These are saved in dialogue_config.palette and emitted as SCENE_DLG_PAL_1/2/3 in the generated dialog header. Each scene can have a different font colour.

With NO_SYSFONT, ngpc_font_apply_palette() is not called in ngpc_dialog_open() — the palette is fully controlled by the generated per-scene code.

7.6 Makefile Integration

CDEFS += -DNO_SYSFONT=1
CDEFS += -DFONT_TILE_BASE=350          # must not overlap BG or sprite tiles
OBJS  += $(OBJ_DIR)/GraphX/gen/ngpc_custom_font.rel
OBJS  += $(OBJ_DIR)/optional/ngpc_dialog/ngpc_dialog.rel
OBJS  += $(OBJ_DIR)/optional/ngpc_dialog/ngpc_font.rel

8. Converter Pitfalls Learned on a Finished Game

Every generator of a complete game hit at least one of these. None fails loudly: each one produces a plausible, wrong image.

8.1 The LEFT pixel is the HIGH bits

A character row is a 16-bit word holding eight 2-bit pixels, and the leftmost pixel is in the most significant bits. Writing them the other way mirrors every character — invisible on symmetric art (a road board had the fault and nobody could see it), obvious the day a start light came out "as a butterfly", its two halves back to back. Test every new generator on an asymmetric glyph.

8.2 Two objects that touch are one sprite

A tool that cuts a ladder of sizes by fully empty columns merged two barriers drawn 1 px apart into one 46-px object — two barriers side by side on screen, reported as "stacked, messy". Nothing in the drawing says it is wrong. Insert the missing empty column in a prep step (keep the source sheet intact) and have the tool print the cuts it found. Also drop near-duplicate sizes (a 22-px step 8 % away from the 24-px one) — fewer characters.

8.3 Prefix macros AND symbols

A generator that takes --name must prefix both its #defines and its C symbols. Three times the same bug (cars, horizons, buttons): two generated files defining the same macro, the last one included silently winning.

8.4 Palette-coherent conversion of a background

A converter that picks each cell's palette independently, then reduces the tile count by bit distance between patterns, produces a checkerboard: two patterns close in bits can show very different colours depending on their palette, and big sky areas show every error. What fixed it:

  • fit the 8 palettes to the colours actually present;
  • assign palettes considering neighbouring edges and the differences present in the source (a real mountain ridge is not a seam);
  • reduce to the tile budget by displayed colour distance, pattern frequency and internal gradients — so building strokes are never reused in the sky;
  • no blur, no new dithering. Then targeted fixes: neighbouring palettes sharing the SAME sky blue / forest green, a majority filter and removal of isolated specks on clouds.

Measure the mean error at tile borders in near-uniform areas: it fell ~60 % across 16 backgrounds. Keep the old converter reachable for comparison.

8.5 A wrapping panorama: judge the seam by eye, shifted

A 256-px panorama wraps when the player turns. The mean difference between column 255 and column 0 overestimates the damage on textured art and does not say where the gap falls: two versions of a snowy pass at 1.85 and 1.79 — one cut a cloud and a ridge cleanly, the other showed nothing. What makes a seam visible is a CONTINUOUS STRUCTURE crossing it (a ridge, a horizon line), not a mean gap. Method: render the board shifted by 128 to put the seam mid-screen, and look, board by board, before rejecting or accepting it. A mirror blend over ~24 columns repairs one that fails. And a preview rebuilt from a scaled image must verify that each 4×4 block is uniform before being trusted — a preview resized another way gives a plausible, wrong image.

8.6 First row uniform; colour 0 is transparent

  • The first (top) row of a scenery board must be one flat colour if the engine may repeat it (hills, §8.4 of Pseudo-3D Road).
  • Art ripped from a system where colour 0 is opaque (e.g. a black HUD panel) becomes a hole here: remap it, or the road shows through.
  • If a plane uses index 0 as a visible colour (grass), that colour is the backdrop register — one flat colour for the whole screen; measure it on the last visible row of the art above it or you get a seam.

8.7 Generated text and generated tables are never edited by hand

When dialogue or data is generated from a source file, regenerating must give the same .c byte for byte — that is the proof the ROM shows the reviewed text. Hand edits in the generated file are lost at the next export or, worse, make the check fail forever. Same for asset headers: a tile count lives in the generated header, and the loader must be rebuilt when it changes (Build Toolchain §8.5b).

8.8 One image, many states: palette writes instead of character loads

If the frames of an animation have exactly the same shape and only change colour (a start light: 108 px of housing, 75 px of lamp), load ONE image and make each state a palette write: 4 characters instead of 20, and no VRAM upload per state. Let the generator find the changing pixels by comparing the frames rather than assuming them.

8.9 The character budget can be clipped in silence

With 512 characters shared by sprites and planes, an allocator that returns "base 512" when full instead of failing leaves the last module served with no art at all (here: road signs killed by a new HUD, while every automated drive stayed green — a bot does not look at signs). Add a check that names the starved module, and measure the peak per selectable asset: the peak depended on the chosen car (458 vs 461 vs 494 of 512).

Quick Reference

Task Command
Export background tilemap python ngpc_tilemap.py INPUT.png -o GraphX/OUT.c -n NAME --header
Export intro (no dedupe, u8 tiles) Add --emit-u8-tiles --black-is-transparent --no-dedupe
Export + raw tile binary Add --tiles-bin GraphX/OUT_tiles.bin
Compress tile binary python ngpc_compress.py INPUT.bin -o OUT_lz.c -n NAME -m lz77 --header
Safe load (no far issue) NGP_TILEMAP_BLIT_SCR1(prefix, tile_base) macro
Standard load ngpc_gfx_load_tiles_at(NGP_FAR *tiles, count, offset)
Link order Metasprite .rel before tilemap .rel in Makefile
Streaming map rows Reversed (bottom first)
Streaming tile lookup base[col - row*W]
Export custom font python ngpc_font_export.py font.png -o GraphX/gen/ngpc_custom_font -n ngpc_custom_font
Force/disable outline Add --outline or --no-outline

See Also