Binary Protocol — Dala UI Render Pipeline

Copy Markdown View Source

Overview

Dala's UI render pipeline uses a custom binary protocol to transfer UI trees and incremental patches from Elixir (BEAM) to the native side (Rust NIF → SwiftUI/Compose).

Key Benefits

  1. Zero-copy at boundary: Rustler's Binary<'a> maps directly to BEAM off-heap binaries
  2. No parsing overhead: Rust NIF reads structured binary data directly
  3. Compact encoding: Binary format is 3-5x smaller than JSON
  4. Type-safe: Well-defined format with versioning support

Binary Format Specification

Version History

VersionUsed ForDescription
3Full Trees & PatchesCurrent format — magic header, SHA-256 node IDs, extended tags

Note: Versions 1 and 2 are deprecated. All current tooling uses version 3.


Full Tree Format (Version 3)

Used by Dala.Ui.Renderer.encode_tree/1 and Dala.Platform.Native.set_root_binary/1.

[2 bytes magic 0xDAA1][2 bytes version=3][8 bytes node_count]
FieldSizeDescription
magic2 bytes0xDA, 0xA1 — identifies Dala protocol
version2 bytesAlways 3 (little-endian)
node_count8 bytesTotal number of nodes in the tree (little-endian)

Node Encoding

Each node is encoded as:

[u64 id][u8 type][u8 field_count][props...][u32 child_count][u64 child_ids...]

Node ID (8 bytes)

A 64-bit hashed identifier for the node, computed by Dala.Ui.Renderer.hash_id/1:

defp hash_id(id) do
  id_str = to_string(id)
  <<hash::unsigned-64-big, _rest::binary>> = :crypto.hash(:sha256, id_str)
  hash
end

This ensures stable, deterministic IDs for diffing using SHA-256 hashing.

Node Type (1 byte)

Byte ValueAtom
0:column
1:row
2:text
3:button
4:image
5:scroll
6:webview
7+Custom / plugin types
0 (default)Unknown types default to 0

Property Count (1 byte)

Number of properties encoded for this node (0-255).

Properties (variable)

Each property is encoded as:

[u8 tag][value...]

See Property Encoding section below. Tags now extend to 16+ for new component properties.

Child Count (4 bytes)

Number of children this node has (little-endian u32).

Child IDs (variable)

Array of child_count × 8 bytes, each child ID is a u64 (little-endian).


Patch Frame Format (Version 3)

Used by Dala.Ui.Renderer.encode_frame/1 and Dala.Platform.Native.apply_patches/1.

Header

[2 bytes magic 0xDAA1][2 bytes version=3][2 bytes flags][2 bytes patch_count]
FieldSizeDescription
magic2 bytes0xDA, 0xA1 — identifies Dala protocol
version2 bytesAlways 3 (little-endian)
flags2 bytesReserved (currently 0)
patch_count2 bytesNumber of patches in this frame (little-endian)

Patch Opcodes

op_frame_begin (0x00)

[u8 opcode=0x00]

Delimits the start of a frame. Used for frame boundary detection.

op_create_node (0x01) — INSERT

[u8 opcode=0x01][u64 id][u64 parent_id][u32 index][u8 type][u64 layout_hash][props...][u32 child_count][u64 child_ids...]

Insert a new node under parent_id at position index.

op_remove (0x02) — REMOVE

[u8 opcode=0x02][u64 id]

Remove the node with the given id.

op_update (0x03) — UPDATE

[u8 opcode=0x03][u64 id][props...]

Update properties on the node with the given id.

op_patch_node (0x04)

[u8 opcode=0x04][u64 id][u16 field_mask][changed props...]

Update specific fields on a node by bitmask. More efficient than a full property update.

op_register_string (0x05)

[u8 opcode=0x05][u16 string_id][u16 len][bytes...]

Pre-register a string with the native side for faster subsequent references.

op_set_text (0x06)

[u8 opcode=0x06][u64 id][u16 len][bytes...]

Fast-path for text-only updates on :text and :button nodes.

op_set_style (0x07)

[u8 opcode=0x07][u64 id][props...]

Update style properties on a node without a full props replacement.

op_event (0x08)

[u8 opcode=0x08][u64 target_id][u8 event_type][u64 timestamp][u16 payload_len][payload_bytes...]

Event delivery from native to Elixir (reverse direction).

op_frame_end (0xFF)

[u8 opcode=0xFF]

Delimits the end of a frame.


Property Encoding

Properties are encoded as tagged values. The field_count byte in the node header indicates how many properties follow.

Property Tags

