# SLM Protocol Specification

Version 4 (UDP) / Version 2 (SHM header)

The SLM (SLAY Media) protocol carries video - raw NV12, or encoded H.264/H.265 - and f32le stereo audio from a sender to one or more receivers over two transports:

- **UDP** - unicast datagrams for LAN or remote receivers
- **SHM** - shared memory ring buffer for receivers on the same machine (POSIX on Unix; Win32 Named File Mapping on Windows)

Both transports are active simultaneously. A receiver signals which one it uses via the `SLM_HELLO_FLAG_SHM` flag in its HELLO packet; the sender does not send UDP data to SHM receivers.

All multi-byte integers are **little-endian** unless noted otherwise.

More information at the [SLAY Media Protocol](https://slay.tools/protocol.html) home page.

---

## UDP Transport

### Common Header

Every SLM UDP datagram begins with a 12-byte header.

```
Offset  Size  Field       Description
------  ----  ---------   --------------------------------------------------
0       4     magic       0x53 0x4C 0x4D 0x01  ("SLM\x01")
4       1     type        Packet type (see below)
5       1     version     Protocol version - currently 4 (see Protocol Version History)
6       2     flags       Packet flags (u16 LE); see per-type notes
8       4     stream_id   Random u32 identifying the logical stream;
                          assigned at sender startup, included in all packets
                          so a receiver can detect a sender restart
```

### Packet Types

| Value | Name | Direction |
|---|---|---|
| 0x01 | ANNOUNCE | sender -> multicast |
| 0x02 | HELLO | receiver -> sender |
| 0x03 | CAPS | sender -> receiver |
| 0x04 | CHALLENGE | sender -> receiver |
| 0x10 | VIDEO | sender -> receivers |
| 0x11 | AUDIO | sender -> receivers |
| 0x12 | FEC | sender -> receivers |
| 0x20 | KEEPALIVE | receiver -> sender |
| 0x30 | TALLY | receiver -> sender |
| 0x31 | MSG | receiver -> sender |
| 0x32 | STAT | receiver -> sender |
| 0xFF | BYE | sender -> receivers |

---

### Discovery and Handshake

#### ANNOUNCE (0x01)

Sent by the sender to the multicast group `239.255.77.43:9878` approximately every 250 ms. Receivers monitor this group to discover senders on the local network.

Payload (67 bytes, follows common header):

```
Offset  Size  Field          Description
------  ----  -----------    ------------------------------------------------
12      2     data_port      Sender's unicast control port; receiver sends
                             HELLO to this port on the sender's IP address
14      64    name           Human-readable sender name, UTF-8, NUL-padded
78      1     source_flags   0x01 = SLM_SOURCE_AUDIO_ONLY (no video frames)
```

Total datagram: 79 bytes.

A receiver ignores any ANNOUNCE whose header `version` does not equal its own `SLM_PROTOCOL_VERSION` (the same check gates every UDP packet on the data path). The mismatch is silent: a sender on a different protocol version is never discovered and simply never appears in the receiver, with no error shown. Always confirm the `version` byte first when a sender fails to show up.

#### HELLO (0x02)

Sent by a receiver to the sender's `data_port` (from the ANNOUNCE) after discovery, and then periodically as a keepalive.

The `flags` field in the header carries receiver capability bits:

| Bit | Name | Meaning |
|---|---|---|
| 0x0001 | SLM_HELLO_FLAG_SHM | Receiver is using the SHM transport; do not send UDP data |

v4 payload (8 bytes, follows common header):

```
Offset  Size  Field        Description
------  ----  ----------   ------------------------------------------------
12      8     echo_nonce   Nonce from the most recent CHALLENGE (0 until one
                           has been received). The sender streams to this
                           address only once echo_nonce matches the value it
                           issued. See Address Validation below.
```

Total datagram: 20 bytes (v4; was 12 bytes, header only, in v3 and earlier).

On the first HELLO from an address, the sender registers the receiver, resets its liveness timer, and replies with a CHALLENGE (not CAPS). It replies with CAPS only once the address is validated (the receiver has echoed the nonce). See [Address Validation](#address-validation-v4).

#### CAPS (0x03)

Sent by the sender to a receiver once its address is validated (v4; see [Address Validation](#address-validation-v4)), in response to a HELLO carrying the correct `echo_nonce`. Describes the stream so the receiver can allocate buffers and configure its video/audio pipeline.

The sender re-sends CAPS in response to **every** validated HELLO (~250 ms), not just the first. The packet is idempotent: the receiver re-initialises its decoder only when the `codec` field changes, so the repeats add no cost, and a CAPS lost to UDP drop self-heals on the next HELLO. A receiver gates all VIDEO/AUDIO on having a valid CAPS - until the first CAPS arrives it drops every media packet (the source stays black), so a sender that sends CAPS only once and loses that single packet never displays.

Payload (20 bytes, follows common header):

```
Offset  Size  Field               Description
------  ----  -----------------   -------------------------------------------
12      2     width               Frame width in pixels
14      2     height              Frame height in pixels
16      4     fps_num             Frame rate numerator
20      4     fps_den             Frame rate denominator
24      1     pixel_fmt           1=NV12, 2=I420, 3=BGRA, 4=YUY2
25      1     compression         Compression the sender offers: 0=none, 1=LZ4
26      2     audio_sample_rate   Audio sample rate (48000)
28      1     audio_channels      Audio channel count (2 = stereo)
29      1     codec               Video codec: 0=raw NV12, 1=H.264, 2=H.265
30      2     _pad                Padding to 4-byte alignment
```

Total datagram: 32 bytes.

The `codec` field (added in protocol v2; it was `_pad[0]` in v1, so a v1 receiver reads it as 0 = raw) selects how the receiver interprets subsequent VIDEO payloads. See [Codecs](#codecs). A receiver that does not support the offered codec must disconnect cleanly rather than render garbage.

#### CHALLENGE (0x04)

Sent by the sender to a receiver's source address in response to a HELLO from an address it has not yet validated. Carries a random nonce the receiver must echo (in its HELLO `echo_nonce` field) before the sender will stream media to it.

Payload (8 bytes, follows common header):

```
Offset  Size  Field   Description
------  ----  -----   --------------------------------------------------
12      8     nonce   Random per-address validation nonce (little-endian)
```

Total datagram: 20 bytes.

### Address Validation (v4)

UDP source addresses are spoofable, so a sender that streamed to whatever address sent a HELLO could be tricked into blasting video at a victim whose address an attacker forged (a reflection/amplification attack). v4 closes this with a return-routability check, giving the push-UDP path the address guarantee TCP gives for free:

1. On the first HELLO from an address X, the sender stores a random nonce and sends CHALLENGE(nonce) to X. It does **not** stream media or send CAPS yet.
2. The receiver records the nonce and echoes it in the `echo_nonce` field of every subsequent HELLO.
3. When the sender sees a HELLO from X whose `echo_nonce` matches the issued nonce, it marks X validated, replies with CAPS, and starts streaming.

A spoofer that forges a victim's source address never receives the CHALLENGE (it is sent to the victim), so it can never echo the right nonce and the flood never starts. The nonce is unguessable to an off-path attacker.

Validation is a one-time, connect-time cost. The validated state persists for the life of the receiver entry, so clip start, seek, loop, and BYE/CAPS never re-validate and add no latency to the steady-state media path (a core design principle). A genuine reconnect (the entry expired after 10 s with no HELLO) re-runs the one-round-trip handshake. Validation only ever transitions not-validated -> validated; a later HELLO with a missing or wrong nonce never revokes a live entry, so a spoofed HELLO cannot de-validate a real receiver.

The sender caps simultaneous UDP receivers at 16 to bound memory against a spoofed-HELLO flood.

---

### Data Packets

#### VIDEO (0x10)

Video frames are split into chunks of at most 1400 bytes of payload each (fits under a 1500-byte Ethernet MTU with 28 bytes of IP+UDP overhead). Each chunk is a separate UDP datagram with the same `frame_seq`; the receiver reassembles by collecting all `chunk_total` chunks for a given `frame_seq`.

Chunk header (36 bytes, follows common header):

```
Offset  Size  Field             Description
------  ----  ---------------   -------------------------------------------
12      8     timestamp_ns      Sender capture time (sender's monotonic clock)
20      4     frame_seq         Monotonically increasing frame counter;
                                wraps at u32 max
24      4     uncompressed_sz   RAW: reassembled uncompressed frame size.
                                H.264/H.265: total encoded access-unit length.
28      4     compressed_sz     RAW: LZ4 block length (0 = stored uncompressed).
                                H.264/H.265: frame type (0=P, 1=IDR) - not a size.
32      2     chunk_index       0-based index of this chunk within the frame
34      2     chunk_total       Total number of chunks for this frame
36      1     compression       0=none, 1=LZ4 (actual compression used)
37      3     _pad
40      8     sender_ts_ns      UNIX-epoch ns when the sender transmitted this
                                chunk (v3+); used for one-way latency measurement
```

Payload immediately follows at offset 48 (up to 1400 bytes of frame data).

Total per-chunk overhead: 48 bytes.

**Reassembly:** the receiver allocates a buffer of `uncompressed_sz` bytes keyed on `frame_seq`. Each chunk's payload is written at `chunk_index * max_payload_size`. When all `chunk_total` chunks have arrived, the buffer is decompressed (if `compression != 0`) and passed to the consumer. Incomplete frames are discarded after one frame-duration timeout.

**Compression (RAW codec):** when LZ4 is offered in CAPS and enabled by the sender, the entire frame is compressed with LZ4 before chunking. `compressed_sz` carries the size of the compressed blob; `uncompressed_sz` is the original size. If compression would increase the size, the sender sends uncompressed and sets `compressed_sz = 0`.

The Rust implementation uses `lz4_flex::compress_prepend_size` which prepends the original length as a 4-byte LE header before the LZ4 data; the sender strips that prefix so the wire carries a bare LZ4 block (the C consumer uses `LZ4_decompress_safe`, which expects no prefix), and `compressed_sz` is the bare block length.

**Encoded codecs (H.264/H.265):** the payload is a raw Annex B access unit and is never LZ4-compressed (`compression` is always 0). The `compressed_sz` field is repurposed to carry the frame type - `1` for an IDR access unit (a decoder entry point), `0` for a P-frame - so a receiver can identify join points without parsing the NAL stream. `uncompressed_sz` holds the total access-unit byte length.

#### AUDIO (0x11)

Audio is always uncompressed. A packet carries one chunk of interleaved f32le PCM at 48 kHz stereo. Packets are sized to fit under the MTU: at most 172 stereo sample-pairs per datagram (172 x 2 x 4 = 1376 bytes of PCM).

Audio header (24 bytes, follows common header):

```
Offset  Size  Field          Description
------  ----  -----------    -----------------------------------------------
12      8     timestamp_ns   Sender capture time (sender's monotonic clock)
20      4     frame_seq      Monotonically increasing audio counter
24      2     num_frames     Audio frames (samples per channel) in this packet
26      1     channels       Channel count (2 = stereo)
27      1     compression    Always 0 (reserved)
28      8     sender_ts_ns   UNIX-epoch ns when the sender transmitted this
                             packet (v3+); used for one-way latency measurement,
                             mirroring the VIDEO chunk field
```

Interleaved f32le PCM payload follows immediately.

Total overhead: 36 bytes.

For audio blocks larger than 172 stereo sample-pairs, the sender splits them into multiple sequential packets. Each split packet gets an offset `timestamp_ns` so that:

```
packet[i].timestamp_ns = base_ts + i * (MAX_AUDIO_FRAMES * 1e9 / SAMPLE_RATE)
```

This lets the receiver schedule each packet independently without needing to track block boundaries.

#### FEC (0x12)

Optional forward error correction for the video path on lossy links (e.g. WiFi). When enabled, the sender emits one FEC packet after every `fec_n` VIDEO chunks of a frame. The parity is the byte-wise XOR of those chunks' payloads (the bytes after the 48-byte VIDEO overhead), zero-padded to 1400 bytes.

FEC header (20 bytes, follows common header):

```
Offset  Size  Field          Description
------  ----  -----------    -----------------------------------------------
12      8     timestamp_ns   Same value as the data chunks this packet covers
20      4     frame_seq      Same value as the data chunks this packet covers
24      2     chunk_total    Total data chunks for this frame
26      1     fec_n          Group size (data chunks covered per FEC packet)
27      1     _pad
28      2     fec_group      0-based group index within this frame
30      2     _pad
```

XOR-parity payload (1400 bytes) follows immediately. Total datagram: 1432 bytes.

**Recovery:** a receiver that loses exactly one chunk within a group reconstructs it by XORing the FEC parity with the other received chunks in that group. Only single-chunk losses per group are recoverable; a group missing two or more chunks is unrecoverable and the frame is dropped at the reassembly timeout. FEC is disabled (no FEC packets sent) when `fec_n < 2`.

---

### Control Packets

#### KEEPALIVE (0x20)

Sent by receivers periodically to prevent the sender from expiring them. Structurally identical to HELLO, including the v4 `echo_nonce` body (20 bytes); the `flags` field carries the same `SLM_HELLO_FLAG_SHM` bit, and the sender applies the same address validation, so a KEEPALIVE carrying the correct nonce validates the address just like a HELLO.

The sender resets the receiver's liveness timer on every HELLO or KEEPALIVE. Receivers that have not sent either within 10 seconds are removed and will no longer receive data until they send a new HELLO.

#### BYE (0xFF)

Sent by the sender to all registered receivers on clean shutdown or stop. Header only, 12 bytes. Receivers should treat this as a stream-end signal and flush their buffers.

---

### Feedback Packets (receiver -> sender)

Optional unicast packets a receiver may send back to the sender's `data_port` (the same port it sends HELLO to). They carry no media; they let a receiver such as the OBS plugin signal on-air status and operator messages to a sender such as the slay-camera mobile app. Both are additive - a sender that does not recognise them drops them silently - so they require no protocol version bump.

#### TALLY (0x30)

Reports the sender's on-air status in the receiver's program. The OBS plugin emits it on every program/preview transition, **and** alongside every HELLO, so a sender that reconnects gets the current state immediately without waiting for the next transition.

Payload (1 byte, follows common header):

```
Offset  Size  Field   Description
------  ----  -----   --------------------------------------------------
12      1     state   0 = off (neither preview nor program)
                      1 = preview
                      2 = program (on air)
```

Total datagram: 13 bytes. Fire-and-forget; no acknowledgement.

#### MSG (0x31)

Delivers a short free-text operator message (e.g. "stand by", "zoom in"). Display is up to the sender that receives it: the slay-camera app shows a dismissible banner; SLAY Cart appends it to its on-screen Messages log.

Payload (1 to 256 bytes, follows common header):

```
Offset  Size  Field    Description
------  ----  ------   -------------------------------------------------
12      1     length   Number of UTF-8 bytes that follow (0-255)
13      N     text     UTF-8 message body, not NUL-terminated
```

Total datagram: 13 to 268 bytes. Fire-and-forget; no acknowledgement. The text is truncated on a byte boundary at 255 bytes, which may split a multi-byte UTF-8 sequence; receivers should decode leniently.

#### STAT (0x32)

Reports transport latency by echoing back the `sender_ts_ns` the receiver read off the most recently consumed frame (SHM or UDP). The receiver never reads its own clock - it reflects the sender's timestamp verbatim - so the sender measures elapsed time entirely on its own clock and no clock synchronisation is required. The OBS plugin emits it at ~2 Hz from the frame-consume path (rate-limited; the echoed timestamp is always that of a just-consumed frame, so receiver-side holding is negligible).

Payload (8 bytes, follows common header):

```
Offset  Size  Field         Description
------  ----  -----------   ------------------------------------------------
12      8     echo_ts_ns    sender_ts_ns of the consumed frame, echoed back
```

Total datagram: 20 bytes. Fire-and-forget; no acknowledgement.

The sender computes `elapsed = now - echo_ts_ns`. For a same-machine **SHM** receiver this is the one-way frame staleness directly (one shared clock, negligible loopback return). For a cross-machine **UDP** receiver the absolute one-way value is unreliable (no clock sync), so the sender treats `elapsed` as a round trip and reports **RTT/2**. Values are smoothed (EMA) and, with multiple receivers on a transport, the worst case is reported.

---

### Pixel Formats

| Value | Name | Description |
|---|---|---|
| 1 | NV12 | Y plane (WxH), then interleaved UV plane (WxH/2). The only pixel format the current sender produces for the RAW codec. |
| 2 | I420 | Planar YUV 4:2:0 |
| 3 | BGRA | 32-bit packed |
| 4 | YUY2 | Packed 4:2:2 |

Color matrix: BT.709, limited range for YUV formats. `pixel_fmt` applies only to the RAW codec; for H.264/H.265 the VIDEO payload is an encoded bitstream and `pixel_fmt` is ignored.

---

### Codecs

The `codec` field in CAPS selects how a receiver interprets VIDEO payloads:

| Value | Name | VIDEO payload |
|---|---|---|
| 0 | RAW | NV12 (per `pixel_fmt`), optionally LZ4-compressed per frame |
| 1 | H.264 | H.264 Annex B access units, no container |
| 2 | H.265 | H.265 Annex B access units, no container |

For RAW, `pixel_fmt` is meaningful and frames may be LZ4-compressed. For H.264/H.265, `pixel_fmt` is ignored, `compression` is always 0, and the per-chunk `compressed_sz` field carries the frame type (P vs IDR) instead of a length (see [VIDEO](#video-0x10)). A receiver must disconnect cleanly if it does not support the offered codec.

---

## SHM Transport

When the receiver is on the same machine as the sender, frames are exchanged through a shared memory ring buffer. This avoids the kernel UDP socket buffer limit (`net.core.rmem_max`) that would otherwise cap throughput at high resolutions.

The shared data layout - header, slot ring, frame headers, and payloads - is **byte-for-byte identical** on all platforms. Only the OS primitives used to create and signal the region differ.

### Platform-Specific Object Names and Primitives

#### Unix (Linux, macOS)

| Resource | Name |
|---|---|
| SHM region | `/slm-<sanitized-name>` |
| Semaphore | `/slm-<sanitized-name>-sem` |

Created with `shm_open` + `ftruncate` + `mmap(PROT_READ|PROT_WRITE, MAP_SHARED)`. Semaphore created with `sem_open`; producer signals with `sem_post`. On clean shutdown, both are unlinked via an `atexit` handler and in `Drop`.

On startup, if an existing region is found with a stale heartbeat (or wrong magic/version), it is unlinked and recreated. This handles crashes that left the POSIX name in `/dev/shm`.

#### Windows

| Resource | Name |
|---|---|
| Named File Mapping | `Local\slm-<sanitized-name>` |
| Named Semaphore | `Local\slm-<sanitized-name>-sem` |

Created with `CreateFileMappingA(INVALID_HANDLE_VALUE, ...)` (page-file-backed) + `MapViewOfFile(FILE_MAP_ALL_ACCESS)`. Semaphore created with `CreateSemaphoreA`; producer signals with `ReleaseSemaphore`. `Local\` scopes the object to the current login session.

No explicit unlink is needed on shutdown: Windows destroys page-file-backed mappings and named semaphores automatically when all handles are closed (`UnmapViewOfFile` + `CloseHandle` in `Drop` is sufficient).

On startup, if `CreateFileMappingA` returns `ERROR_ALREADY_EXISTS` (e.g. the OBS consumer still holds a read handle from a prior session), the producer peeks at the heartbeat. If stale, the header is reinitialized in-place. If a live producer is detected, `create` returns `None` and the sender falls back to UDP.

### Name Sanitization

Replace spaces, `/`, and `\` with `-`, then prepend the platform prefix.

Example: sender name `"SLAY Media"`:
- Unix -> `/slm-SLAY-Media` and `/slm-SLAY-Media-sem`
- Windows -> `Local\slm-SLAY-Media` and `Local\slm-SLAY-Media-sem`

### Region Layout

```
Offset 0:    slm_shm_hdr_t (128 bytes, fixed)
Offset 128:  Slot 0  [ slm_shm_frame_hdr_t (24 bytes) | payload (max_frame_bytes) ]
             Slot 1  [ ... ]
             ...
             Slot 15 [ ... ]
```

`max_frame_bytes = width x height x 3 / 2` (sized for NV12; audio frames are smaller and always fit).

Total region size at 1920x1080: 128 + 16 x (24 + 3,110,400) ~ **47.5 MB**.

### Region Header (`slm_shm_hdr_t`, 128 bytes)

```
Offset  Size  Field               Description
------  ----  -----------------   -------------------------------------------
0       4     magic               0x534C4D02 (SHM-specific; differs from UDP magic)
4       4     version             2
8       2     width               Frame width in pixels
10      2     height              Frame height in pixels
12      4     fps_num             Frame rate numerator
16      4     fps_den             Frame rate denominator
20      1     pixel_fmt           Same values as UDP CAPS
21      1     source_flags        Same values as UDP ANNOUNCE
22      2     audio_sample_rate   48000
24      1     audio_channels      2
25      1     caps_valid          Producer sets to 1 after filling all cap fields
26      1     disconnect          Producer sets to 1 on stop; consumer flushes queue
27      1     _pad
28      4     max_frame_bytes     Per-slot payload capacity in bytes
32      4     write_seq           Ring write index (atomic u32, Release on write)
36      4     _pad2               Aligns heartbeat_ns to 8-byte boundary
40      8     heartbeat_ns        UNIX-epoch ns; producer updates ~every 500 ms
48      64    sender_name         Original sender name, UTF-8, NUL-padded
112     16    _reserved
```

### Frame Slot Header (`slm_shm_frame_hdr_t`, 24 bytes)

```
Offset  Size  Field          Description
------  ----  -----------    -----------------------------------------------
0       8     timestamp_ns   Sender's monotonic clock ns at capture
8       4     seq            Frame/audio sequence counter
12      4     size           Actual payload bytes in this slot
16      1     is_video       1 = NV12 video, 0 = f32le audio
17      3     _pad
20      4     _pad2          Explicit trailing padding (total = 24 bytes)
```

Payload follows immediately after the frame header.

### Ring Buffer Protocol

**Producer (write):**
1. Select slot: `slot = slots[write_seq % 16]`
2. Write frame header and payload into the slot
3. Update `heartbeat_ns` (atomic store, Relaxed)
4. Atomically increment `write_seq` (store, **Release** ordering)
5. Signal the semaphore (`sem_post` on Unix; `ReleaseSemaphore` on Windows)

**Consumer (read):**
1. Wait on semaphore with a 200 ms timeout (for graceful shutdown)
2. Read current `write_seq` (load, **Acquire** ordering)
3. Drain all slots from `last_read_seq` to `write_seq`, calling the frame handler for each
4. Update `last_read_seq = write_seq`

A consumer that falls more than 16 slots behind will miss frames silently (the producer overwrites). In practice the consumer runs faster than the producer at any realistic frame rate.

### Liveness and Stale Detection

The producer updates `heartbeat_ns` to UNIX-epoch nanoseconds (`SystemTime::now()` / `CLOCK_REALTIME`) on every frame write and at minimum every 500 ms from its idle loop. The consumer checks `heartbeat_ns` on attach and periodically during the read loop. If the value has not changed for more than **5 seconds**, the producer is considered dead and the consumer falls back to UDP discovery.

### Connect / Disconnect Lifecycle

- **Producer startup**: creates (or reinitializes) the region, fills all cap fields, sets `caps_valid = 1`
- **Consumer attach**: waits for `caps_valid`, maps the region, records a timestamp origin based on the first frame's `timestamp_ns`
- **Clip start**: producer calls `clear_disconnect()` (sets `disconnect = 0`) before the first frame
- **Clip end / stop**: producer calls `send_disconnect()` (sets `disconnect = 1`, then signals semaphore). Consumer detects this, flushes OBS's async video queue so the frozen last frame clears from the canvas, and re-syncs its ring read position to the producer's current `write_seq`
- **Producer restart**: the region name and semaphore are reused; the consumer sees `caps_valid` reset to 0 then back to 1 and re-initialises

SHM receivers send HELLO packets with `SLM_HELLO_FLAG_SHM` set. The sender tracks these for status reporting but does not send UDP data to them.

---

## Session Lifecycle (UDP)

```
Sender                                  Receiver
  |                                        |
  |-- ANNOUNCE (multicast, ~250 ms) -----> |  receiver discovers sender
  |                                        |
  |<-- HELLO (echo_nonce=0) ------------- |  receiver registers (pending)
  |-- CHALLENGE (nonce) ----------------> |  sender asks it to prove its address
  |                                        |
  |<-- HELLO (echo_nonce=nonce) --------- |  receiver echoes
  |-- CAPS (unicast) -------------------> |  sender validates; receiver configures
  |                                        |
  |-- VIDEO / AUDIO (unicast, each frame)->|  continuous stream (validated only)
  |                                        |
  |<-- HELLO/KEEPALIVE (~250 ms) -------- |  receiver stays registered
  |                                        |
  | ... (10 s no HELLO/KEEPALIVE) ...      |  sender expires receiver silently
  |                                        |
  |-- BYE (unicast) --------------------> |  sender shutting down
```

A receiver that misses BYE will be expired after 10 s of silence. A sender that restarts gets a new `stream_id`; receivers that reconnect after a restart get a fresh CAPS.

---

## Protocol Version History

| Version | Transport | Notes |
|---|---|---|
| 1 | UDP | Initial release. Common header, ANNOUNCE/HELLO/CAPS/VIDEO/AUDIO/BYE. |
| 2 | UDP | Codec negotiation: `codec` field in CAPS (reusing `_pad[0]`, so v1 receivers read 0 = raw). For H.264/H.265 the VIDEO `compressed_sz` field carries the frame type (P/IDR) instead of a length. |
| 3 | UDP | `sender_ts_ns` (UNIX-epoch send time) appended to the VIDEO chunk header (per-chunk overhead 40 -> 48 bytes) and to the AUDIO header (overhead 28 -> 36 bytes), for one-way latency measurement. Sender and receiver must be built from the same generation - the field shifts the audio payload offset, so a mismatched pair mis-parses audio. |
| 3 | UDP | Additive packet types, no further header bump: FEC (`0x12`) XOR parity for the WiFi video path; TALLY (`0x30`) and MSG (`0x31`) receiver->sender feedback (June 2026). Senders/receivers that don't recognise these drop them silently. |
| 4 | UDP | Receiver address validation: new CHALLENGE (`0x04`) packet, and an 8-byte `echo_nonce` appended to HELLO/KEEPALIVE (header-only -> 20 bytes). The sender streams only after the receiver echoes the challenge nonce, defeating UDP source-address spoofing (reflection/amplification). Connect-time only; persists for the life of the receiver entry, so no steady-state cost. |
| 1 | SHM v1 | 64-byte SHM header; no heartbeat field, no `sender_name`. |
| 1 | SHM v2 | 128-byte header; adds `heartbeat_ns` at offset 40 and `sender_name` at offset 48. Identified by `SLM_SHM_VERSION = 2`. |
| 1 | SHM v2 (Windows) | Same layout as SHM v2; uses Win32 Named File Mappings and Named Semaphores in place of POSIX primitives. No version bump - the shared data format is unchanged. |

The UDP header `version` byte is `4`. New packet types (FEC, TALLY, MSG, STAT) are additive and do not themselves bump the version; changes to existing packet layouts do: the CAPS `codec` field at v2, the VIDEO and AUDIO `sender_ts_ns` fields at v3, and the HELLO/KEEPALIVE `echo_nonce` body at v4 (the CHALLENGE packet at v4 is additive, but the HELLO layout change drives the bump). The SHM version is stored separately in `slm_shm_hdr_t.version`; a consumer that reads SHM version 1 falls back to UDP.

---

## Constants Reference

| Constant | Value | Description |
|---|---|---|
| `SLM_MCAST_GROUP` | `239.255.77.43` | Multicast group for ANNOUNCE |
| `SLM_MCAST_PORT` | `9878` | Multicast port |
| `SLM_MAX_UDP_PAYLOAD` | `1400` bytes | Max chunk payload; fits 1500-byte MTU |
| `SLM_PROTOCOL_VERSION` | `4` | UDP protocol version |
| `SLM_SHM_MAGIC` | `0x534C4D02` | SHM region magic (distinct from UDP `SLM\x01`) |
| `SLM_SHM_VERSION` | `2` | SHM header version |
| `SLM_SHM_SLOTS` | `16` | Ring buffer slot count |
| `SLM_SHM_HDR_SIZE` | `128` bytes | Fixed SHM header size |
| `SLM_SHM_STALE_SECS` | `5` s | Heartbeat age threshold for dead-producer detection |
| Audio sample rate | `48000` Hz | Fixed for all streams |
| Audio channels | `2` | Stereo interleaved |
| Samples per frame | `1600` | 33.33 ms per frame at 48 kHz |
| Max audio frames/pkt | `172` | 172 x 2ch x 4B = 1376B PCM, fits MTU |
| Receiver expiry | `10` s | HELLO/KEEPALIVE timeout |
