# Embedding punktfunk-core (the C ABI)

This guide is for developers who want to build their **own** punktfunk client — on a platform we
don't ship an app for — by linking `punktfunk-core` through its stable C ABI. It covers what the
core does (and, importantly, what it doesn't), how to build and link it, the full client lifecycle,
and worked integration blueprints for **webOS**, **Xbox**, and **Tizen**.

The authoritative header is [`include/punktfunk_core.h`](../include/punktfunk_core.h) — it is
generated from Rust by cbindgen and every symbol carries a doc comment. This guide is the narrative;
the header is the contract.

---

## 1. What the core gives you — and what it doesn't

`punktfunk-core` is the *one* implementation of the wire format, shared by the host and every
first-party client. When you embed it you get the entire network side of a client for free:

**The core does:**

- The `punktfunk/1` handshake (Hello/Welcome/Start) over a **QUIC control plane**.
- The **UDP video data plane**: packetization, reassembly, pacing, socket tuning.
- **Forward error correction** (GF(2⁸) Reed–Solomon and GF(2¹⁶) Leopard-RS) — loss recovery.
- **AES-128-GCM** session crypto, **cert pinning / TOFU**, and **SPAKE2 PIN pairing**.
- **Clock synchronization** (host↔client offset for glass-to-glass latency math).
- Delivery of every plane: reassembled **video access units**, **Opus audio** (raw or decoded to
  PCM in-core), **rumble**, **DualSense HID output**, **HDR metadata**, **host timing**.
- The uplink: **input events**, **mic**, **rich input** (touchpad/motion).
- Loss-recovery signalling: keyframe requests and **reference-frame invalidation** (RFI).

**The core does NOT do (this is your job):**

- **Video decoding.** The core hands you encoded access units (H.264, HEVC, or AV1 NAL units /
  OBUs). You feed them to the platform's hardware decoder.
- **Rendering / presentation.** You own the swapchain / surface and put decoded frames on glass.
- **Audio output.** You own the audio sink; the core only delivers Opus (or f32 PCM).
- **Input capture / controller enumeration.** You read the platform's input and translate it into
  `PunktfunkInputEvent`s.
- **UI, discovery UX, settings.** (mDNS discovery is not part of the C ABI; see §4.)

Think of it as: **the core is the modem and the codec-agnostic protocol brain; you are the TV and
the remote.**

```
                          ┌─────────────────────────── punktfunk-core (C ABI) ───────────────────────────┐
   your platform          │                                                                              │      punktfunk host
  ┌───────────┐  input    │  punktfunk_connection_send_input / _send_mic / _send_rich_input  ───────────────▶
  │  remote / │──────────▶│                                                                              │
  │  gamepad  │           │                          QUIC control + UDP data + FEC + AES-GCM             │
  └───────────┘           │                                                                              │
  ┌───────────┐  AUs      │  ◀── punktfunk_connection_next_au        (H.264 / HEVC / AV1 access units)   │◀─── encoder
  │ HW decoder│◀──────────│  ◀── punktfunk_connection_next_audio(_pcm)(Opus / f32 PCM)                   │
  │ + display │           │  ◀── punktfunk_connection_next_rumble2 / _next_hidout / _next_hdr_meta        │
  └───────────┘           └──────────────────────────────────────────────────────────────────────────────┘
```

---

## 2. Building and linking the library

### 2.1 The `quic` feature is mandatory for clients

The entire connection API (`punktfunk_connect*`, `punktfunk_connection_*`, pairing, probe) is
gated, in both Rust and the C header, behind the **`quic`** Cargo feature. Everything under
`#if defined(PUNKTFUNK_FEATURE_QUIC)` in the header only exists when the core was built with it.

> **Two things must line up or nothing links:**
> 1. Build the library with `--features quic`.
> 2. Compile your C/C++ with `-DPUNKTFUNK_FEATURE_QUIC` so the header declares those prototypes.

The non-QUIC surface (`punktfunk_session_new`, `punktfunk_host_submit_frame`,
`punktfunk_client_poll_frame`, the loopback test pair) is the **raw transport** used by the host and
by tests. **Client embedders do not use it** — you use the `punktfunk_connect*` family.

### 2.2 Build outputs

```sh
# Native (host machine) — good for a desktop prototype
cargo build -p punktfunk-core --features quic --release
```

`crate-type = ["lib", "cdylib", "staticlib"]`, so one build produces all three:

| Output      | File (per platform)                                             | Use when |
|-------------|----------------------------------------------------------------|----------|
| `cdylib`    | `libpunktfunk_core.so` / `.dylib` / `punktfunk_core.dll`        | dynamic linking (most TV apps, sandboxed apps) |
| `staticlib` | `libpunktfunk_core.a` / `punktfunk_core.lib`                    | static embedding into a single binary |

The header lands at `include/punktfunk_core.h` (regenerated on build; also checked in).

### 2.3 Cross-compiling for your device

Every target below is a standard Rust target. Add it and point Cargo at the platform sysroot/linker.

```sh
rustup target add aarch64-unknown-linux-gnu    # most ARM Smart TVs (webOS, Tizen)
rustup target add armv7-unknown-linux-gnueabihf # older 32-bit TV SoCs
rustup target add x86_64-pc-windows-msvc        # Xbox (GDK consoles are x64)

# Tell Cargo which cross-linker + sysroot to use (example: aarch64 TV NDK)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
cargo build -p punktfunk-core --features quic --release \
    --target aarch64-unknown-linux-gnu
```

The QUIC tree is deliberately **ring-only** (no aws-lc-rs / no cmake), so cross builds do not need a
C crypto toolchain — this is what keeps the TV cross-compiles simple. `libopus` (for the in-core PCM
decode path) is the only C dependency and builds with the target `CC`.

### 2.4 Compiling and linking your app

```sh
# compile
cc -std=c11 -DPUNKTFUNK_FEATURE_QUIC -I path/to/include -c myclient.c

# link, dynamic
cc myclient.o -L target/aarch64-unknown-linux-gnu/release -lpunktfunk_core -o myclient

# link, static — you also need the native libs rustc pulls in
NATIVE=$(cargo rustc -p punktfunk-core --features quic --release \
         --target aarch64-unknown-linux-gnu \
         --crate-type staticlib -- --print native-static-libs 2>&1 \
         | sed -n 's/.*native-static-libs: //p' | tail -1)
cc myclient.o target/aarch64-unknown-linux-gnu/release/libpunktfunk_core.a $NATIVE -o myclient
```

`--print native-static-libs` is the reliable way to discover the platform libs (`-lpthread`,
`-lm`, WinSock, etc.) a static link needs — the C harness at
[`crates/punktfunk-core/tests/c/run.sh`](../crates/punktfunk-core/tests/c/run.sh) does exactly this
and is your smallest end-to-end reference.

### 2.5 Version check first, always

```c
#include "punktfunk_core.h"

if (punktfunk_abi_version() != ABI_VERSION) {
    // The .so you loaded is a different core than this header was generated from. Abort.
}
```

`ABI_VERSION` (the embeddable C surface) is distinct from `WIRE_VERSION` (what the handshake
carries). The ABI can grow — new `connect_ex` variants, new planes — without touching the wire, so a
newer client keeps talking to a deployed host. Only equal `WIRE_VERSION`s interoperate on the wire;
the core enforces that inside the handshake.

### 2.6 Hear what the core says (optional, v25+)

The core logs through Rust's `tracing`; an embedder that installs no Rust subscriber hears none of
it — transport warnings, connection events, handshake notes. Register a sink once at startup:

```c
static void on_core_log(uint8_t level, const char *target, const char *message, void *user) {
    // level 1=error 2=warn 3=info 4=debug 5=trace; both strings are borrowed for this call only.
    my_log(level, "%s %s", target, message);
}
punktfunk_set_log_callback(3 /* info and up */, on_core_log, NULL);
```

The callback runs on whichever core thread logged: keep it short and thread-safe. Pass `NULL` to
detach. Returns `PUNKTFUNK_STATUS_UNSUPPORTED` when a `log` backend is already installed in the
process (a Rust shell that brought its own) — that backend already receives these lines.

---

## 3. Core concepts

### 3.1 The connection handle

`PunktfunkConnection *` is an **opaque handle to one live client session** — QUIC control plane plus
UDP data plane, with all the I/O pumped on threads the core owns internally. You obtain it from a
`punktfunk_connect*` call and release it with `punktfunk_connection_close`.

### 3.2 The threading contract (read this before you design your loops)

The planes are **independent single-consumer queues**. The rule:

> Each plane (video, audio, rumble, HID-out, HDR, host-timing) may be pulled from **its own thread,
> at most one thread per plane**. Different planes may be pulled concurrently. Never pull the *same*
> plane from two threads.

A typical client runs **three threads**:

- **Video thread** — blocks on `punktfunk_connection_next_au`, decodes, presents.
- **Audio thread** — blocks on `punktfunk_connection_next_audio_pcm` (or `_next_audio`).
- **Feedback thread** — pulls rumble / HID-out / HDR-meta (all low-rate; poll or short-timeout).

Input is sent from whatever thread reads your input (send calls are non-blocking enqueues and are
safe to call from any thread).

### 3.3 Borrowed memory

`next_au`, `next_audio`, `next_audio_pcm` return a struct whose `data`/`samples` pointer **borrows
core-owned memory that is valid only until your next pull on that same handle/plane.** Decode or copy
before you call again. Cross-plane pulls do not invalidate each other (the audio pull won't free the
video buffer).

### 3.4 Status codes

Every fallible call returns `PunktfunkStatus`: `PUNKTFUNK_STATUS_OK == 0`, all errors negative
(`rc < 0`). The two you branch on constantly in the pull loops:

- `PUNKTFUNK_STATUS_NO_FRAME` (-5) — nothing ready within `timeout_ms`; loop again.
- `PUNKTFUNK_STATUS_CLOSED` (-10) — the session ended; tear down and leave the loop.

---

## 4. Identity, pairing, discovery, wake

### 4.1 Generate a persistent identity once

```c
char cert[4096], key[4096];
if (punktfunk_generate_identity(cert, sizeof cert, key, sizeof key) != PUNKTFUNK_STATUS_OK) { /* … */ }
// Persist BOTH strings securely (platform secure storage / keychain).
// The certificate's SHA-256 is how a host recognizes this client after pairing.
```

Do this **once per device/install** and store the PEM strings. Pass them to every `pair` and
`connect` call. Anonymous (`NULL`/`NULL`) sessions are rejected by hosts running `--require-pairing`.

### 4.2 Pair (PIN ceremony)

```c
uint8_t host_fp[32];
PunktfunkStatus rc = punktfunk_pair(host, port, cert, key,
                                    user_typed_pin, "Living Room TV",
                                    host_fp, /*timeout_ms=*/10000);
// rc == PUNKTFUNK_STATUS_CRYPTO  => wrong PIN.
// rc == PUNKTFUNK_STATUS_OK      => persist host_fp; it's the pin for future connects.
```

The host shows a PIN; the user types it into your UI. On success you get the host's verified
fingerprint — store it keyed by host, and pass it as `pin_sha256` on every later connect (that pins
the host and defeats MITM). For a first connect without prior pairing you may pass `NULL` for the pin
and capture `observed_sha256_out` (trust-on-first-use), then pin it thereafter.

### 4.3 Reachability probe and Wake-on-LAN

- `punktfunk_probe(host, port, timeout_ms)` — a bounded, trust-agnostic handshake attempt. Returns
  `OK` if the host answered (drives "online" pips), `TIMEOUT` otherwise. Works over routed
  networks (Tailscale/VPN) where mDNS never reaches. Call off the UI thread.
- `punktfunk_wake_on_lan(macs, mac_count, last_known_ip)` — sends WoL magic packets. The host's wake
  MAC(s) arrive out-of-band via the mDNS `mac` TXT record, so no connection is needed to wake a
  sleeping host.

> **Discovery is out of scope for the C ABI.** mDNS/DNS-SD browsing is the embedder's job (use the
> platform's Bonjour/Avahi/`nsd` API to find `_punktfunk._udp` hosts, or let the user type an
> IP/hostname). The core only *connects*.

