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¶
- Add generated
GraphX/*.cfiles to the build object list in the Makefile. - Include generated
GraphX/*.hheaders in game code. - Call the load function in your state initialization (
title_init,level_init, etc.). - 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_countconstant countsu16words, 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¶
- Tilemaps and Scrolling — VRAM layout, tilemap entry format, upload patterns
- Build Toolchain — NGP_FAR rules, linker order bug, Makefile integration
- Sprites and OAM — Sprite asset export, metasprite format
- Colors and Palettes — RGB444 palette format, palette slot layout