Skip to content

Bytecode Format

PNGB is PNGine’s bytecode, the compiled form of a .sjon document (SJON, the S-expression source language): a 40-byte header, an optional embedded executor (the small WASM interpreter that plays the bytecode), an opcode stream, and a set of trailing tables. All integers are little-endian.

The format is locked at version 0 by design. A self-contained payload carries its own interpreter (the embedded executor), so payload evolution rides on the executor rather than on the header: the WASM↔JS interface is what is versioned, and it is frozen as ABI v1 (see WASM API).

+--------------------------------------+
| Header (40 bytes) |
+--------------------------------------+
| Executor WASM (if embedded) |
+--------------------------------------+
| Bytecode (opcode stream) |
+--------------------------------------+
| String Table |
+--------------------------------------+
| Data Section |
+--------------------------------------+
| WGSL Table |
+--------------------------------------+
| Uniform Table |
+--------------------------------------+
| Animation Table |
+--------------------------------------+
| Device-Limits Table (if flag set) |
+--------------------------------------+
Offset Size Field Description
0 4 magic PNGB (0x50 0x4E 0x47 0x42)
4 2 version Format version (0)
6 2 flags Bitfield (below)
8 1 plugins Plugin set bitfield (below)
9 3 reserved u24 LE offset of the device-limits table when flags bit 2 is set; {0,0,0} otherwise
12 4 executor_offset Byte offset of the embedded executor (0 if none)
16 4 executor_length Executor WASM size in bytes
20 4 string_table_offset Byte offset of the string table
24 4 data_section_offset Byte offset of the data section
28 4 wgsl_table_offset Byte offset of the WGSL table
32 4 uniform_table_offset Byte offset of the uniform table
36 4 animation_table_offset Byte offset of the animation table

The header is frozen at 40 bytes. New optional tables use spare capacity (a free flags bit plus the reserved u24) and serialize to zero bytes when unused, so payloads that don’t use a feature stay byte-identical to older ones. The device-limits table (emitted by (limits …)) is the worked example.

Bit Name Description
0 has_embedded_executor Executor WASM is embedded in the payload
1 has_animation_table Animation table is present
2 has_device_limits Device-limits table is present; its offset is in reserved
3 canvas_alpha_premultiplied The document asked for (canvas :alpha-mode premultiplied); clear means WebGPU’s default, opaque
4–15 reserved 0

One byte naming the runtime features the payload needs. The compiler detects them from the document and picks the smallest executor variant that covers them.

Bit Plugin Covers
0 core Bytecode parsing, buffers, command emission (always set)
1 render Render pipelines, render passes, draw commands
2 compute Compute pipelines, dispatch
3 wasm Nested WASM execution ((wasm-call …), (wasm-data …))
4 animation Scene timeline tables
5 texture Image/texture loading
6–7 reserved 0

Executor variants as built today (uncompressed, measured 2026-08-18; the whole payload is DEFLATE-compressed inside the PNG, so the 11.8 KB render executor plus the triangle’s 631 bytes of bytecode land at 4.6 KB on disk):

Variant Size
pngine-core.wasm 9.1 KB
pngine-compute.wasm 9.4 KB
pngine-render.wasm 11.8 KB
pngine-render-anim.wasm 11.8 KB
pngine-render-compute.wasm 12.1 KB
pngine-render-compute-anim.wasm 12.1 KB
pngine-render-wasm.wasm 12.7 KB
pngine-full.wasm 13.6 KB

The opcode stream starts after the header (or after the executor, if embedded) and runs to string_table_offset. Each instruction is a 1-byte opcode followed by its operands.

Operand layouts are not listed here: they are defined in one place in the engine, src/bytecode/wire_schema.zig, which the emitter, the scanner and the dispatcher all read, so the three cannot drift. The opcode values themselves are append-only: never renumbered, never reused.

Resource creation (0x00–0x0E)

Code Name
0x00 nop
0x01 create_buffer
0x02 create_texture
0x03 create_sampler
0x04 create_shader_module
0x05 create_shader_concat (reserved: never emitted, dispatcher rejects it)
0x06 create_bind_group_layout
0x07 create_pipeline_layout
0x08 create_render_pipeline
0x09 create_compute_pipeline
0x0A create_bind_group
0x0B create_image_bitmap
0x0C create_texture_view
0x0D create_query_set
0x0E create_render_bundle

Pass operations (0x10–0x1F)

Code Name
0x10 begin_render_pass
0x11 begin_compute_pass
0x12 set_pipeline
0x13 set_bind_group
0x14 set_vertex_buffer
0x15 set_index_buffer
0x16 draw
0x17 draw_indexed
0x18 dispatch
0x19 end_pass
0x1A execute_bundles
0x1B begin_render_pass_mrt
0x1C draw_indirect
0x1D draw_indexed_indirect
0x1E dispatch_indirect
0x1F set_viewport