---

## 5. Connecting

There is a **ladder** of `connect` variants; each adds parameters and defaults the rest to the prior
variant's behavior. Use the highest one you need — they all return `PunktfunkConnection *` (NULL on
failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**

| Variant          | Adds |
|------------------|------|
| `punktfunk_connect`     | base: `width`, `height`, `refresh_hz`, pin, identity |
| `punktfunk_connect_ex`  | `compositor` preference (Linux hosts) |
| `punktfunk_connect_ex2` | `gamepad` backend (X-Box 360 / DualSense / …) |
| `punktfunk_connect_ex3` | `bitrate_kbps` |
| `punktfunk_connect_ex4` | `launch_id` (auto-launch a library title) |
| `punktfunk_connect_ex5` | `video_caps` (10-bit / HDR / 4:4:4 / host-timing) |
| `punktfunk_connect_ex6` | `audio_channels` (2 / 6 / 8) |
| **`punktfunk_connect_ex7`** | **`video_codecs` + `preferred_codec`** — the recommended full call |

```c
uint8_t caps   = 0;                       // add PUNKTFUNK_VIDEO_CAP_10BIT | _HDR | _444 as supported
uint8_t codecs = PUNKTFUNK_CODEC_HEVC;    // OR-in _H264 (needed for software hosts) / _AV1
uint8_t host_fp[32]; uint8_t observed[32];

PunktfunkConnection *c = punktfunk_connect_ex7(
    "192.168.1.50", 9777,
    1920, 1080, 60,                        // mode
    PUNKTFUNK_COMPOSITOR_AUTO,
    PUNKTFUNK_GAMEPAD_XBOX360,
    /*bitrate_kbps=*/20000,
    caps,
    /*audio_channels=*/2,
    codecs, /*preferred_codec=*/PUNKTFUNK_CODEC_HEVC,
    /*launch_id=*/NULL,
    host_fp,                               // pin (or NULL for TOFU)
    observed,                              // filled with the observed fp
    cert, key,                             // identity
    /*timeout_ms=*/8000);
if (!c) { /* handshake failed */ }
```

