Nybble documentation

Nybble reads a binary file through a schema — a short description of how bytes map to fields. This is the whole language. It is deliberately small: a layout description, not a scripting language.

Core concept

Every field the runtime produces carries its exact offset and size. That per-field byte range is what powers byte ↔ field highlighting: select a field and its bytes light up in the hex view; click a byte and the owning field is selected. A schema executes against a file to produce a tree of { name, type, value, offset, size, children } nodes.

Quick start

A struct lists fields in order. Each field is a name followed by a type:

struct Header {
    magic   char[4]
    version u16
    flags   u16
    size    u32
}

Arrays can take their length from an earlier field:

struct File {
    header       Header
    player_count u32
    players      Player[player_count]
}

Primitive types

u8  u16  u32  u64      // unsigned integers
i8  i16  i32  i64      // signed integers
f32 f64                // IEEE floats
bool  char             // 1-byte boolean / character
string  bytes          // sized text / raw bytes (see below)

Endianness is set per schema (little or big) and defaults to little-endian.

Varints (LEB128)

varint reads an unsigned LEB128 integer; svarint reads the signed form (SLEB128 — sign-extended, not zig-zag). Each consumes only as many bytes as the encoding needs, up to ten, so the same field can be a different size in every file. The tree still records exactly which bytes it used.

size  varint         // section length
count varint         // how many entries follow
idx   varint[count]  // an array of varints
delta svarint        // signed LEB128

A varint serves as an array length like any other integer, so formats built almost entirely on them — WebAssembly, DWARF, Protocol Buffers — need no special handling.

Arrays & strings

T[N] is an array of N — a literal or an earlier field. string[N] and bytes[N] read a fixed number of bytes; a fixed string stops at NUL padding but still occupies all N bytes.

name  string[32]     // 32 bytes, decoded up to the first NUL
data  bytes[len]     // 'len' bytes, where len is an earlier field
rows  Row[count]     // array of a struct type

Enums

An enum gives symbolic names to an integer. In the tree it shows the matching name, or N (unknown) when no variant matches.

enum ColorType : u8 {
    Grayscale = 0
    RGB       = 2
    Palette   = 3
    RGBA      = 6
}

struct Ihdr {
    colorType ColorType   // shows e.g. "RGBA (6)"
}

The repr after : must be an integer type. Variant values are non-negative literals (decimal or 0x hex).

Bitfields

A bitfield unpacks an integer into named bits. Each member is a single bit (name N) or an inclusive range (name lo..hi), with bit 0 the least-significant. It expands to one child per member.

bitfield GzipFlags : u8 {
    text    0
    hcrc    1
    extra   2
    name    3
    comment 4
    // level 5..6   // a multi-bit sub-field
}

Conditional fields

An if guard reads a field only when the condition holds; otherwise it is absent and consumes no bytes, so later fields keep their offsets.

struct Record {
    version u8
    payload u32 if version >= 2   "added in v2"
}

// XLEN exists only when the FEXTRA flag bit is set:
extraLen u16 if flags.extra

Conditions are a (dotted) field reference, optionally with a comparison:

if <path>            // truthy — non-zero / true
if <path> == <int>   // also != < <= > >=

Pointers & relative offsets

An at clause reads a field from an offset stored elsewhere (ELF's e_phoff, BMP's pixel data, ZIP's central directory) and consumes no sequential bytes. Prefix with + to make the offset relative to the enclosing struct.

firstPixels at dataOffset bytes[4]          // absolute, from a field
progHeaders at +phoff ProgramHeader[phnum]  // relative to this struct
table       at 0x200  TableEntry            // absolute literal offset

A pointer field's offset/size point at the target bytes, so selecting it highlights the pointed-to region wherever it lives.

Discriminated unions — match

When a field's type depends on an earlier tag (TLV records, tagged chunks, protocol messages), match selects the variant to read. Only the selected variant's bytes are consumed.

struct TLV {
    tag  u8
    body match tag {
        0 => u32
        1 => string[16]
        2 => Point            // any type, including a struct
        default => bytes[8]   // optional catch-all
    }
}

Without a default, an unmatched value is a parse error. The tree shows the resolved variant.

Variable-length fields

Three forms handle data whose size isn't a fixed number:

  • cstring — a NUL-terminated string (reads up to and including the NUL; to end-of-file if there's no NUL).
  • [*] — rest-of-file: bytes[*] / string[*] consumes everything from the field to the end.
  • repeat T — read T over and over until the input runs out, for formats that end in an open-ended run of records.
name     cstring if flags.name   // NUL-terminated file name
payload  bytes[*]                // the rest of the file
sections repeat Section          // records until the bytes run out

Computed fields

A field written name = expr is computed from earlier fields and reads no bytes — handy for a length the format only stores implicitly.

struct Blob {
    total  u32
    header u32
    dataLen = total - header    // computed, zero bytes
    data    bytes[dataLen]      // ...usable as a later length
}

Expressions support + - * / %, parentheses, integer literals, and (dotted) field references, with normal precedence.

Decoding & transforms

Real files hide their structure behind compression or light obfuscation. A decode clause transforms a field's bytes after reading them, and an optional as re-parses the result as another type — so a compressed blob becomes a real tree instead of a wall of noise.

payload bytes[len] decode zlib               // inflate, keep as bytes
module  bytes[*]   decode gunzip as Module   // inflate, then parse as Module
save    bytes[*]   decode xor(0x5A) as Save  // de-obfuscate, then parse

The available transforms:

  • zlib / zlib_inflate — zlib stream (RFC 1950)
  • inflate / deflate_raw — raw DEFLATE (RFC 1951)
  • gunzip / gzip — gzip member (RFC 1952)
  • base64 — standard-alphabet Base64
  • xor(k, ...) — repeating-key XOR, one or more key bytes
  • rolling_xor(seed, mul, add) — the key updates as k = k * mul + add after every byte
  • add(n) / sub(n) — shift every byte by a constant

A decoded field keeps its real span in the file — the compressed bytes — so highlighting still points at the region the data actually occupies. The fields beneath it are positioned within the decoded stream, since those bytes exist nowhere on disk.

That's the entire language. Nybble ships eight worked examples as built-in schemas — PNG, GZIP, ZIP, SQLite, ELF, PE, Mach-O, and PCAP — that auto-load when the app detects the format. Open one from the schema library to see the language used end to end.

More formats live in the community format registry, installable from inside the app. The engine itself is open source — read it, or file an issue, on GitHub.