TagNameValue FormatElixir Type
1:text[u16 len][bytes]binary string
2:title[u16 len][bytes]binary string
3:color[u16 len][bytes]binary string (e.g., "red")
4:background[u16 len][bytes]binary string
5:on_tap[u64 handle]NIF tap handle (integer)
6:width[f32]float (little-endian)
7:height[f32]float (little-endian)
8:padding[f32]float (little-endian)
9:flex_grow[f32]float (little-endian)
10:flex_direction[u8]byte (0=column, 1=row)
11:justify_content[u8]byte (0=start, 1=center, 2=end, 3=space_between)
12:align_items[u8]byte (0=start, 1=center, 2=end, 3=stretch)
13:thickness[f32]float (little-endian)
14:fixed_size[f32]float (little-endian)
15+(extended)variesReserved for new component properties

Enum Encoding

Flex Direction (tag 10)

ByteAtom
0:column (default)
1:row

Justify Content (tag 11)

ByteAtom
0:start (default)
1:center
2:end
3:space_between

Align Items (tag 12)

ByteAtom
0:start (default)
1:center
2:end
3:stretch

Implementation Details

Elixir Side (Encoder)

Located in lib/dala/ui/renderer.ex:

# Full tree encoding
Dala.Ui.Renderer.encode_tree(%Dala.Node{} = node)

# Patch frame encoding
Dala.Ui.Renderer.encode_frame([patch1, patch2, ...])

# Hash function for stable node IDs (SHA-256)
Dala.Ui.Renderer.hash_id(id)  # returns u64 integer

Rust NIF Side (Decoder)

The Rust NIF (native/dala_nif/src/protocol.rs) receives binaries via Rustler:

#[rustler::nif]
fn set_root_binary(binary: Binary) -> NifResult<Atom> {
    let bytes: &[u8] = binary.as_slice();
    // Parse header: magic (0xDAA1), version (3), node_count
    // Then parse each node...
}

Rustler's Binary type provides zero-copy access to BEAM off-heap binaries.


Usage Examples

Encoding a Full Tree

node = %Dala.Node{
  id: "root",
  type: :column,
  props: %{padding: 10, background: "blue"},
  children: [
    %Dala.Node{
      id: "text1",
      type: :text,
      props: %{text: "Hello World"},
      children: []
    }
  ]
}

binary = Dala.Ui.Renderer.encode_tree(node)
# => <<218, 161, 3, 0, 0, 0, 0, 0, 0, 0, 2, ...>>

Encoding Patches

patches = [
  {:remove, "old_node"},
  {:update_props, "node1", %{text: "Updated"}},
  {:insert, "parent", 0, %Dala.Node{...}}
]

binary = Dala.Ui.Renderer.encode_frame(patches)
# => <<218, 161, 3, 0, 2, 0, ...>>

In Render Functions

# Dala.Ui.Renderer.render/4 now uses binary protocol
def render(tree, platform, nif \\ @default_nif, _transition \\ :none) do
  node = to_node(tree, "root")
  binary = encode_tree(node)
  nif.set_root_binary(binary)  # Instead of nif.set_root(json)
  {:ok, :binary_tree}
end

Testing

Test file: test/dala/binary_protocol_test.exs

# Example test
test "encodes a simple text node" do
  node = %Dala.Node{
    id: "text1",
    type: :text,
    props: %{text: "Hello"},
    children: []
  }

  binary = Dala.Ui.Renderer.encode_tree(node)
  assert is_binary(binary)
  assert byte_size(binary) > 10
end

Run tests:

mix test test/dala/binary_protocol_test.exs

Performance Considerations

Zero-Copy Boundary

Elixir BEAM (off-heap binary)  Rustler Binary<'a>  &[u8]

No copying occurs at the BEAM↔Rust boundary. The binary data is referenced directly.

iodata Usage

The encoder uses Elixir's iodata to build the binary efficiently:

IO.iodata_to_binary([
  <<0xDA, 0xA1, version::little-16, flags::little-16, node_count::little-64>>,
node_binaries
])

This avoids intermediate binary allocations.

Node Count

The node_count field in the header allows the Rust side to pre-allocate memory if desired.


Future Extensions

Flags Field

The 16-bit flags field in version 3 header is reserved for:

  • Compression indication
  • Encryption indication
  • Custom extensions

Additional Opcodes

New opcodes (0x0B+) can be added for custom patch operations without breaking backward compatibility.

Additional Property Types

New property tags can be added (14, 15, 16, ...) without breaking backward compatibility.


References

  • Implementation: lib/dala/ui/renderer.ex (functions encode_tree, encode_frame, hash_id)
  • NIF declarations: lib/dala/platform/native.ex (set_root_binary/1, apply_patches/1)
  • Tests: test/dala/binary_protocol_test.exs (Elixir) and native/dala_nif/src/protocol.rs (Rust)
  • Rust NIF decoder: native/dala_nif/src/protocol.rs (fully implemented with 21+ tests)