This is my first proper file format done in a single day with out sleep. ill try to make the code and documentation as clean as i can. good luck to anybody willing to fuck with my file format. any suggestions and contributions apreciated, this is a learning project
This document describes the CNS format. The container version
is 2; this is the only public CNS version defined by this specification.
Extension: .cns
Magic bytes: 43 4E 53 (ASCII "CNS", 3 bytes)
Reference implementations: cns_format.py (encoder + decoder, pure Python,
stdlib only) and clipnote.js (browser parser/player).
Not stored in the file — hard-coded into every encoder/decoder, identical for every .cns file that exists:
| Constant | Value |
|---|---|
| Resolution | 320 × 240 |
| Tile size | 8 × 8 |
| Tile grid | 40 × 30 = 1200 tiles per layer per frame |
| Layer count | 3 (0 = bottom, 1 = middle, 2 = top) |
| Palette | 6 colors + transparency (7 values total) |
| Index | Name | HEX |
|---|---|---|
| 0 | white | #ffffff |
| 1 | black | #000000 |
| 2 | red | #ff1010 |
| 3 | yellow | #ffe700 |
| 4 | green | #008431 |
| 5 | blue | #0039ce |
| 6 | transparent | — |
Every pixel in every layer is one of these 7 values. Index 6 is never rendered directly — it means "show what's beneath this pixel." The CNS file does not store RGB colors; these six RGB values are the format's default display palette. A player may offer a runtime recoloring option, but that changes display only and never changes the encoded pixel indexes or the CNS file bytes.
Two representations exist, used at different stages:
- Raw pixel value: a single integer 0–6. This is what layers/tiles are made of.
- Packed tile key (
pack_tile): a tile's 64 raw pixel values packed 3-bits-each, MSB-first, into a fixed 24-byte integer (64 × 3 = 192 bits = 24 bytes exactly, no padding). This packed form is used only as the dictionary-deduplication key — two tiles with identical pixels always produce identical 24-byte keys, so dedup is exact equality, not a hash with collision risk.
def pack_tile(pixels): # 64 ints (0-6) -> 24 bytes
value = 0
for p in pixels:
value = (value << 3) | p
return value.to_bytes(24, "big")
def unpack_tile(packed): # 24 bytes -> 64 ints (0-6)
value = int.from_bytes(packed, "big")
return [(value >> ((63 - i) * 3)) & 0b111 for i in range(64)]Step 1 — deduplicate. Walk every layer of every frame, tile by tile (40×30 grid). For each 8×8 tile, compute its 64 raw pixel values and its 24-byte packed key. If the key has been seen before (anywhere — any layer, any frame), reuse that dictionary index. Otherwise append it to the dictionary as a new entry.
Step 2 — RLE each unique tile. Each dictionary entry's 64 raw pixel values are run-length encoded as (run_length: u8, value: u8) pairs. A solid-color tile (extremely common — sky, background, empty space) compresses to a single 2-byte pair.
def rle_encode(pixels): # 64 raw ints -> bytes of (run, value) pairs
out = bytearray()
i, n = 0, len(pixels)
while i < n:
run = 1
while i + run < n and pixels[i + run] == pixels[i] and run < 255:
run += 1
out += bytes([run, pixels[i]])
i += run
return bytes(out)Step 3 — deflate the whole dictionary. All RLE'd tiles, each prefixed with a uint16 length, are concatenated and passed through one zlib-wrapped DEFLATE pass (zlib.compress in Python). This is the TDIC chunk payload. FRMS uses the same zlib-wrapped DEFLATE encoding. The maximum RLE payload for one 8×8 tile is 128 bytes, so the uint16 length is sufficient.
Step 4 — frames reference tiles by index. Each frame stores, per layer, a 1200-entry grid of dictionary indices (1 byte each if the dictionary has ≤255 tiles, 2 bytes if it has ≤65,535 tiles, otherwise 4 bytes) instead of pixels. A layer that is 100% transparent for a frame stores its empty_flag and no grid. All frame/layer records are concatenated and deflated once more as the FRMS chunk.
This is why static content is nearly free: an unchanging background tile is stored once in the dictionary and referenced by the same tiny index in every single frame; deflate then squeezes the repeated indices themselves down further.
[ MAGIC 3 bytes "CNS" ]
[ VERSION 2 bytes uint16 LE ] <- must be 2
[ HDR1 chunk ]
[ TDIC chunk ]
[ FRMS chunk ]
[ AUD1 chunk ] <- only present if has_audio = 1
Generic chunk shape: [ id: 4 bytes ASCII ][ length: uint32 LE ][ payload: length bytes ].
The reference encoder writes chunks in HDR1, TDIC, FRMS, then optional
AUD1 order. Readers should identify chunks by their four-byte IDs and reject
truncated chunks rather than reading beyond the declared payload.
All integers are little-endian. Strings are a uint16 byte-length prefix
followed by UTF-8 bytes, not null-terminated. The length counts encoded bytes,
not characters, and must be at most 65,535. Encoders must reject longer UTF-8
strings; decoders must reject strings that exceed the containing HDR1 chunk.
| Field | Type | Notes |
|---|---|---|
| framerate | uint8 | 1–120 |
| frame_max | uint32 | index of last frame; frame count = frame_max + 1 |
| thumbnail_frame | uint32 | index of the frame a viewer/gallery should composite and show as this file's thumbnail; must satisfy 0 <= thumbnail_frame <= frame_max. This is just a pointer — no separate thumbnail image is stored; a reader renders it the same way it renders any other frame (composite_rgb(thumbnail_frame)) |
| replay | uint8 | 0 = play once and hold, 1 = loop forever |
| locked | uint8 | 0 = editable, 1 = read-only in authoring tools |
| file_id | string | unique ID for this file, assigned once at creation, never changes on re-save |
| spinoff | uint8 | 0/1 — is this file a fork of another CNS file |
| spinoff_source_file_id | string | the file_id of the file this was forked from; empty if spinoff = 0 |
| original_user_name | string | username of the original creator; copied forward at fork time, never overwritten by later saves; empty if spinoff = 0 |
| original_user_id | string | userid of the original creator; empty if spinoff = 0 |
| has_audio | uint8 | 0/1 — mirrors presence of the AUD1 chunk |
| dict_tile_count | uint32 | number of unique tiles in TDIC; determines the FRMS index width (1 byte if ≤255, 2 bytes if ≤65,535, otherwise 4) |
| user_name | string | username of the current/last editor — updated every save |
| user_id | string | userid of the current/last editor |
file_id identifies the file itself (a stable identity, like an inode). user_id/user_name identify whoever last saved it. original_user_id/original_user_name identify who originally made the content this file is a fork of. All three are separate namespaces — never conflate them.
zlib.compress(concat of [uint16 rle_length][rle_length bytes] for each unique tile, in dictionary order 0..dict_tile_count-1).
Decoding: zlib.decompress, then walk sequentially reading length-prefixed RLE blocks until dict_tile_count tiles have been read. No separate offset table is needed — order is the dictionary index. A decoder must reject a truncated length prefix, a block extending past the decompressed stream, an RLE block that does not expand to exactly 64 values, a value outside 0–6, or unconsumed bytes after the final dictionary entry.
For each frame 0..frame_max, for each layer 0..2, in order:
visible : 1 byte (0/1)
empty_flag : 1 byte (0/1)
grid : present only if empty_flag == 0
1200 tile-dictionary indices, row-major (ty 0..29, tx 0..39),
each 1, 2, or 4 bytes per dict_tile_count (see HDR1); indexes
are unsigned little-endian and must be less than dict_tile_count
The whole concatenated stream (all frames, all layers) is one zlib-wrapped
DEFLATE pass. A decoder must reject truncated layer records, invalid boolean
flags other than 0/1, and any tile index greater than or equal to
dict_tile_count.
Raw Ogg container bytes (Vorbis or Opus), byte-for-byte, untouched — no re-encoding. Only present if has_audio = 1. The CNS format does not define an audio codec beyond requiring an Ogg container, nor does it store a sample rate in AUD1; the Ogg stream headers carry that information. Playback starts at frame 0; if replay = 1, audio restarts in sync each time the animation loops.
- Fixed 3 layers, bottom (0) → top (2).
- Each layer has a per-frame visibility bit (in
FRMS), independent of its tile data — lets you hide/show a layer across the timeline without touching its content. - Compositing rule: for each pixel, walk layers top → bottom; the first visible, non-transparent (index ≠ 6) pixel wins. If all three layers are transparent (or hidden) at a pixel, the result remains transparent. A viewer may display that transparency using a checker/grid background; a white background is only a viewer choice, not encoded CNS data.
The reference Python convenience method CNSFile.composite_indices() uses
white (index 0) as its display fallback for fully transparent pixels. That is
an API convenience, not a different file-format rule; a transparency-aware
viewer can instead retain index 6 and draw its own background.
def composite_indices(file, frame_index):
out = [6] * (320 * 240) # transparent until a layer paints it
for layer in (0, 1, 2):
px = file.decode_layer(frame_index, layer)
if px is None:
continue
for i in range(320 * 240):
if px[i] != 6:
out[i] = px[i]
return out- Every file has its own
file_id, generated once and stable forever — this is what gets referenced, never a filename or path. spinoff = 1→spinoff_source_file_idholds thefile_idof the file this one was forked from.original_user_name/original_user_idare stored inline, copied from the source file at fork time, so a viewer can show "originally by X" without needing to locate or load the (possibly missing/offline) source file.- On a non-spinoff file,
original_user_*anduser_*will typically match on first save and thenuser_*alone tracks future edits.
A conforming encoder/decoder must enforce:
1 <= framerate <= 120- every raw pixel value is
0–6; a7is a corrupt/invalid file, not a valid fallback - every frame has exactly 3 layer entries in
FRMS 0 <= thumbnail_frame <= frame_maxlocked == 1→ editors must open the file read-onlyhas_audiomust matchAUD1chunk presence exactly- every tile index in
FRMSmust be< dict_tile_count VERSIONmust be2; a reader must refuse (not guess-parse) any other version
Say you have a single frame, layer 0 only, and the top-left 8×8 tile is solid yellow (index 3):
- Raw pixels:
[3, 3, 3, ..., 3](64 values). - Packed key (
pack_tile): the 192-bit integer formed by3 << 189 | 3 << 186 | ... | 3, rendered as 24 bytes — used only to check "have I seen this tile before?" - RLE: since all 64 values are identical, this collapses to one pair:
[0x40, 0x03](run=64, value=3) — 2 bytes instead of 64. - Dictionary entry: if this is the first unique tile in the file, it becomes dictionary index
0. Stored inTDICas[0x02, 0x00](uint16 length = 2) followed by the 2 RLE bytes — 4 bytes total, before the whole dictionary gets deflated. - Frame reference: in
FRMS, this tile's grid cell (tx=0, ty=0) for layer 0 just stores the byte0x00(dictionary index 0) — 1 byte, regardless of how large or complex the tile's original pixel content was.
A tile that's reused in 500 later frames costs zero additional dictionary space — each reuse is just one more 0x00 byte in that frame's grid, and since those bytes are usually identical to the frame before, the final deflate pass over FRMS squeezes that down even further.
cns.encode(
path, frames, framerate,
thumbnail_frame=0,
replay=0, locked=0,
file_id="", spinoff=0, spinoff_source_file_id="",
original_user_name="", original_user_id="",
user_name="", user_id="",
audio_bytes=None,
) -> dict # {"bytes_written", "unique_tiles", "frame_count"}
cns.decode(path) -> CNSFilethumbnail_frame defaults to 0 (the first frame) and must be a valid frame
index (0 <= thumbnail_frame <= len(frames) - 1); encode() raises
ValueError otherwise.
frames is a list of:
{
"layers": [layer0, layer1, layer2], # each: None (fully transparent) or a flat
# list of 320*240 ints (0-6), row-major
"visible": [True, True, True], # per-layer visibility for this frame
}CNSFile (returned by decode) exposes all header fields as attributes (.framerate, .file_id, .spinoff_source_file_id, .original_user_id, .user_id, .thumbnail_frame, etc.) plus:
file.decode_layer(frame_index, layer_index) -> list[int] | None # raw 0-6 pixels, or None
file.composite_indices(frame_index) -> list[int] # composited display pixels; white fallback
file.composite_rgb(frame_index) -> list[(r,g,b)] # ready to blit/display
file.thumbnail_rgb() -> list[(r,g,b)] # composite_rgb(thumbnail_frame)
file.audio_bytes # raw Ogg bytes, or NoneThis example creates a valid two-frame CNS file with a red square on layer 1.
None means a fully transparent layer. Every non-None layer is a flat,
row-major array of exactly 320 * 240 palette indexes.
from pathlib import Path
import cns_format as cns
WIDTH, HEIGHT = 320, 240
def blank_layer():
return [cns.TRANSPARENT] * (WIDTH * HEIGHT)
def red_square(x, y, size):
pixels = blank_layer()
for row in range(y, y + size):
start = row * WIDTH + x
pixels[start:start + size] = [2] * size # palette index 2 = red
return pixels
frames = [
{"layers": [None, red_square(20, 30, 16), None],
"visible": [True, True, True]},
{"layers": [None, red_square(40, 30, 16), None],
"visible": [True, True, True]},
]
info = cns.encode(
"example.cns", frames, framerate=12, thumbnail_frame=1, replay=1,
file_id="example-file", user_name="artist", user_id="user-001",
)
print(info)
decoded = cns.decode("example.cns")
assert decoded.frame_count == 2
assert decoded.thumbnail_frame == 1
assert decoded.decode_layer(0, 0) is None
assert decoded.decode_layer(0, 1)[30 * WIDTH + 20] == 2
thumbnail = decoded.thumbnail_rgb() # composites the declared thumbnail frameFor each frame and layer, a conforming encoder:
- Validates the layer length and every pixel value.
- Splits each layer into 1200 row-major 8×8 tiles.
- Deduplicates tiles globally using their 24-byte packed keys.
- RLE-encodes each unique tile and writes compressed
TDIC. - Writes visibility and empty flags plus dictionary indexes into
FRMS. - Chooses FRMS index width from
dict_tile_count: 1, 2, or 4 bytes. - Writes
HDR1,TDIC,FRMS, and optionalAUD1after the magic/version.
To embed audio, pass an existing Ogg stream directly:
ogg_bytes = Path("master.ogg").read_bytes()
cns.encode("with-audio.cns", frames, framerate=12, audio_bytes=ogg_bytes)cns_format.py does not convert PCM to Ogg. A converter must perform that
conversion before calling cns.encode().
The decoder first validates the current-format prefix:
raw = Path("example.cns").read_bytes()
assert raw[:3] == b"CNS"
assert int.from_bytes(raw[3:5], "little") == 2It reads chunks from offset 5, parses HDR1, decompresses TDIC, and reads
exactly dict_tile_count [uint16 rle_length][RLE bytes] records. It then
selects the FRMS index width:
if dict_tile_count <= 255:
index_width = 1
elif dict_tile_count <= 65535:
index_width = 2
else:
index_width = 4The decoder decompresses FRMS, reads three layer records per frame, and uses
the dictionary indexes to reconstruct each layer grid. Index 6 remains
transparent. A viewer may draw a checker/grid background behind the result,
but that background is not stored in the CNS file.
All CNS integers are little-endian:
import struct
def u16(value):
return struct.pack("<H", value)
def u32(value):
return struct.pack("<I", value)
def chunk(name, payload):
assert len(name) == 4
return name.encode("ascii") + u32(len(payload)) + payloadTDIC RLE lengths are always uint16. FRMS tile indexes are the only fields
whose width changes with dictionary size.