> **Advertise only what you can actually decode and present.** The host upgrades to 10-bit / HDR /
> 4:4:4 / AV1 **only** when you set the matching bit *and* it opted in. Setting a cap you can't honor
> gives you a stream you can't render.

### 5.1 Read the resolved session (right after connect)

The host echoes what it actually chose. Build your decoder and present path from **these**, never
from your request:

```c
uint8_t codec, prim, trc, mtx, full, depth, chroma, ch;
punktfunk_connection_codec(c, &codec);                       // PUNKTFUNK_CODEC_{H264,HEVC,AV1}
punktfunk_connection_color_info(c, &prim, &trc, &mtx, &full, &depth); // CICP + bit depth
punktfunk_connection_chroma_format(c, &chroma);              // 1 = 4:2:0, 3 = 4:4:4
punktfunk_connection_audio_channels(c, &ch);                 // 2 / 6 / 8

int64_t clk = 0; punktfunk_connection_clock_offset_ns(c, &clk); // host−client ns, for latency math

uint32_t w, h, hz; punktfunk_connection_mode(c, &w, &h, &hz);
```

`trc == 16` (PQ) or `18` (HLG) means an **HDR** session — set up an HDR present path and drain
`punktfunk_connection_next_hdr_meta`. On `PUNKTFUNK_COMPOSITOR_GAMESCOPE`
(`punktfunk_connection_compositor`) draw a client-side cursor by default (that capture carries none).

---

## 6. The video loop

The core delivers **complete, in-order access units** with in-band parameter sets. The first AU is
an IDR — build your decoder from it (and from the resolved codec/color above). The stream is then an
**infinite-GOP** of P-frames: no periodic IDRs, so loss recovery is explicit and is the part you must
get right.

