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 LEB128A 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 typeEnums
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.extraConditions 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 offsetA 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— readTover 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 outComputed 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 parseThe 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 Base64xor(k, ...)— repeating-key XOR, one or more key bytesrolling_xor(seed, mul, add)— the key updates ask = k * mul + addafter every byteadd(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.