Skip to main content

FORMAT.md

# TTYCast container format

All integers are unsigned big-endian. Terms are Erlang External Term Format.

## Layout

```text
magic               "TTYCAST\0"
version             u16
header_len          u32
header_etf          header_len bytes
chunk*
trailer_magic       "TTYCAST_INDEX\0"
trailer_len         u64
trailer_etf         trailer_len bytes
footer_magic        "TTYCAST_FOOTER\0"
trailer_offset      u64
```

The footer lets readers open large recordings with one end-of-file read and one trailer `pread`. Writers also maintain `<recording>.live.idx` after each chunk flush. If a process crashes after writing chunks but before writing the trailer/footer, readers can fall back to the live index; `TTYCast.reindex/1` scans intact chunks and writes a fresh trailer/footer.

## Header

The header is an ETF map with dimensions, codec, input policy, metadata, and chunk thresholds.

## Chunk

```text
compressed_len      u64
uncompressed_len    u64
start_t_us          u64
end_t_us            u64
event_count         u32
payload_gzip        compressed_len bytes
```

The uncompressed payload is an ETF map:

```elixir
%{
  seq: non_neg_integer(),
  start_t_us: non_neg_integer(),
  end_t_us: non_neg_integer(),
  event_count: non_neg_integer(),
  keyframe: %{format: :ghostty_snapshot, t_us: integer(), plain: binary(), vt: binary()},
  events: [event]
}
```

Chunks are independently compressed so seeking reads only the nearest keyframe chunk and forward deltas. Keyframes are stored periodically according to `:keyframe_interval_ms`; chunks without a keyframe use `keyframe: nil`.

## Events

Core events:

```elixir
{:output, t_us, bytes}
{:input, t_us, bytes}
{:input_redacted, t_us, byte_count}
{:resize, t_us, cols, rows}
{:marker, t_us, name, metadata}
{:event, t_us, stream, payload}
```

Record raw input only in disposable sessions. The default input policy is `:redacted`; use `:raw` only when explicitly needed, or `:none` to drop input events entirely.