```c
PunktfunkFrame f;
for (;;) {
    PunktfunkStatus rc = punktfunk_connection_next_au(c, &f, /*timeout_ms=*/20);
    if (rc == PUNKTFUNK_STATUS_NO_FRAME) continue;
    if (rc == PUNKTFUNK_STATUS_CLOSED)   break;
    if (rc != PUNKTFUNK_STATUS_OK)       continue;

    // (1) Loss recovery — let the core do the hard part. Call this EVERY frame:
    bool gap = false;
    punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
    //   On a forward gap it fires a throttled RFI request for you (clean P-frame recovery on
    //   AMD-LTR/NVENC hosts, no IDR spike). `gap == true` is your cue to freeze on the last good
    //   picture until re-anchor (see (3)).

    // (2) Clean re-anchor points (infinite-GOP has no periodic keyframes):
    bool recovery_point  = f.flags & USER_FLAG_RECOVERY_POINT;  // intra-refresh wave boundary
    bool recovery_anchor = f.flags & USER_FLAG_RECOVERY_ANCHOR; // definitive LTR/RFI re-anchor

    // (3) Decode + present. f.data/f.len are valid until the next next_au call.
    decode_and_present(f.data, f.len, f.pts_ns);
    //   If you implement freeze-until-reanchor: while frozen, keep redrawing the last good frame
    //   and lift the freeze on a real keyframe, on the FIRST recovery_anchor, or the SECOND
    //   recovery_point after the gap.

    // (4) Backstop trigger: poll the reassembler's unrecoverable-drop count. Under infinite GOP,
    //     unrecoverable loss yields reference-missing deltas the decoder *silently conceals*
    //     (frozen/garbage, no decode error) — so this counter, not a decode error, is the signal.
    uint64_t dropped = 0; punktfunk_connection_frames_dropped(c, &dropped);
    if (dropped > last_dropped) {           // THROTTLE this (≤ ~1/100ms) — see below
        punktfunk_connection_request_keyframe(c);
        last_dropped = dropped;
    }
}
```

**Recovery, in priority order:**

1. `note_frame_index` every frame — the cheapest, earliest signal. It issues a **throttled RFI**
   request (`request_rfi`) for the exact lost range so RFI-capable hosts recover with a clean
   P-frame (no 20–40× IDR bandwidth spike).
2. `frames_dropped` climbing → `request_keyframe` as the backstop when RFI can't help or the recovery
   frame itself was lost.
3. A wedged decoder (received AUs but no decoded output for several frames) → `request_keyframe`.

**Throttle** both `request_keyframe` and `request_rfi` — decode stays wedged for several frames until
recovery lands, so one request per ~100 ms is right; requesting per frame floods the control stream.
If you prefer not to hand-roll this, `note_frame_index` + a throttled `frames_dropped`→keyframe check
is a complete, correct recovery policy on its own.

### 6.1 Mid-session resolution / refresh changes

```c
punktfunk_connection_request_mode(c, new_w, new_h, new_hz);
```

Non-blocking. On acceptance the next AU is a fresh IDR with in-band parameter sets — **rebuild your
decoder from it** — and `punktfunk_connection_mode` reflects the switch. Use this when your window
resizes or the display refresh changes.

---

## 7. Audio

Two mutually-exclusive ways to consume audio — pick one, on one dedicated thread:

