Binary as a structured document.
spun builds composable, inspectable in-memory layout graphs. Instead of writing bytes immediately, you construct a graph of typed nodes, resolve it, and emit - or parse any binary file back into the same graph using a scheme.
Most binary tooling forces a choice: write bytes immediately, or parse into plain objects. spun does neither. A layout is a retained graph - you can inspect it, edit it, resolve references across it, and emit to bytes only when ready. Parsing a file produces the same kind of graph, giving you a uniform API for both reading and writing.
layout emits binary. scheme parses binary back
into a live layout.inspect() returns a JSON-serializable tree of every node
with offsets and sizes.LayoutNode to create custom types that integrate
with the full API.Installation
spun has zero runtime dependencies. It targets ES2022 and works in Node.js, Bun, Deno, browsers, and
workers. No node:buffer dependency.
# npm / pnpm / bun
npm install @gottheflag/spun
pnpm add @gottheflag/spun
bun add @gottheflag/spun
import { layout, section, emit, u8, u16, u32, i8, i16, i32, bytes, tag, pad, align, ref, reserve, scheme, field, sizeof, inspect, treeview, hexdump, serialize, registerNode, LayoutNode, } from "@gottheflag/spun";
The Pipeline
Every spun workflow follows three clean stages, always separated. You never skip from construction to bytes without the resolution step in between.
emit(layout) to get a Uint8Array.
Resolution runs automatically.const file = layout( // 1. construction section("header", tag("EXE"), u16(1), pad(2)), ); const buf = emit(file); // 2 + 3. resolve + emit // buf → Uint8Array [45 58 45 00 01 00 00 00]
Use Cases
- Binary file authoring - design and emit BMP, WAV, EXE, custom game formats, font files, and any structured binary format.
- Binary file parsing - define a scheme describing a format's structure, then load any conforming file into an inspectable layout graph.
- Reverse engineering - parse an unknown binary into named, offset-annotated sections. Modify fields and re-emit.
- Tooling and GUI editors -
inspect()returns a JSON-serializable tree, ready for rendering in any UI. - Checksums and patching - use
reserveto hold space, compute values after the layout is built, and re-emit cleanly. - Persistent binary documents - serialize any layout to JSON with
inspect(), restore a fully live layout withserialize().
Integer Nodes
Six unsigned and signed integer primitives. All are little-endian. The
value argument is optional and defaults to 0 - useful when defining scheme
templates where values come from the parsed buffer.
| Function | Size | Range |
|---|---|---|
| u8(value?) | 1 byte | 0 - 255 |
| u16(value?) | 2 bytes | 0 - 65 535 |
| u32(value?) | 4 bytes | 0 - 4 294 967 295 |
| i8(value?) | 1 byte | -128 - 127 |
| i16(value?) | 2 bytes | -32 768 - 32 767 |
| i32(value?) | 4 bytes | -2 147 483 648 - 2 147 483 647 |
const a = u8(255); const b = u16(0x0102); // emits: 02 01 (little-endian) const c = u32(0xDEADBEEF); const d = i32(-1); a.size; // 1 a.value; // 255 // in scheme context - value filled by load() const slot = u32(); // defaults to 0
bytes()
Holds a raw binary blob. Four call signatures cover emit and parse contexts:
| Signature | Context | Behaviour |
|---|---|---|
| bytes(data: Uint8Array) | Emit | Size and content from data. |
| bytes(n: number) | Both | Fixed n-byte slot. |
| bytes(() => node.value) | Parse | Lazy - size evaluated at parse time. |
| bytes(sizeof("field")) | Scheme | Size from a named field() in the same scheme. |
| bytes() | Parse | Remainder - consumes all remaining bytes. |
const data = new Uint8Array([0xAA, 0xBB, 0xCC]); bytes(data); // emit: 3 bytes from data bytes(16); // fixed: always 16 bytes bytes(() => n.value); // lazy: size from another node at parse time bytes(sizeof("size")); // scheme: size from a named field bytes(); // remainder: consumes rest of buffer
tag()
Encodes an ASCII string literal as raw bytes - one byte per character. Commonly used for binary magic numbers, format signatures, and chunk identifiers.
Called with a string - size is the string length, value is the string. Called with a number - reserves that many bytes for parsing (scheme use).
tag("BM"); // 2 bytes: 42 4D tag("TEX0"); // 4 bytes tag("PNG\r\n\x1A\n"); // 8-byte PNG signature // scheme: read 4 bytes as a tag string tag(4);
pad() and align()
pad inserts exactly n zero bytes. align inserts however many zero bytes are needed to bring the cursor to the next multiple of the boundary - computed during resolution.
const file = layout( tag("PNG"), // @0, size 3 → cursor at 3 align(4), // inserts 1 byte → cursor at 4 u32(1), // @4 pad(8), // always exactly 8 zero bytes );
AlignNode.size is 0 at construction time. The real padding size is computed
during resolution when the node's offset is known.
ref()
A symbolic reference to another node. During resolution, ref(target) becomes a 4-byte
little-endian unsigned integer holding the byte offset of target in the final layout.
const body = section("body", bytes(pixelData)); const file = layout( tag("TEX0"), // 4 bytes @0 ref(body), // 4 bytes @4 → resolves to body's offset (8) body, // @8 ); emit(file); // bytes 4-7: 08 00 00 00 (little-endian offset of body)
The target must exist somewhere in the same layout. If it cannot be found during
resolution, an error is thrown.
reserve
Allocates a fixed-size slot whose value is not known at construction time. Call .set(value)
at any point before emitting. Initial value is 0.
const checksum = reserve.u32(); const fileSize = reserve.u32(); const file = layout(tag("EXE"), fileSize, checksum, bytes(body)); // first pass - compute sizes const temp = emit(file); fileSize.set(temp.byteLength); checksum.set(computeCRC32(temp)); // final pass - correct values baked in const final = emit(file);
layout()
The root container for emission. Holds an ordered sequence of nodes and sections. layout is
emit-only - it carries real values and produces binary via emit().
const file = layout( section("header", tag("BM"), u32(102)), section("body", bytes(data)), ); file.nodes; // ReadonlyArray<LayoutNode> file.size; // total byte size (AlignNode contributes 0 before resolution)
layout is a pure construction container - it does not emit, parse, or carry a buffer.
Call emit(layout) to produce bytes. Use scheme.load(buffer) to parse bytes
back into a layout.
section()
A named group of nodes. Sections are themselves LayoutNode instances - they can be nested
arbitrarily and appear anywhere nodes are accepted.
Section names must match /^[A-Za-z0-9_-]{1,1024}$/. This constraint is validated at
construction time.
const header = section("file-header", tag("BM"), u32(0), u32(0), ); // nested sections const chunks = section("chunks", section("chunk-a", u32(10), bytes(data)), section("chunk-b", u32(20)), );
emit()
Resolves the layout graph and serializes it to a Uint8Array. This is the
only function that produces binary output. Resolution (offsets, alignments, references)
happens automatically.
const buf = emit(file); // use anywhere Uint8Array is accepted fs.writeFileSync("output.bin", buf); socket.send(buf); new Blob([buf]);
emit() can be called multiple times. Each call re-resolves from the current node values.
For layouts with reserve nodes, the typical pattern is: emit once to compute sizes →
set reserved values → emit again for the final binary.
resolve()
Runs the resolution phase explicitly and returns an OffsetMap - a
Map<LayoutNode, number> from every node to its byte offset. emit() calls
this internally; expose it directly for tooling.
const offsets = resolve(file); offsets.get(myNode); // → number
scheme()
A structural template that describes the shape of a binary format. No values - only types and
sizes. Calling scheme.load(buffer) produces an independent, fully populated
Layout.
| Concept | Description |
|---|---|
| layout | Carries real values. Used to construct and emit binary. |
| scheme | Carries no values. Reusable parsing template. |
const bmpScheme = scheme( section("file-header", tag(2), // read 2 bytes as a tag field("fileSize", u32()), u32(), // reserved - no name needed field("pixelOffset", u32()), ), section("dib-header", u32(), field("width", i32()), field("height", i32()), u16(), field("bpp", u16()), u32(), field("imageSize", u32()), i32(), i32(), u32(), u32(), ), section("pixels", bytes(sizeof("imageSize")), // size from named field ), );
field()
Wraps any node with a name, making it addressable by layout.get() and usable as a size
source for sizeof(). Transparent in size and emission - delegates entirely to its inner
node.
Field names must match /^[A-Za-z0-9_-]{1,1024}$/, validated at construction time.
field("fileSize", u32()) // named u32 slot field("pixel-data", bytes(sizeof("imageSize")))
After scheme.load(), access values via layout.get("fieldName") and mutate with
.set(value).
sizeof()
Scheme-only. A lazy size reference that resolves to the parsed value of the named field()
when load() runs. The correct way to express variable-length blobs in schemes.
const s = scheme( field("dataSize", u32()), bytes(sizeof("dataSize")), // reads dataSize bytes ); // buffer: [03 00 00 00 AA BB CC] const l = s.load(buf); l.get("dataSize").value; // 3 // bytes node holds [AA, BB, CC]
sizeof() is not valid in layout context. An error is thrown if the
referenced field name is not found in the scheme at load time.
scheme.load()
Parses a Uint8Array against the scheme and returns a fully populated, independent
Layout. Each call produces a completely separate layout - the scheme itself is never
mutated.
const bmp1 = bmpScheme.load(buffer1); const bmp2 = bmpScheme.load(buffer2); // independent - mutating bmp1 does not affect bmp2 bmp1.get("width").set(800); bmp2.get("width").value; // unchanged // the result is a normal Layout - all APIs work emit(bmp1); inspect(bmp1); treeview(inspect(bmp1));
layout.get()
Unified path and offset accessor. Works on any Layout - including those produced by
scheme.load().
| Signature | Returns | Description |
|---|---|---|
| get(path) | LayoutNode | Node at dot-separated path. Throws if not found. |
| get(path with *, max?) | LayoutNode[] | Wildcard - all matching nodes, up to max. |
| get(offset) | LayoutNode | Node at exact byte offset. Throws if not found. |
| get(start, end) | LayoutNode[] | All nodes with range [start, end). |
const bmp = bmpScheme.load(buffer); // by name - searches all sections bmp.get("width").value; // by dot path bmp.get("dib-header.width").value; bmp.get("file-header"); // → SectionNode // by byte offset bmp.get(18); // → node at byte 18 // by byte range bmp.get(0, 14); // → all nodes in [0, 14) // wildcard - all fields named "size" bmp.get("*.size"); // → LayoutNode[] // wildcard with max results bmp.get("*.size", 3); // → LayoutNode[] (max 3)
Each segment must match /^[A-Za-z0-9_-]{1,1024}$/. Use * as a wildcard
segment to match any name. Dot notation separates levels: "section-name.field-name".
TypeScript's return type is automatically narrowed - paths containing * return
LayoutNode[], all others return LayoutNode.
field.set()
Mutates the value of a named field in place. Works on any FieldNode returned by
layout.get(). The next emit() call uses the updated value.
const bmp = bmpScheme.load(buffer); bmp.get("dib-header.width").value; // 4 bmp.get("dib-header.width").set(800); const out = emit(bmp); // width field now 800 // wildcard set - all "size" fields to 0 (bmp.get("*.size") as FieldNode[]).forEach(f => f.set(0));
inspect()
The core inspection primitive. Returns a JSON-serializable tree describing the layout's complete structure - every section and node with offsets, sizes, types, labels, and values.
Inspector is InspectorNode[]. Each element is either an
InspectorSection or an InspectorLeaf:
// InspectorSection { kind: "section", name: "file-header", offset: 0, size: 14, nodes: InspectorNode[], } // InspectorLeaf { kind: "leaf", type: "u32", // "u8" | "u16" | "u32" | "i8" | ... | "bytes" | "tag" | ... label: "U32(102)", offset: 2, size: 4, value: 102, // number | string | number[] | null }
const tree = inspect(layout); // fully JSON-serializable const json = JSON.stringify(tree, null, 2); fs.writeFileSync("layout.json", json); // navigate programmatically tree[0].kind; // "section" tree[0].nodes[0].type; // "tag" tree[0].nodes[0].value; // "BM" tree[0].nodes[0].offset; // 0
inspect() is the guaranteed contract. It returns raw structured data - how you present
it is entirely up to you. Use treeview() and hexdump() for quick terminal
output, or build your own GUI renderer, diff tool, or binary visualizer on top of the
Inspector tree.
treeview()
Convenience printer. Takes an Inspector and prints a tree to the console. A thin helper
built on top of inspect() - not a core primitive.
treeview(inspect(layout)); // Layout // ├─ Section("file-header") @0000 +14 // │ ├─ Tag("BM") @0000 +2 // │ ├─ U32(102) (fileSize) @0002 +4 // │ └─ U32(54) (pixelOffset) @000a +4 // └─ Section("pixels") @000e +48 // └─ Bytes(48) @000e +48
hexdump()
Convenience printer. Takes an Inspector and prints a classic hex dump to the console,
reconstructing byte values from the inspect tree. 16 bytes per row.
hexdump(inspect(layout)); // 00000000 42 4D 66 00 00 00 00 00 00 00 36 00 00 00 28 00 // 00000010 00 00 04 00 00 00 04 00 00 00 01 00 18 00 00 00 // ...
serialize()
Restores a fully live Layout from an Inspector tree or a JSON string. The
restored layout is fully functional - emittable, inspectable, and accessible via get().
// from Inspector object const tree = inspect(layout); const restored = serialize(tree); // from JSON string - full round-trip const json = JSON.stringify(inspect(layout)); const fromJson = serialize(json); // fully live emit(restored); // → identical Uint8Array inspect(restored); // → same tree restored.get("width"); // → FieldNode
RefNode instances are restored as plain u32 nodes holding the resolved
offset value. The symbolic edge is not preserved - only the resolved state is captured. Custom nodes
require registration via registerNode() to survive serialization.
Custom Nodes
Extend LayoutNode to create fully integrated custom node types. They participate in
emit(), inspect(), treeview(), hexdump(),
serialize(), and scheme parsing automatically.
Contract
| Method | Required | Description |
|---|---|---|
| readonly size: number | Yes | Byte length of this node. Must be a fixed value. |
| _emit(view, offset) | Yes | Write this node's bytes into view at offset. |
| _load(buffer, offset, remaining) | For scheme parsing | Read and store value from buffer at offset. |
| _inspect() | Recommended | Return { type, label, value } for inspection output. |
| static fromInspect(node) | For serialize() | Reconstruct an instance from an InspectorLeaf. |
import { LayoutNode, registerNode } from "@gottheflag/spun"; import type { InspectorLeaf } from "@gottheflag/spun"; class XoredNode extends LayoutNode { readonly size = 4; value: number; key: number; constructor(value: number, key: number) { super(); this.value = value; this.key = key; } // write XOR-obfuscated value _emit(view: DataView, offset: number): void { view.setUint32(offset, this.value ^ this.key, true); } // read and de-obfuscate _load(buffer: Uint8Array, offset: number): void { const raw = new DataView(buffer.buffer).getUint32(offset, true); this.value = raw ^ this.key; } // inspection output _inspect() { return { type: "xored", label: `XOR(${this.value}, key=${this.key})`, value: { raw: this.value, key: this.key }, }; } // restore from serialized JSON static fromInspect(node: InspectorLeaf): XoredNode { const v = node.value as { raw: number; key: number }; return new XoredNode(v.raw, v.key); } } registerNode("xored", XoredNode); // use it const node = new XoredNode(0xDEAD, 0xFF); const file = layout(tag("XOR"), node); emit(file); // node emits XOR-obfuscated bytes inspect(file); // _inspect() output appears in tree // full round-trip through JSON const restored = serialize(JSON.stringify(inspect(file))); emit(restored); // identical bytes
registerNode() / resolveNode()
The node registry maps string keys to custom node constructors. Used by serialize() to
reconstruct custom nodes from JSON. The key must match the type field returned by
_inspect().
registerNode("crc32", CRC32Node); registerNode("lz4-block", LZ4BlockNode); // throws if key is already registered registerNode("crc32", AnotherNode); // Error! // look up - used internally by serialize() resolveNode("crc32"); // → CRC32Node constructor
Guide: Emit a BMP File
Produces a valid, openable 24-bit BMP image. Demonstrates layout, section,
tag, u16, u32, i32, bytes,
reserve, and emit.
import { writeFileSync } from "fs"; import { layout, section, tag, u16, u32, i32, bytes, reserve, emit } from "@gottheflag/spun"; const width = 4; const height = 4; // build pixel data - BGR, bottom-to-top row order, rows padded to 4 bytes const rowSize = width * 3; // 12 bytes, already aligned const pixelData = new Uint8Array(rowSize * height); // ... fill pixelData with BGR values ... const fileSize = reserve.u32(); const pixelOffset = reserve.u32(); const file = layout( section("file-header", tag("BM"), fileSize, u32(0), // reserved pixelOffset, ), section("dib-header", u32(40), // BITMAPINFOHEADER size i32(width), i32(height), u16(1), // color planes u16(24), // bits per pixel u32(0), // compression: none u32(pixelData.byteLength), i32(2835), i32(2835), // ~72 DPI u32(0), u32(0), ), section("pixels", bytes(pixelData)), ); // resolve deferred fields const temp = emit(file); fileSize.set(temp.byteLength); pixelOffset.set(54); // 14 (file header) + 40 (DIB header) writeFileSync("output.bmp", emit(file));
Guide: Parse a BMP File
Define a scheme once, load any conforming BMP into an inspectable, editable layout.
import { readFileSync, writeFileSync } from "fs"; import { scheme, section, field, sizeof, tag, u16, u32, i32, bytes, emit, inspect, treeview, } from "@gottheflag/spun"; const bmpScheme = scheme( section("file-header", tag(2), field("fileSize", u32()), u32(), field("pixelOffset", u32()), ), section("dib-header", u32(), field("width", i32()), field("height", i32()), u16(), field("bpp", u16()), u32(), field("imageSize", u32()), i32(), i32(), u32(), u32(), ), section("pixels", bytes(sizeof("imageSize"))), ); // parse const buf = readFileSync("output.bmp"); const bmp = bmpScheme.load(new Uint8Array(buf)); // inspect treeview(inspect(bmp)); // read bmp.get("width").value; // 4 bmp.get("height").value; // 4 bmp.get("bpp").value; // 24 // modify and re-emit bmp.get("dib-header.width").set(800); bmp.get("dib-header.height").set(600); writeFileSync("modified.bmp", emit(bmp)); // persist the layout as JSON import { serialize } from "@gottheflag/spun"; const json = JSON.stringify(inspect(bmp)); const restored = serialize(json); emit(restored); // identical bytes
Guide: Custom CRC32 Node
A complete custom node that stores a CRC32 checksum. Integrates with emit, inspect, and serialize.
import { LayoutNode, registerNode, layout, tag, bytes, emit } from "@gottheflag/spun"; import type { InspectorLeaf } from "@gottheflag/spun"; function crc32(data: Uint8Array): number { // your CRC32 implementation here let crc = 0xFFFFFFFF; for (const byte of data) { crc ^= byte; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1)); } return (crc ^ 0xFFFFFFFF) >>> 0; } class CRC32Node extends LayoutNode { readonly size = 4; value = 0; _emit(view: DataView, offset: number): void { view.setUint32(offset, this.value, true); } _load(buffer: Uint8Array, offset: number): void { this.value = new DataView(buffer.buffer).getUint32(offset, true); } _inspect() { return { type: "crc32", label: `CRC32(0x${this.value.toString(16).padStart(8, "0")})`, value: this.value, }; } static fromInspect(node: InspectorLeaf): CRC32Node { const n = new CRC32Node(); n.value = node.value as number; return n; } } registerNode("crc32", CRC32Node); // usage const data = new Uint8Array([1, 2, 3, 4]); const checksum = new CRC32Node(); const file = layout(tag("DAT"), checksum, bytes(data)); checksum.value = crc32(data); const out = emit(file); // [44 41 54 | crc32 bytes | 01 02 03 04]
Copyright © GTF.