Queue operations (0x20–0x2C)

Code Name
0x20 write_buffer
0x21 write_uniform
0x22 copy_buffer_to_buffer
0x23 copy_texture_to_texture
0x24 submit
0x25 copy_external_image_to_texture
0x26 init_wasm_module
0x27 call_wasm_func
0x28 write_buffer_from_wasm
0x29 resolve_query_set
0x2A write_time_uniform
0x2B write_pointer_uniform
0x2C write_audio_data

Frame control (0x30–0x35)

Code Name
0x30 define_frame
0x31 end_frame
0x32 exec_pass
0x33 define_pass
0x34 end_pass_def
0x35 exec_pass_once

Pool operations (0x40–0x43): ping-pong resource selection for :pool 2 buffers

Code Name
0x40 select_from_pool
0x41 set_vertex_buffer_pool
0x42 set_bind_group_pool
0x43 begin_render_pass_pool

Extended pass state (0x4A–0x52)

Code Name
0x4A set_pass_timestamp_writes
0x4B set_pass_occlusion_query_set
0x4C end_occlusion_query
0x4D begin_occlusion_query
0x4E set_stencil_reference
0x4F set_scissor_rect
0x50 set_pass_depth_stencil_ops
0x51 set_blend_constant
0x52 set_pass_clear_values

Numeric operands use a 1/2/4-byte prefix code (payload bits big-endian within the value):

Value range Encoding Bytes
0–127 0xxxxxxx 1
128–16383 10xxxxxx xxxxxxxx 2
16384+ 11xxxxxx + 3 bytes 4

Interned strings: entry-point names, uniform field names, frame names.

count: u16
offsets: u16 × count (from the start of the string data)
lengths: u16 × count
data: UTF-8 bytes, not null-terminated

Binary blobs referenced by opcodes: shader code, vertex data, descriptors, embedded WASM modules.

count: u16
entries: (offset: u32, length: u32) × count
data: raw bytes

Three tables follow the data section, and a fourth is appended when its flag is set. Their byte layouts live with their reader/writer pairs in the engine source; the payload offsets above locate them.

Table Purpose Source of truth
WGSL Maps shader modules to data-section entries src/bytecode/format.zig
Uniform Per-binding uniform field metadata (name, offset, type); what setUniform resolves names against src/bytecode/uniform_table.zig
Animation Scene timeline (flags, duration, scene list). The runtime reads it, but no SJON form emits one today; see JavaScript API src/bytecode/animation_table.zig
Device limits requiredLimits for device acquisition, from (limits …) src/bytecode/format.zig

The uniform and device-limits tables are frontend-only: the JS runtime and the native renderer parse them, the embedded executor never does.

PNGine uses five ancillary chunks, all public and safe-to-copy, so image tools that don’t know them preserve them:

Chunk Contents
pNGb PNGB bytecode (executor embedded by default)
pNGm Animation metadata as JSON, for the JS runtime
pNGa Audio WASM module (e.g. a song compiled with the Sointu tracker synthesizer)
pNGf Flat pre-decoded command buffers for the small main-thread mini player (--flat)
pNGw DEFLATE-compressed WGSL source, used by --html output
version: u8 (0x01)
flags: u8 (bit 0: payload is DEFLATE-compressed)
payload: bytes (PNGB, optionally compressed)

Compression is raw DEFLATE with no zlib header, so browsers decompress it with DecompressionStream("deflate-raw"). The chunk sits immediately before IEND; the image pixels are untouched. pNGa and pNGf use the same two-byte wrapper; pNGm uses the version byte alone and stores its JSON uncompressed.

Inside that wrapper, a flat payload carries a header of its own:

version: u8 (2)
flags: u8 (bit 0: premultiplied canvas)
init_len: u32 LE Length of the init command buffer
frame_len: u32 LE Length of the per-frame command buffer
data_len: u32 LE Length of the inline data section
init_cmds, frame_cmds, data

The two buffers hold the executor’s command-buffer opcodes, decoded at compile time by replaying the bytecode against the mock GPU, which is why a pNGf payload carries no executor. The version is 2 because clear values became one f32 per channel and the pass commands changed shape with them; a player that reads any other value must refuse the payload rather than walk it.

pNGb follows PNG chunk-naming rules:

  • p lowercase: ancillary, so decoders that don’t know it still display the image
  • N uppercase: public namespace
  • G uppercase: reserved bit, must be uppercase
  • b lowercase: safe to copy through image editors

Compiled from the engine’s examples/ corpus (2026-08-18). .pngb is bare bytecode; .png is the self-contained artifact: 1×1 image, bytecode and DEFLATE-compressed executor:

Example .pngb Self-contained .png
simple_triangle 631 B 4,716 B
rotating_cube 3,901 B 6,082 B
boids (compute) 4,902 B 6,343 B