- **`punktfunk_connection_next_audio_pcm`** — the core decodes to interleaved **f32 PCM** at 48 kHz
  in channel order `FL FR FC LFE RL RR SL SR`. Use this if you lack a *multistream*-capable Opus
  decoder (e.g. Apple's AudioToolbox is stereo-only; many TV audio stacks are too). Simplest path.
- **`punktfunk_connection_next_audio`** — raw Opus packets (5 ms frames). Use only if you have a
  multistream Opus decoder and build it from `punktfunk_connection_audio_channels` (see
  `audio::layout_for`). Do **not** mix the two on one connection.

```c
PunktfunkAudioPcm a;
for (;;) {
    PunktfunkStatus rc = punktfunk_connection_next_audio_pcm(c, &a, /*timeout_ms=*/100);
    if (rc == PUNKTFUNK_STATUS_CLOSED) break;
    if (rc != PUNKTFUNK_STATUS_OK)     continue;
    // a.samples = a.frame_count * a.channels f32s, valid until the next PCM call.
    audio_sink_write(a.samples, a.frame_count, a.channels);
}
```

---

## 8. Input (uplink)

Fill a `PunktfunkInputEvent` and send it — non-blocking, from any thread:

```c
PunktfunkInputEvent ev; memset(&ev, 0, sizeof ev);
```

Field meaning depends on `kind` (`PunktfunkInputKind`). The contracts that trip people up:

- **Absolute pointer / touch** (`MOUSE_MOVE_ABS`, `TOUCH_DOWN/MOVE`): `x`/`y` are pixel coordinates
  and **`flags` must pack your coordinate space as `(width << 16) | height`** — the host normalizes
  against it. A **zero `flags` is dropped**, so always set it.
- **Gamepad button** (`GAMEPAD_BUTTON`): `code` = a `PUNKTFUNK_BTN_*` bit, `x != 0` = pressed,
  `flags` = pad index (0..15).
- **Gamepad axis** (`GAMEPAD_AXIS`): `code` = `PUNKTFUNK_AXIS_*`, `flags` = pad index. Sticks are
  **i16 (−32768..32767), +y = up** (opposite of screen coordinates); triggers are 0..255.
- **Relative mouse** (`MOUSE_MOVE`): `x`/`y` carry `dx`/`dy`. **Scroll** (`MOUSE_SCROLL`): `x` is the
  signed delta.

```c
// Example: press A on pad 0
ev.kind = PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON;
ev.code = PUNKTFUNK_BTN_A; ev.x = 1; ev.flags = 0;
punktfunk_connection_send_input(c, &ev);

// Example: absolute touch at (640,360) on a 1280x720 surface
ev.kind = PUNKTFUNK_INPUT_KIND_TOUCH_DOWN;
ev.code = 0 /*finger id*/; ev.x = 640; ev.y = 360;
ev.flags = (1280u << 16) | 720u;
punktfunk_connection_send_input(c, &ev);
```

> Gamepad state on the C ABI uses **per-transition** button/axis events (above). The wire also has an
> idempotent full-pad *snapshot* mode (`HOST_CAP_GAMEPAD_STATE`), but it is not exposed as a
> dedicated C entry point — per-transition events are accepted by every host and are the C path.

**Other uplinks (all non-blocking):**

- `punktfunk_connection_send_mic(c, opus, len, seq, pts_ns)` — Opus mic frames (you encode).
- `punktfunk_connection_send_rich_input(c, &rich)` — DualSense touchpad contact / motion sample.
- `punktfunk_connection_send_rich_input2(c, &richEx)` — the forward-compatible superset (Steam
  trackpads, signed coords, pressure); set `struct_size = sizeof(PunktfunkRichInputEx)`.

### Stylus / pen

Full-fidelity pen (pressure, tilt, azimuth, barrel roll, hover, eraser, barrel buttons) has its
own plane. **Gate on the capability first** — only send when
`punktfunk_connection_host_caps(c, &caps)` shows `PUNKTFUNK_HOST_CAP_PEN`; without it the call
returns `UNSUPPORTED` and you keep your pen-as-touch fallback (what every client does today).

Samples are **state-full**: every sample carries the complete pen state (in-range / touching /
buttons in `state`, plus all axes — unknown axes take their `PUNKTFUNK_PEN_*_UNKNOWN` sentinel,
never 0). There are no down/up events to send; the host diffs consecutive samples and
synthesizes the transitions, so a lost datagram self-heals on the next one. Batch a capture
callback's coalesced samples (oldest first, `dt_us` = spacing) into one call, at most
`PUNKTFUNK_PEN_BATCH_MAX` per call — split longer runs into consecutive calls.

```c
// Example: an in-contact Apple Pencil sample, mapped into video-frame space
PunktfunkPenSample s; memset(&s, 0, sizeof s);
s.state = PUNKTFUNK_PEN_IN_RANGE | PUNKTFUNK_PEN_TOUCHING;
s.tool = PUNKTFUNK_PEN_TOOL_PEN;
s.x = 0.5f; s.y = 0.5f;                       // normalized 0..1 across the video frame
s.pressure = (uint16_t)(force_norm * 65535);  // 0 while hovering
s.tilt_deg = 90 - altitude_deg;               // polar tilt-from-normal
s.azimuth_deg = azimuth_deg;                  // 0..359, clockwise from north
s.roll_deg = PUNKTFUNK_PEN_ANGLE_UNKNOWN;     // Pencil Pro: rollAngle in degrees
s.distance = PUNKTFUNK_PEN_DISTANCE_UNKNOWN;
punktfunk_connection_send_pen(c, &s, 1);
```

While hovering, send `PUNKTFUNK_PEN_IN_RANGE` samples (with `distance` if you have it); when the
pen leaves range, send one final sample with `state = 0` so the host releases proximity.

**Heartbeat**: while the pen is in range or touching, repeat the last sample at least every
~100 ms even when nothing changed — pen capture APIs are silent for a stationary pen, and the
host force-releases the stroke after 200 ms without samples (its dead-client failsafe). Run a
timer alongside your touch callbacks.

---

## 9. Feedback planes (rumble, HID, HDR)

Pull these on your feedback thread (or poll with `timeout_ms = 0`). Same
`NO_FRAME`/`CLOSED` semantics as everywhere.

- **Rumble** — `punktfunk_connection_next_rumble2(c, &pad, &low, &high, &ttl_ms, timeout)`.
  Amplitudes 0..0xFFFF; `(0,0)` = stop. `ttl_ms` is a host-supplied self-terminating lease — render
  the level for that long unless renewed; `PUNKTFUNK_RUMBLE_NO_TTL` means fall back to your own
  staleness timeout. (The v1 `_next_rumble` drops the TTL — prefer v2.)
- **Rumble, policy-engine form** — `punktfunk_connection_next_rumble_cmd(c, &pad, &low, &high,
  &backstop_ms, timeout)` hands you **effective commands** instead of raw wire state: the core owns
  lease expiry, legacy-host staleness and close-drain zeros, so you apply what you are told and keep
  no staleness policy of your own. `backstop_ms` is a safety net for APIs that take a duration
  (ignored by explicit-stop APIs; `0` on stops). Pick **one** rumble API per connection — they
  consume the same plane.
- **Rumble with trigger motors** (ABI ≥ 18) — `punktfunk_connection_next_rumble_cmd2(c, &pad, &low,
  &high, &left_trigger, &right_trigger, &backstop_ms, timeout)` is the same command with the two
  Xbox impulse-trigger levels, on the same 0..0xFFFF scale; a stop is all four at zero. It is a
  **new symbol, not a wider `_cmd`** — `_cmd` keeps its signature and its two-handle view forever,
  so existing embedders need no change. Render the trigger pair only on a pad that has trigger
  motors (Windows: `IGameInputDevice::SetRumbleState`'s `leftTrigger`/`rightTrigger`, or WGI's
  `GamepadVibration`; SDL: `SDL_RumbleGamepadTriggers` gated on
  `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`; Apple: `GCHapticsLocalityLeftTrigger` /
  `…RightTrigger`) and **drop them otherwise — never fold them into a handle motor**: impulse-trigger
  content is continuous (a racing title drives it off engine RPM and tyre slip while the handles stay
  near silent), so folding drones a handle flat-out for the whole race at a level the game never
  asked for. A pad without trigger motors is the common case, not an error; do not log per command.
  Note that on a trigger-driving host a `_cmd` caller now sees commands carrying `low == high == 0`
  while only the triggers run — correct (its motors *should* be silent) and idempotent.
  Nothing sources non-zero trigger levels end to end yet: only the Windows HID Xbox pad has the
  channel at all (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` each have exactly two
  members), and it is reachable only through GameInput/WGI.
- **DualSense HID output** — `punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects
  lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic. Replay on a real DualSense
  via the platform's controller API. Only a DualSense-backend session emits these.
- **HDR metadata** — `punktfunk_connection_next_hdr_meta(c, &meta, timeout)`. ST.2086 mastering
  display + content light level, in HDR10 SEI fixed-point units — ready to hand to DXGI
  `DXGI_HDR_METADATA_HDR10`, Apple `CAEDRMetadata`, or Android `KEY_HDR_STATIC_INFO`. Only an HDR
  session emits these; apply the latest to your display.
- **Host timing** (optional, if you advertised `VIDEO_CAP_HOST_TIMING`) —
  `punktfunk_connection_next_host_timing` gives the host's capture→sent µs per AU, so your stats HUD
  can split `host` vs `network` latency.

---

## 10. Speed test and stats

```c
punktfunk_connection_speed_test(c, /*target_kbps=*/50000, /*duration_ms=*/2000); // pauses video briefly
PunktfunkProbeResult r;
do { punktfunk_connection_probe_result(c, &r); } while (!r.done);  // poll
// r.throughput_kbps drives a bitrate choice; r.loss_pct vs r.host_drop_pct
// distinguishes a lossy link from a host that can't keep up.
```

---

## 11. Teardown

```c
punktfunk_connection_disconnect_quit(c);  // user pressed "stop": host tears down now, no linger
punktfunk_connection_close(c);            // joins internal threads, frees the handle
```

Call `disconnect_quit` **only** on a deliberate user quit — it makes the host drop the virtual
display immediately. On a network drop / backgrounding, skip it (a plain `close`) so the host lingers
and a reconnect can resume. After `close`, the handle is dead; stop all your plane threads first.

---

# Platform integration blueprints

Everything above is exact — it's the punktfunk side, which is identical on every platform. The
sections below are **blueprints**: they name the real decode/present/input subsystems on each target
and show where the punktfunk glue plugs in. Treat the platform-SDK calls as illustrative — confirm
signatures against that platform's current SDK. The pattern is always the same:

> **connect → build HW decoder from the resolved codec/color → three threads (video decode+present,
> audio, feedback) → translate native input into `PunktfunkInputEvent`s.**

---

## 12. webOS (LG Smart TV)

**App model.** webOS ships both web apps and **native** apps via the **webOS NDK** (Linux, ARM —
usually `aarch64`, some older panels `armv7`). A low-latency streaming client wants the native path:
you get real threads, a hardware video pipeline, and SDL for window/input. (This mirrors how the
community Moonlight webOS client is built.)

**Cross-compile the core.** Build `libpunktfunk_core.so` with the webOS NDK sysroot:

```sh
rustup target add aarch64-unknown-linux-gnu
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=$WEBOS_NDK/.../aarch64-webos-linux-gnu-gcc
export CC_aarch64_unknown_linux_gnu=$WEBOS_NDK/.../aarch64-webos-linux-gnu-gcc
cargo build -p punktfunk-core --features quic --release --target aarch64-unknown-linux-gnu
```

Bundle the `.so` in your `.ipk` alongside the header-compiled client.

**Video decode + present.** Use the webOS media pipeline for the SoC's hardware decoder — historically
**NDL-DirectMedia** and on newer panels the **Starfish** media pipeline
(`com.webos.service.mediapipeline`). You push the punktfunk AUs (Annex-B H.264/HEVC/AV1) into the
pipeline's `feed`/`push` entry point; it decodes and composits to the TV's video plane, and you draw
your overlay/UI on the graphics plane over it. Present cadence is driven by the pipeline, not you.

**Audio.** Simplest is `punktfunk_connection_next_audio_pcm` → **PulseAudio** (webOS's audio server)
via a simple playback stream. Or route the raw Opus into the media pipeline if it accepts a
secondary audio ES.

**Input.** The Magic Remote / standard remote and Bluetooth gamepads surface as **SDL2** events (LG
ships SDL for NDK apps). Map remote keys to `KEY_DOWN/UP`, the pointer to `MOUSE_MOVE_ABS` (set
`flags = (w<<16)|h`), and `SDL_GameController` axes/buttons to `GAMEPAD_AXIS`/`GAMEPAD_BUTTON`.

**Skeleton:**

```c
// 1. connect (HEVC, stereo, SDR to start)
PunktfunkConnection *c = punktfunk_connect_ex7(host, 9777, 1920,1080,60,
    PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOX360, 20000,
    /*caps=*/0, /*ch=*/2, PUNKTFUNK_CODEC_HEVC, PUNKTFUNK_CODEC_HEVC,
    NULL, host_fp, observed, cert, key, 8000);

uint8_t codec; punktfunk_connection_codec(c, &codec);
StarfishPipeline *p = starfish_open(codec /*→ "video/hevc" etc*/, 1920,1080);

// 2. video thread
PunktfunkFrame f;
while (running) {
    if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
    bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
    starfish_feed(p, f.data, f.len, f.pts_ns);          // HW decode + present
    uint64_t d; punktfunk_connection_frames_dropped(c, &d);
    if (d > last_d) { punktfunk_connection_request_keyframe(c); last_d = d; }  // throttle
}
// 3. audio thread → next_audio_pcm → pulse_write(...)
// 4. SDL input thread → fill PunktfunkInputEvent → punktfunk_connection_send_input(c, &ev)
```

**Gotchas.** webOS app networking is permitted for UDP/QUIC, but background/suspend policy is
aggressive — pull-loop `CLOSED` on backgrounding and reconnect on resume. Video-plane / graphics-plane
z-order and scaling are set through the pipeline's display-window API, not by drawing pixels yourself.

---

## 13. Xbox (GDK — Series X|S / One)

**App model.** Use the **Microsoft GDK** (Game Development Kit) for consoles — a **C++** title with
**Direct3D 12** and the **GameInput** API. Consoles are **x64**, so the core target is
`x86_64-pc-windows-msvc`. (Requires an ID@Xbox / partner console in Developer Mode; UWP on retail is
possible but GDK is the low-latency path.)

**Build the core.** From an MSVC environment:

```sh
rustup target add x86_64-pc-windows-msvc
cargo build -p punktfunk-core --features quic --release --target x86_64-pc-windows-msvc
```

You get `punktfunk_core.dll` (+ import lib) and `punktfunk_core.lib` (static). Compile your C++ with
`/DPUNKTFUNK_FEATURE_QUIC` and add `include/` to the include path. The header is `extern "C"`-clean
for C++ (`__cplusplus` guards are in place).

**Video decode + present.** Feed the AUs to **Media Foundation** with a **DXVA2 / D3D12
hardware-accelerated decoder** (`IMFTransform` H.264/HEVC/AV1 decoder), or a D3D12 Video decode
(`ID3D12VideoDecoder`) if you want to own the DPB. Output NV12/P010 textures and present with your
D3D12 swapchain. For HDR, set the swapchain to `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020` and pass
`punktfunk_connection_next_hdr_meta` straight into `IDXGISwapChain4::SetHDRMetaData` — the core's
`PunktfunkHdrMeta` is already in `DXGI_HDR_METADATA_HDR10` units.

**Audio.** `punktfunk_connection_next_audio_pcm` (f32) → **XAudio2** source voice, or **WASAPI**
shared-mode render. Request 6/8 channels at connect for surround.

**Input.** **GameInput** (`IGameInput::GetCurrentReading`) gives you gamepad state; diff it per frame
and emit `GAMEPAD_BUTTON`/`GAMEPAD_AXIS` events. Because a real Xbox pad drives this, connect with
`PUNKTFUNK_GAMEPAD_XBOXONE` for matching glyphs. Rumble comes **back** from the host — feed
`punktfunk_connection_next_rumble2` into `IGameInputDevice::SetRumbleState` (map `low`→
low-frequency, `high`→high-frequency motors). `GameInputRumbleParams` has two more members,
`leftTrigger`/`rightTrigger`, and this is the one platform API that can drive them: use
`punktfunk_connection_next_rumble_cmd2` (ABI ≥ 18) instead and fill all four. The host can only
ever source non-zero trigger levels from its Windows HID Xbox pad, so expect zeros elsewhere.

**Skeleton (C++):**

```cpp
auto* c = punktfunk_connect_ex7(host, 9777, 3840,2160,60,
    PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOXONE, /*bitrate=*/60000,
    PUNKTFUNK_VIDEO_CAP_10BIT | PUNKTFUNK_VIDEO_CAP_HDR,      // 4K HDR
    /*ch=*/6, PUNKTFUNK_CODEC_HEVC, PUNKTFUNK_CODEC_HEVC,
    nullptr, hostFp, observed, cert, key, 8000);

uint8_t trc; punktfunk_connection_color_info(c, nullptr,&trc,nullptr,nullptr,nullptr);
bool hdr = (trc == 16 || trc == 18);
auto decoder = MakeMFHevcDecoder(d3d12Device, hdr /*P010*/);

// video thread
PunktfunkFrame f;
while (running) {
    if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
    bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
    decoder.Decode(f.data, f.len, f.pts_ns);      // → NV12/P010 texture → D3D12 present
}
// feedback thread
uint16_t pad, lo, hi; uint32_t ttl;
while (punktfunk_connection_next_rumble2(c,&pad,&lo,&hi,&ttl, 100) == PUNKTFUNK_STATUS_OK)
    SetRumble(pad, lo, hi, ttl);
// HDR: PunktfunkHdrMeta hm; next_hdr_meta(...) → swapChain4->SetHDRMetaData(HDR10, &hm-as-DXGI)
```

**Gotchas.** GDK console socket use is allowed but goes through the title's network stack — enable it
in your MicrosoftGame.config and test in the console sandbox. Retail devices need proper cert; keep
the DLL and header ABI versions locked (§2.5) in your CI.

---

## 14. Tizen (Samsung Smart TV)

**App model.** Use **Tizen Native** (C, EFL) — .NET/web apps can't get low-latency HW-decode feed
access cleanly. TVs are ARM (`aarch64` on modern panels, `armv7` on older). Build with the Tizen
Studio / GBS toolchain and its sysroot.

**Cross-compile the core.**

```sh
rustup target add aarch64-unknown-linux-gnu       # or armv7-unknown-linux-gnueabihf
export CC_aarch64_unknown_linux_gnu=$TIZEN_TOOLCHAIN/bin/aarch64-tizen-linux-gnu-gcc
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=$CC_aarch64_unknown_linux_gnu
cargo build -p punktfunk-core --features quic --release --target aarch64-unknown-linux-gnu
```

Add the `.so` to your project's `lib/` and link with `-lpunktfunk_core`, compiling with
`-DPUNKTFUNK_FEATURE_QUIC`.

**Video decode + present.** Use **`capi-media-codec`** (`mediacodec_*`) — Tizen's hardware codec
binding over OpenMAX. Configure it from the resolved codec (`MEDIACODEC_H264`/`_HEVC`/AV1), wrap each
AU in a **`media_packet`** (`capi-media-tool`) and `mediacodec_process_input`; on the output callback
render the decoded packet to an **Evas GL** surface (or hand tunpacked frames to the TV video plane).
For HDR, drive the panel's HDR mode from `punktfunk_connection_color_info` + `next_hdr_meta`.

**Audio.** `punktfunk_connection_next_audio_pcm` (f32, 48 kHz) → **`capi-media-audio-io`**
(`audio_out_*`). Convert f32→s16 if you open the sink as PCM S16.

**Input.** TV remote and Bluetooth gamepads arrive as **Ecore** key events (`Ecore_Event_Key`).
Register key grabs (`elm_win_keygrab_set`) for the remote, translate D-pad/OK/back to
`KEY_DOWN/UP` or gamepad buttons, and map any HID gamepad to `GAMEPAD_AXIS`/`GAMEPAD_BUTTON`.

**Skeleton:**

```c
PunktfunkConnection *c = punktfunk_connect_ex7(host, 9777, 1920,1080,60,
    PUNKTFUNK_COMPOSITOR_AUTO, PUNKTFUNK_GAMEPAD_XBOX360, 20000,
    /*caps=*/0, /*ch=*/2, PUNKTFUNK_CODEC_HEVC | PUNKTFUNK_CODEC_H264,
    PUNKTFUNK_CODEC_HEVC, NULL, host_fp, observed, cert, key, 8000);

uint8_t codec; punktfunk_connection_codec(c, &codec);
mediacodec_h mc; mediacodec_create(&mc);
mediacodec_set_codec(mc, codec==PUNKTFUNK_CODEC_HEVC?MEDIACODEC_HEVC:MEDIACODEC_H264,
                     MEDIACODEC_DECODER | MEDIACODEC_SUPPORT_TYPE_HW);
mediacodec_set_output_buffer_available_cb(mc, on_decoded /*→ Evas GL present*/, NULL);
mediacodec_prepare(mc);

// video thread
PunktfunkFrame f;
while (running) {
    if (punktfunk_connection_next_au(c, &f, 20) != PUNKTFUNK_STATUS_OK) continue;
    bool gap; punktfunk_connection_note_frame_index(c, f.frame_index, &gap);
    media_packet_h pkt = wrap_au(f.data, f.len, f.pts_ns);   // capi-media-tool
    mediacodec_process_input(mc, pkt, 0);
    uint64_t d; punktfunk_connection_frames_dropped(c, &d);
    if (d > last_d) { punktfunk_connection_request_keyframe(c); last_d = d; }  // throttle
}
// audio thread: next_audio_pcm → audio_out_write(...)
// Ecore key handler: fill PunktfunkInputEvent → punktfunk_connection_send_input(c, &ev)
```

**Gotchas.** Declare the `internet` and `network.get` privileges in `tizen-manifest.xml` or the QUIC
socket won't open. `mediacodec` prefers Annex-B start codes and periodic parameter sets — the
punktfunk IDR carries in-band parameter sets, but if your SoC decoder wants an explicit
codec-config/CSD, extract VPS/SPS/PPS from the first IDR and feed it before the stream. On a
mid-session mode change (`request_mode`), tear down and re-prepare the `mediacodec` from the new IDR.

---

## 15. Checklist for a new port

- [ ] Build the core with `--features quic`; compile your app with `-DPUNKTFUNK_FEATURE_QUIC`.
- [ ] `punktfunk_abi_version() == ABI_VERSION` at startup.
- [ ] Persist a generated identity; implement PIN pairing; pin the host fingerprint.
- [ ] Connect off the UI thread; **build the decoder from the *resolved* codec/color/chroma**, not
      your request.
- [ ] One thread per plane; never two on the same plane; decode/copy borrowed buffers before the next
      pull.
- [ ] `note_frame_index` every video frame; throttled `frames_dropped`→`request_keyframe` backstop.
- [ ] Rebuild the decoder on the IDR after any accepted `request_mode`.
- [ ] Set `flags = (w<<16)|h` on absolute pointer/touch events (nonzero!).
- [ ] `disconnect_quit` only on deliberate user quit; always `close` and stop plane threads on
      teardown.

## 16. Reference

- **Header (the contract):** [`include/punktfunk_core.h`](../include/punktfunk_core.h)
- **Minimal C link + round-trip proof:** [`crates/punktfunk-core/tests/c/`](../crates/punktfunk-core/tests/c/)
- **Core crate README:** [`crates/punktfunk-core/README.md`](../crates/punktfunk-core/README.md)
- **A full reference client (Rust, same ABI surface):** `crates/pf-client-core/src/session.rs`
