diff --git a/.github/workflows/capture-vectors.yml b/.github/workflows/capture-vectors.yml new file mode 100644 index 0000000..d3382d1 --- /dev/null +++ b/.github/workflows/capture-vectors.yml @@ -0,0 +1,60 @@ +name: capture-vectors + +# Build the pinned Ableton/link reference and record protocol test vectors in +# isolated network namespaces. The capture script generates an observed-fact +# manifest per vector (tools/analyze_pcap.py) and fails if any capture does +# not structurally contain the events its scenario demonstrates +# (tools/check_vectors.py). Vectors are uploaded as a build artifact; they are +# committed to the repo only by a maintainer running tools/capture-vectors.sh +# locally and reviewing pcaps + manifests together (captures are not +# byte-reproducible — see vectors/README.md). + +on: + workflow_dispatch: + push: + paths: + - tools/capture-vectors.sh + - tools/analyze_pcap.py + - tools/check_vectors.py + - .github/workflows/capture-vectors.yml + - LAST_REVIEWED_SHA + +permissions: + contents: read + +jobs: + capture: + runs-on: ubuntu-latest + steps: + - name: Checkout spec repo + uses: actions/checkout@v4 + + - name: Install build and capture dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ git tcpdump iproute2 util-linux python3 \ + jackd2 libjack-jackd2-dev portaudio19-dev libasound2-dev + + - name: Build reference, record vectors, generate manifests, assert structure + env: + # Clone the reference OUTSIDE the workspace so it is never vendored + # or accidentally committed (PROVENANCE.md firewall). + LINK_CAPTURE_WORK: ${{ runner.temp }}/link-capture + LINK_CAPTURE_OUT: ${{ github.workspace }}/vectors + run: | + # netns isolation (unshare --net) and tcpdump need root; the runner + # provides passwordless sudo. Manifest generation and the structural + # assertions run inside the script and fail the job on a hollow + # capture. + sudo --preserve-env=LINK_CAPTURE_WORK,LINK_CAPTURE_OUT \ + bash tools/capture-vectors.sh + + - name: Upload vectors artifact + uses: actions/upload-artifact@v4 + with: + name: link-wire-vectors + path: | + vectors/*.pcap + vectors/manifests/*.md + if-no-files-found: error diff --git a/.github/workflows/conformance-selftest.yml b/.github/workflows/conformance-selftest.yml new file mode 100644 index 0000000..061638c --- /dev/null +++ b/.github/workflows/conformance-selftest.yml @@ -0,0 +1,46 @@ +name: conformance-selftest + +# Validate the conformance harness end-to-end by running it in self-test mode +# (reference vs reference): builds the pinned reference into the runner's +# temp dir (never the workspace), runs every scenario in an isolated network +# namespace, and fails on any failed observation. + +on: + workflow_dispatch: + push: + paths: + - conformance/** + - tools/build-reference.sh + - .github/workflows/conformance-selftest.yml + - LAST_REVIEWED_SHA + +permissions: + contents: read + +jobs: + selftest: + runs-on: ubuntu-latest + steps: + - name: Checkout spec repo + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ git iproute2 util-linux python3 \ + jackd2 libjack-jackd2-dev portaudio19-dev libasound2-dev + + - name: Run harness self-test (reference vs reference) + env: + LINK_CAPTURE_WORK: ${{ runner.temp }}/link-reference + run: | + sudo --preserve-env=LINK_CAPTURE_WORK \ + bash conformance/run-isolated.sh | tee observations.txt + + - name: Upload observation log + if: always() + uses: actions/upload-artifact@v4 + with: + name: selftest-observations + path: observations.txt diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml new file mode 100644 index 0000000..e2c49a9 --- /dev/null +++ b/.github/workflows/upstream-watch.yml @@ -0,0 +1,126 @@ +name: upstream-watch + +# Weekly comparison of Ableton/link HEAD against the reviewed pin +# (LAST_REVIEWED_SHA). If no new upstream commit touches wire-relevant paths, +# the pin advances automatically. Otherwise a triage issue is opened (or +# updated) listing the new commits and the wire-relevant paths they touch, so +# a dirty-side author can classify each change (no wire impact / behavioral / +# wire-format) per PROVENANCE.md. +# +# Provenance note: the issue body contains only commit SHAs, dates, and file +# paths — no upstream commit messages, code, or comments. + +on: + schedule: + - cron: "17 6 * * 1" # weekly, Monday 06:17 UTC + workflow_dispatch: + +permissions: + contents: write + issues: write + +env: + UPSTREAM: https://github.com/Ableton/link.git + # Paths in the upstream repo considered wire-relevant. Conservative: the + # whole library (protocol logic AND platform socket configuration both + # produce wire-visible behavior). Examples, docs, CI, and build files are + # not wire-relevant by themselves. + RELEVANT_GLOB: "include/ableton/" + +jobs: + watch: + runs-on: ubuntu-latest + steps: + - name: Checkout spec repo + uses: actions/checkout@v4 + + - name: Compare upstream HEAD against pin + id: cmp + run: | + set -euo pipefail + PIN=$(tr -d '[:space:]' < LAST_REVIEWED_SHA) + git clone --bare --filter=blob:none "$UPSTREAM" /tmp/upstream + HEAD=$(git -C /tmp/upstream rev-parse HEAD) + echo "pin=$PIN" >> "$GITHUB_OUTPUT" + echo "head=$HEAD" >> "$GITHUB_OUTPUT" + if [ "$PIN" = "$HEAD" ]; then + echo "status=current" >> "$GITHUB_OUTPUT" + echo "Pin is current ($PIN)." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if ! git -C /tmp/upstream merge-base --is-ancestor "$PIN" "$HEAD"; then + echo "status=diverged" >> "$GITHUB_OUTPUT" + exit 0 + fi + RELEVANT=$(git -C /tmp/upstream diff --name-only "$PIN" "$HEAD" \ + | grep "^$RELEVANT_GLOB" || true) + { + echo "commits<> "$GITHUB_OUTPUT" + if [ -z "$RELEVANT" ]; then + echo "status=advance" >> "$GITHUB_OUTPUT" + else + echo "status=triage" >> "$GITHUB_OUTPUT" + fi + + - name: Advance pin (no wire-relevant changes) + if: steps.cmp.outputs.status == 'advance' + run: | + set -euo pipefail + echo "${{ steps.cmp.outputs.head }}" > LAST_REVIEWED_SHA + git config user.name "upstream-watch" + git config user.email "actions@users.noreply.github.com" + git add LAST_REVIEWED_SHA + git commit -m "upstream-watch: advance pin to ${{ steps.cmp.outputs.head }} + + No commit in ${{ steps.cmp.outputs.pin }}..${{ steps.cmp.outputs.head }} + touches wire-relevant paths (include/ableton/)." + git push + echo "Pin advanced to ${{ steps.cmp.outputs.head }} (no wire-relevant changes)." \ + >> "$GITHUB_STEP_SUMMARY" + + - name: Open or update triage issue (wire-relevant changes) + if: steps.cmp.outputs.status == 'triage' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TITLE="upstream triage: ${{ steps.cmp.outputs.pin }}..${{ steps.cmp.outputs.head }}" + BODY=$(cat <<'EOF' + Upstream `Ableton/link` has moved past the reviewed pin and the diff + touches wire-relevant paths. Per PROVENANCE.md, classify each commit: + *no wire impact* (advance pin), *behavioral change* (spec errata + + regenerate vectors), or *wire-format change* (chapter revision + spec + version bump). Record verdicts in CHANGELOG.md. + + New commits (SHA, author date): + ``` + COMMITS_PLACEHOLDER + ``` + + Wire-relevant paths touched: + ``` + RELEVANT_PLACEHOLDER + ``` + EOF + ) + BODY=${BODY/COMMITS_PLACEHOLDER/"${{ steps.cmp.outputs.commits }}"} + BODY=${BODY/RELEVANT_PLACEHOLDER/"${{ steps.cmp.outputs.relevant }}"} + EXISTING=$(gh issue list --state open --search "in:title \"upstream triage:\"" \ + --json number --jq '.[0].number' || true) + if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then + gh issue comment "$EXISTING" --body "$BODY" + else + gh issue create --title "$TITLE" --body "$BODY" + fi + + - name: Report diverged history + if: steps.cmp.outputs.status == 'diverged' + run: | + echo "::warning::Upstream history no longer contains the pin (force push or branch change). Manual review required." + echo "Upstream diverged from pin — manual review required." >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 269148a..b060062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,80 @@ All notable changes to this specification. Every entry records the upstream pin (`Ableton/link` commit) the spec describes at that version. -## [Unreleased] — 0.1.0-draft +## [0.1.0] — 2026-06-11 -- Initial scaffolding: provenance rules, upstream-watch workflow, version pin. -- Drafts: 00-overview (serialization, transport model), 03-audio - (LinkAudio v1). Chapters 01-discovery and 02-sync are stubs. +First complete release. Spec text plus test vectors; this is the only artifact +the clean-side implementation ([link-wire-rs](https://github.com/structuresound/link-wire-rs)) +is permitted to consume. + +Upstream pin: `902aef95bf94af49746fdda5369b42cdcfa1e6d2` (2026-05-19). + +### Evidence model + +Every claim in the spec is tagged with how it is known (Chapter 0 §1.1): **[W]** +wire-observed (pinned to a released capture via auto-generated manifests and +structural assertions), **[B]** behavioral (dirty-side analysis of the reference, +not exercised by the captures), or **[N]** normative (a requirement of this spec). +Observable facts about the vectors — topology, gateways per peer, message-type +counts, datagram shapes — are *generated from the capture bytes* by +`tools/analyze_pcap.py` into `vectors/manifests/`, and each capture must pass the +per-scenario structural assertions in `tools/check_vectors.py` before release. + +### Added + +- **Chapter 1 (Discovery):** completed from stub — multicast transport + (`224.76.78.75:20808` v4, `ff12::8080` port 20808 v6), `_asdp_v\x01` framing, + Alive/Response/ByeBye message types, peer-state payload entries (`tmln`, `sess`, + `stst`, `mep4`/`mep6`, `aep4`/`aep6`) with the family-switch rule, ttl-based + timeout/pruning, socket-configuration facts with wire-visible consequences + (notably: multicast loopback only on loopback-address gateways), and full byte + layouts. +- **Chapter 2 (Sync):** completed from stub — `_link_v\x01` ping/pong measurement + protocol, `__ht`/`__gt`/`_pgt`/`sess` entries, the ghost-time transform and median + offset filter, the `tmln` timeline model with beat-origin priority, session + election/merge rules (ghost-time-wins with session-id tie-break, including its + behavior under measurement noise), `stst` start/stop propagation, and the + quantum/phase model with the exact inverse phase-encoding equations. Algorithm + rationale cited to F. Goltz, "Ableton Link — A technology to synchronize music + software," LAC 2018. +- **Test vectors** (`vectors/*.pcap`, CC0), each captured in an isolated network + namespace with a generated manifest: `discovery-join-leave`, `sync-tempo-change`, + `sync-start-stop`, `audio-channel-lifecycle` (including request keepalive + repetitions and a mid-stream tempo change), `multi-gateway-discovery`. +- **Tooling** (MIT): `tools/capture-vectors.sh` (netns-isolated scenario rig), + `tools/analyze_pcap.py` (field-level decoder + manifest generator), + `tools/check_vectors.py` (structural assertions), with CI workflows + `capture-vectors.yml` and `upstream-watch.yml`. Reference source is cloned + outside the repo and never vendored. +- **Conformance harness** (`conformance/`, MIT): drives a reference peer and a + candidate (any program speaking `CANDIDATE-CONTRACT.md`) through the + vector scenarios — discovery join/leave, tempo follow, start/stop, beat + phase alignment, audio announce→subscribe→stream→bye — emitting pass/fail + observations as plain text. Contains no protocol logic (assertions are on + observable endpoint behavior only); self-tests reference-vs-reference in CI + (`conformance-selftest.yml`); ships an example workflow for candidate + repositories. Homed in this repo so the dirty-side-authored harness stays + behind the release gate; the clean side consumes it from a release tag + (PROVENANCE.md firewall item 2). + +### Open-question verdicts + +| # | Chapter | Question | Verdict | Evidence | +|---|---|---|---|---| +| 00-§4.2 | Overview | string length `N` not bound-checked before construction | Bound is required of implementations (`N` > remaining ⇒ parse error). No on-wire string exceeds its region. | [B] reference analysis; [N] requirement; benign case [W] | +| 00-§4.5(7) | Overview | are duplicate payload-container keys ever legitimate? | No. Senders MUST NOT emit duplicates; receivers apply last-one-wins. (Systematic near-exception: the sync pong's verbatim echo, Ch.2 §4.1.) | absence [W]; semantics [B]; rule [N] | +| 03-1 | Audio | does an `_abu` header precede the AudioBuffer structure? | **No** — payload begins bare with the channel id. | [W] asserted over every captured AudioBuffer | +| 03-2 | Audio | do receivers enforce a 1176- vs 1180-byte payload ceiling? | No receive-side ceiling; bounded only by the 1200-byte socket buffer. 24-byte budget is sender-side only. | [B]; not exercised by any vector | +| 03-3 | Audio | exact derivation of the 50-byte non-audio allowance | None — hand-chosen fixed allowance; encoder subtracts the real chunk-list size at runtime. | [B]; resulting 502-byte cap [W] | +| 03-4 | Audio | receiver behavior for names > 256 bytes | Cap is sender-side only; receivers accept longer length-prefixed names. | [B]; not exercised by any vector | +| 03-5 | Audio | handling of unknown nonzero codec values | Reference parses and decodes as PCM i16 (no recheck). Spec recommends rejecting unknown codecs. | [B]; codec-1-only traffic [W]; recommendation [N] | +| 03-6 | Audio | semantics of nonzero `groupId` | Reserved; MUST send 0, MUST ignore nonzero. | send-0 [W]; drop-nonzero [B]; rule [N] | +| 03-7 | Audio | duplicate payload entries legitimate? | Same as 00-§4.5(7): no. | as above | +| 03-8 | Audio | cross-host usability of advertised IPv6 (`aep6`) addresses | **Deferred** — requires `discovery-ipv6.pcap`; the capture environment's kernel has no IPv6 support. The rig emits it automatically where IPv6 exists. | open | + +## [0.1.0-draft] — initial scaffolding + +- Provenance rules, version pin. +- Drafts: 00-overview (serialization, transport model), 03-audio (LinkAudio v1). + Chapters 01-discovery and 02-sync were stubs. - Upstream pin: `902aef95bf94af49746fdda5369b42cdcfa1e6d2` (2026-05-19). diff --git a/PROVENANCE.md b/PROVENANCE.md index 4d5276d..9aabc35 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -40,7 +40,7 @@ in doubt, express the fact as a table, equation, or state machine. |---|---|---| | Spec text (`spec/`) | CC-BY-4.0 | facts only, per the rules above | | Test vectors (`vectors/`) | CC0 | packet captures of reference peers; protocol facts | -| Tooling (workflows, capture scripts) | MIT | | +| Tooling (workflows, capture scripts, conformance harness) | MIT | the harness (`conformance/`) contains no protocol logic: it asserts on observable endpoint behavior only | Reference binaries are built from upstream in CI for capture and conformance purposes and are never redistributed from this repository. @@ -50,8 +50,12 @@ purposes and are never redistributed from this repository. Implementations claiming clean-room provenance from this spec may use ONLY: 1. Released versions of this specification and its test vectors. -2. Conformance-harness results phrased as observations (pass/fail, - measured behavior) — never as reference-source diffs. +2. The released conformance harness (`conformance/`) — which the clean side + may execute against its candidate, fetching this repository at a release + tag into CI caches only (never vendoring it) — and the harness's results + phrased as observations (pass/fail, measured behavior) — never as + reference-source diffs. The harness is authored on the dirty side and is + releasable because it contains no protocol implementation logic. 3. Public non-GPL documentation (the Goltz paper, Ableton's public help pages and FAQ). diff --git a/README.md b/README.md index d0e0f03..2354cf2 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,19 @@ source) and implementers (who must not). | Chapter | Scope | Status | |---|---|---| -| [spec/00-overview.md](spec/00-overview.md) | Terminology, transport model, common serialization rules | draft | -| [spec/01-discovery.md](spec/01-discovery.md) | Multicast peer discovery, peer state gossip | stub | -| [spec/02-sync.md](spec/02-sync.md) | Timeline, tempo, clock measurement, start/stop sync | stub | -| [spec/03-audio.md](spec/03-audio.md) | LinkAudio v1: channels, sinks/sources, audio buffers, beat-time alignment | draft | -| `vectors/` | Captured packet traces (golden test vectors) | pending | +| [spec/00-overview.md](spec/00-overview.md) | Terminology, transport model, common serialization rules | v0.1.0 | +| [spec/01-discovery.md](spec/01-discovery.md) | Multicast peer discovery, peer state gossip | v0.1.0 | +| [spec/02-sync.md](spec/02-sync.md) | Timeline, tempo, clock measurement, start/stop sync | v0.1.0 | +| [spec/03-audio.md](spec/03-audio.md) | LinkAudio v1: channels, sinks/sources, audio buffers, beat-time alignment | v0.1.0 | +| [vectors/](vectors/) | Captured packet traces (golden test vectors) with auto-generated observed-fact manifests | v0.1.0 | +| [conformance/](conformance/) | Conformance harness: reference-vs-candidate scenarios emitting pass/fail observations; no protocol logic | v0.1.0 | + +Every claim in the spec carries an evidence class (Chapter 0 §1.1): wire-observed +in a vector, behavioral (reference analysis), or normative. Observable facts about +the vectors are generated from the capture bytes +([tools/analyze_pcap.py](tools/analyze_pcap.py)) and structurally asserted +([tools/check_vectors.py](tools/check_vectors.py)), so descriptions cannot drift +from what the captures contain. ## Versioning and upstream tracking diff --git a/conformance/CANDIDATE-CONTRACT.md b/conformance/CANDIDATE-CONTRACT.md new file mode 100644 index 0000000..4c451d8 --- /dev/null +++ b/conformance/CANDIDATE-CONTRACT.md @@ -0,0 +1,67 @@ +# Conformance candidate contract + +| | | +|---|---| +| Contract version | 1 | +| License | CC-BY-4.0 | + +To be tested by the conformance harness (`conformance/run.py`), a candidate +implementation provides an executable that exposes its peer through this +line-based stdin/stdout interface. The contract is deliberately about +*application-observable state only* — peers, tempo, transport, beats, audio +channels — so that neither the harness nor the contract encodes any wire +knowledge beyond the released specification. + +The harness launches the executable given by `CANDIDATE_CMD` (and, for audio +scenarios, `CANDIDATE_AUDIO_CMD`), writes commands to its stdin, and reads +status lines from its stdout. + +## Requirements + +- The peer MUST start in the **disabled** state with tempo 120 bpm and quantum 4. +- Lines are UTF-8, newline-terminated. Unknown commands MUST be ignored. +- Stdout MUST NOT contain lines other than those defined here. + +## Commands (stdin) + +| Command | Effect | +|---|---| +| `enable` / `disable` | join / leave the network (Link enable state) | +| `tempo ` | set the session tempo | +| `start` / `stop` | start / stop the transport | +| `startstop-sync <0\|1>` | disable / enable start-stop synchronization | +| `quit` | shut down cleanly (send departure announcements) and exit | +| `audio-enable` / `audio-disable` | (audio feature) enable LinkAudio, publishing exactly one channel / withdraw it | +| `audio-subscribe ` | (audio feature) subscribe to the index-th channel of the currently visible channel list (sorted by peer name, then channel name) | +| `audio-unsubscribe` | (audio feature) drop the subscription | + +## Status lines (stdout) + +Emit `ready` once when the peer is operational, then a `status` line at least +every 500 ms *and* on every state change: + +``` +status peers= tempo= playing=<0|1> beat= quantum= +``` + +`beat` is the application beat time at the moment of emission (the value the +implementation would report to its client for "now", at the stated quantum). + +Candidates declaring the `audio` feature (via the `CANDIDATE_FEATURES` +environment variable read by the harness) append: + +``` + audio=<0|1> channels= receiving=<0|1> publishing=<0|1> +``` + +where `channels` counts currently visible remote channels and `receiving` is 1 +while subscribed audio is arriving. + +## Notes + +- Timing assertions in the harness allow several seconds; sub-second status + cadence is sufficient. +- The harness compensates for sampling skew when comparing `beat` values, but + the value should be computed at (or very near) emission time. +- Exit code of the candidate process is not asserted; `quit` MUST terminate it + within 5 seconds. diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..68b2080 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,88 @@ +# Conformance harness + +Drives a **reference** Link peer (built from the pinned upstream commit) and a +**candidate** implementation side by side through the same scenarios the test +vectors capture, and emits pass/fail **observations** as plain text: + +``` +OBS | tempo-follow | PASS | reference adopted candidate's tempo 100 bpm in 0.2s (reference reports 100.0) +``` + +## Why it lives in the spec repository + +This harness is dirty-side tooling (its authors read the GPL reference; see +[PROVENANCE.md](../PROVENANCE.md)), so it is published here — behind the spec +repo's release gate, where provenance review happens — rather than authored +into the clean-side implementation repo. It is part of the release artifact +(MIT, like the capture tooling). The clean side consumes it the same way it +consumes vectors: **from a released version**, fetched into CI caches at a +pinned tag and never vendored. The clean team performs its own integration +(see "Integrating in a candidate repository" below), which requires no +protocol knowledge. + +Two properties keep that consumption safe: + +1. **No protocol logic.** The harness never constructs, parses, or inspects a + network message. Every assertion is about *observable endpoint behavior*: + what each peer reports about its peers, tempo, transport, beat position, + and audio reception. Reading this code teaches nothing about the wire + beyond what the released spec already states. +2. **Observations only.** Results are pass/fail statements with measured + behavior — the form of evidence the PROVENANCE firewall explicitly permits + the clean side to use. Never reference-source diffs. + +The reference itself is cloned and built **outside the repository** +(`tools/build-reference.sh`) and exists only in CI caches. + +## Files + +| File | Role | +|---|---| +| `run.py` | scenario runner and assertions (pure orchestration) | +| `hut_adapter.py` | drives the reference hut binaries through their own keyboard/stdout interface, translating to the candidate contract; also serves as the self-test stand-in candidate | +| `run-isolated.sh` | wrapper: builds the reference, enters an isolated network namespace (loopback only), starts a dummy JACK server, runs `run.py` | +| `CANDIDATE-CONTRACT.md` | the stdin/stdout interface a candidate must expose | +| `example-candidate-ci.yml` | a workflow a candidate repository can copy to run the harness against its binary | + +## Scenarios and observations + +| Scenario | Observed behavior (each line an OBS) | +|---|---| +| `discovery-join-leave` | each side reports the other after enable; reference's peer count returns to 0 after the candidate quits | +| `tempo-follow` | tempo set on either side is adopted by the other | +| `start-stop` | transport start/stop on either side is followed by the other (start/stop sync enabled) | +| `beat-alignment` | skew-compensated phase difference at quantum 4 within 0.3 beats | +| `audio-stream` | each side sees the other's announced channel; subscribed audio arrives in both directions; withdrawing a channel empties the other side's list | + +The `audio-stream` scenario runs only when the candidate declares the `audio` +feature (`CANDIDATE_FEATURES=audio`). + +## Running + +Self-test (reference vs reference — validates the harness itself; this is what +the spec repo's CI runs): + +``` +sudo conformance/run-isolated.sh +``` + +Against a candidate: + +``` +export CANDIDATE_CMD="path/to/candidate --contract" # speaks CANDIDATE-CONTRACT.md +export CANDIDATE_FEATURES="" # or "audio" +sudo conformance/run-isolated.sh +``` + +Exit code is 0 iff no observation failed. Individual scenarios can be selected +by name: `sudo conformance/run-isolated.sh tempo-follow beat-alignment`. + +## Integrating in a candidate repository + +Copy `example-candidate-ci.yml` into the candidate repo's workflows after +review. It checks out this spec repository **at a pinned release tag** into the +runner's temp directory (a CI cache, never committed), builds the candidate, +and runs the harness with `CANDIDATE_CMD` pointing at the candidate binary. +The observation log is the job output; the firewall obligation on the +candidate side is to keep it that way — consume observations, never the +reference source that the harness builds in its cache. diff --git a/conformance/example-candidate-ci.yml b/conformance/example-candidate-ci.yml new file mode 100644 index 0000000..b17960b --- /dev/null +++ b/conformance/example-candidate-ci.yml @@ -0,0 +1,66 @@ +# Example workflow for a CANDIDATE repository (e.g. link-wire-rs): +# run the link-wire-spec conformance harness against the candidate binary. +# +# Copy into .github/workflows/ of the candidate repo (after review) and adjust +# the two CUSTOMIZE points. The spec repo — and the GPL reference it builds — +# live only in the runner's temp directory and caches, never in the candidate +# repo's tree or artifacts (clean-room firewall; see the spec repo's +# PROVENANCE.md). +# +# License: MIT + +name: conformance + +on: + push: + workflow_dispatch: + +permissions: + contents: read + +env: + SPEC_REPO: structuresound/link-wire-spec + SPEC_REF: v0.1.0 # pin the released spec version the candidate targets + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - name: Checkout candidate + uses: actions/checkout@v4 + + # CUSTOMIZE: build your candidate and its contract-speaking executable + - name: Build candidate + run: | + cargo build --release --bin conformance-peer + + - name: Fetch spec + harness at pinned release (outside the workspace) + run: | + git clone --depth 1 --branch "$SPEC_REF" \ + "https://github.com/$SPEC_REPO.git" "$RUNNER_TEMP/link-wire-spec" + + - name: Install harness dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ git iproute2 util-linux python3 \ + jackd2 libjack-jackd2-dev portaudio19-dev libasound2-dev + + - name: Run conformance harness + env: + # CUSTOMIZE: how to run your candidate (CANDIDATE-CONTRACT.md) and + # which optional features it declares ("" or "audio") + CANDIDATE_CMD: ${{ github.workspace }}/target/release/conformance-peer + CANDIDATE_FEATURES: "" + LINK_CAPTURE_WORK: ${{ runner.temp }}/link-reference + run: | + sudo --preserve-env=CANDIDATE_CMD,CANDIDATE_FEATURES,LINK_CAPTURE_WORK \ + bash "$RUNNER_TEMP/link-wire-spec/conformance/run-isolated.sh" \ + | tee conformance-observations.txt + + - name: Upload observation log + if: always() + uses: actions/upload-artifact@v4 + with: + name: conformance-observations + path: conformance-observations.txt diff --git a/conformance/hut_adapter.py b/conformance/hut_adapter.py new file mode 100755 index 0000000..9ec6190 --- /dev/null +++ b/conformance/hut_adapter.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""hut_adapter.py — drive a reference Link peer (LinkHutSilent / LinkAudioHut) +through the conformance candidate contract (CANDIDATE-CONTRACT.md). + +The adapter translates contract commands into the keystrokes the hut binaries +accept, and parses their printed status into contract `status` lines. Both the +key bindings and the status format are the programs' own runtime interface +(printed by their usage text at startup); no protocol logic is involved — +this file never constructs or parses a network message. + +Used two ways by the harness: + - as the driver for reference peers, and + - as the default stand-in candidate (self-test mode: reference vs reference). + +Usage: + hut_adapter.py --binary PATH [--audio] [--name NAME] + +License: MIT +""" +import argparse +import os +import re +import subprocess +import sys +import threading +import time + +# Status-line patterns for the huts' periodic state printout (one line per +# refresh, carriage-return separated). Fields per the binaries' own header +# line: LinkHutSilent prints +# enabled | num peers | quantum | start stop sync | tempo | beats | metro +# and LinkAudioHut prints +# enabled [au] | num peers | start stop sync | source (buffered)| tempo | beats | metro +RE_PLAIN = re.compile( + r"^(yes|no)\s*\|\s*(\d+)\s*\|\s*([\d.]+)\s*\|\s*(yes|no)\s+\[(playing|stopped)\]" + r"\s*\|\s*([\d.]+)\s*\|\s*(-?[\d.]+)\s*\|" +) +RE_AUDIO = re.compile( + r"^(yes|no)\s*\[(yes|no)\]\s*\|\s*(\d+)\s*\|\s*(yes|no)\s+\[(playing|stopped)\]" + r"\s*\|\s*(yes|no)\s*\(\s*([\d.]+)s\)\|\s*([\d.]+)\s*\|\s*(-?[\d.]+)\s*\|" +) + + +class HutAdapter: + def __init__(self, binary, audio=False, name="adapter"): + self.audio = audio + argv = [binary] + ([name] if audio else []) + self.proc = subprocess.Popen( + argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=0) + self.lock = threading.Lock() + # tracked state, parsed from the hut's own printout + self.state = None # dict once first status line parsed + self.channels = 0 # audio mode: channels currently listed by the hut + self.ready_emitted = False + self.last_emit = 0.0 + self.awaiting_index_prompt = False + self.pending_subscribe = None + + # ---- hut input ----------------------------------------------------- + + def keys(self, s): + try: + self.proc.stdin.write(s.encode()) + self.proc.stdin.flush() + except (BrokenPipeError, ValueError): + pass + + # ---- hut output parsing -------------------------------------------- + + def pump(self): + """Read hut stdout, splitting on CR/LF; parse status and channel + lists; emit contract lines.""" + buf = b"" + channel_block = None + while True: + chunk = self.proc.stdout.read(256) + if not chunk: + break + buf += chunk + while True: + # split on either line ending; huts refresh with '\r' + idx_r = buf.find(b"\r") + idx_n = buf.find(b"\n") + idx = min(x for x in (idx_r, idx_n) if x >= 0) if ( + idx_r >= 0 or idx_n >= 0) else -1 + if idx < 0: + break + line = buf[:idx].decode("latin1") + buf = buf[idx + 1:] + channel_block = self.handle_line(line.strip(), channel_block) + # the channel-index prompt has no trailing newline; detect in buf + if self.pending_subscribe is not None and b"Enter channel index" in buf: + self.keys(f"{self.pending_subscribe}\n") + self.pending_subscribe = None + buf = b"" + + def handle_line(self, line, channel_block): + # channel list block (audio): "LinkAudio Peers:" then "peer | name" rows + if self.audio: + if "LinkAudio Peers:" in line or "Select channel index:" in line: + return [] + if channel_block is not None: + if " | " in line and not line.startswith("enabled"): + channel_block.append(line) + return channel_block + # block ended (blank line or header) + self.channels = len(channel_block) + return None + + m = RE_AUDIO.match(line) if self.audio else RE_PLAIN.match(line) + if m: + g = m.groups() + if self.audio: + st = {"enabled": g[0] == "yes", "audio": g[1] == "yes", + "peers": int(g[2]), "quantum": 4.0, + "startstop_sync": g[3] == "yes", + "playing": g[4] == "playing", "source": g[5] == "yes", + "buffered": float(g[6]), "tempo": float(g[7]), + "beat": float(g[8])} + else: + st = {"enabled": g[0] == "yes", "peers": int(g[1]), + "quantum": float(g[2]), "startstop_sync": g[3] == "yes", + "playing": g[4] == "playing", "tempo": float(g[5]), + "beat": float(g[6])} + with self.lock: + self.state = st + self.emit_status() + return channel_block + + # ---- contract output ------------------------------------------------ + + def emit(self, line): + sys.stdout.write(line + "\n") + sys.stdout.flush() + + def emit_status(self, force=False): + now = time.monotonic() + if not self.ready_emitted: + self.ready_emitted = True + self.emit("ready") + if not force and now - self.last_emit < 0.25: + return + self.last_emit = now + st = self.state + extra = "" + if self.audio: + receiving = 1 if (st["source"] and st["buffered"] > 0) else 0 + extra = (f" audio={int(st['audio'])} channels={self.channels}" + f" receiving={receiving} publishing={int(st['audio'])}") + self.emit( + f"status peers={st['peers']} tempo={st['tempo']:.2f}" + f" playing={int(st['playing'])} beat={st['beat']:.2f}" + f" quantum={st['quantum']:g} ts={time.time():.3f}" + extra) + + # ---- contract commands ---------------------------------------------- + + def wait_state(self, timeout=5.0): + t0 = time.monotonic() + while self.state is None and time.monotonic() - t0 < timeout: + time.sleep(0.02) + return self.state + + def command(self, line): + parts = line.split() + if not parts: + return True + cmd, args = parts[0], parts[1:] + st = self.wait_state() or {} + if cmd == "quit": + self.keys("q") + return False + if cmd == "enable": + if not st.get("enabled"): + self.keys("a") + elif cmd == "disable": + if st.get("enabled"): + self.keys("a") + elif cmd == "tempo" and args: + # the huts step tempo by 1 bpm per keypress + target = round(float(args[0])) + current = round(st.get("tempo", 120.0)) + delta = target - current + self.keys("e" * delta if delta > 0 else "w" * (-delta)) + elif cmd == "start": + if not st.get("playing"): + self.keys(" ") + elif cmd == "stop": + if st.get("playing"): + self.keys(" ") + elif cmd == "startstop-sync" and args: + want = args[0] == "1" + if st.get("startstop_sync") != want: + self.keys("s") + elif cmd == "audio-enable" and self.audio: + if not st.get("audio"): + self.keys("c") + elif cmd == "audio-disable" and self.audio: + if st.get("audio"): + self.keys("c") + elif cmd == "audio-subscribe" and self.audio and args: + if not st.get("source"): + self.pending_subscribe = int(args[0]) + self.keys("o") + elif cmd == "audio-unsubscribe" and self.audio: + if st.get("source"): + self.keys("o") + return True + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--binary", required=True) + ap.add_argument("--audio", action="store_true") + ap.add_argument("--name", default="adapter") + args = ap.parse_args() + + adapter = HutAdapter(args.binary, audio=args.audio, name=args.name) + t = threading.Thread(target=adapter.pump, daemon=True) + t.start() + + try: + for line in sys.stdin: + if not adapter.command(line.strip()): + break + except KeyboardInterrupt: + adapter.keys("q") + adapter.proc.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/conformance/run-isolated.sh b/conformance/run-isolated.sh new file mode 100755 index 0000000..699ccdc --- /dev/null +++ b/conformance/run-isolated.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# run-isolated.sh — run the conformance harness inside an isolated network +# namespace (loopback only), after building the pinned reference outside the +# repository. Starts a dummy-backend JACK server for the audio scenarios when +# jackd is available. +# +# Usage: conformance/run-isolated.sh [scenario ...] +# (scenarios as listed by conformance/run.py; default: all) +# +# Env passthrough: CANDIDATE_CMD, CANDIDATE_AUDIO_CMD, CANDIDATE_FEATURES — +# see conformance/README.md. Requires root (netns + reference build deps). +# License: MIT + +set -euo pipefail +export PATH="$PATH:/usr/sbin:/sbin" + +REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +if [ "${1:-}" != "--inner" ]; then + REFERENCE_BIN_DIR=$("$REPO_DIR/tools/build-reference.sh") + export REFERENCE_BIN_DIR + exec unshare --net "$BASH" "$0" --inner "$@" +fi +shift # --inner + +ip link set lo up + +JACK_PID="" +cleanup() { [ -n "$JACK_PID" ] && kill "$JACK_PID" 2>/dev/null || true; } +trap cleanup EXIT + +if command -v jackd >/dev/null; then + JACK_NO_AUDIO_RESERVATION=1 jackd -r -d dummy -r 48000 -p 256 \ + >/tmp/conformance-jackd.log 2>&1 & + JACK_PID=$! + sleep 2 + export CONFORMANCE_AUDIO=1 +else + echo "[conformance] jackd not found: audio scenarios will be skipped" >&2 + export CONFORMANCE_AUDIO=0 +fi + +exec python3 "$REPO_DIR/conformance/run.py" "$@" diff --git a/conformance/run.py b/conformance/run.py new file mode 100755 index 0000000..0938c4c --- /dev/null +++ b/conformance/run.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""run.py — Link wire-protocol conformance harness. + +Drives a *reference* peer (the pinned upstream implementation, wrapped by +hut_adapter.py) and a *candidate* peer (any program speaking +CANDIDATE-CONTRACT.md) side by side through the same scenarios the spec's +test vectors capture, and emits pass/fail OBSERVATIONS as plain text. + +The harness contains no protocol implementation logic: it never constructs, +parses, or inspects a network message. Every assertion is about observable +endpoint behavior — what each peer *reports* about peers, tempo, transport, +and audio reception. This is what keeps the harness (and its results) safe +for the clean-room implementation side to consume; see PROVENANCE.md. + +Environment: + REFERENCE_BIN_DIR dir containing LinkHutSilent and LinkAudioHut (required) + CANDIDATE_CMD shell command for the candidate peer; default: a second + reference peer via hut_adapter.py (self-test mode) + CANDIDATE_AUDIO_CMD shell command for the candidate in audio scenarios; + default mirrors CANDIDATE_CMD's self-test behavior + CANDIDATE_FEATURES comma list of optional features ("audio"); + self-test mode defaults to CONFORMANCE_AUDIO=1's value + +Output: one line per observation: + OBS | | PASS|FAIL|SKIP | +Exit code 0 iff no FAIL. License: MIT +""" +import os +import shlex +import subprocess +import sys +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +FAILED = [] + + +def obs(scenario, ok, text): + verdict = "SKIP" if ok is None else ("PASS" if ok else "FAIL") + print(f"OBS | {scenario} | {verdict} | {text}", flush=True) + if ok is False: + FAILED.append(f"{scenario}: {text}") + + +class Peer: + """A peer process speaking the candidate contract on stdin/stdout.""" + + def __init__(self, label, argv_or_cmd, shell=False): + self.label = label + self.proc = subprocess.Popen( + argv_or_cmd, shell=shell, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) + self.latest = {} + self.latest_t = None + self.ready = threading.Event() + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + for raw in self.proc.stdout: + line = raw.decode("latin1").strip() + if line == "ready": + self.ready.set() + elif line.startswith("status "): + fields = {} + for tok in line.split()[1:]: + if "=" in tok: + k, v = tok.split("=", 1) + try: + fields[k] = float(v) if "." in v else int(v) + except ValueError: + fields[k] = v + self.latest = fields + self.latest_t = time.monotonic() + self.ready.set() + + def send(self, cmd): + try: + self.proc.stdin.write((cmd + "\n").encode()) + self.proc.stdin.flush() + except (BrokenPipeError, ValueError): + pass + + def wait(self, pred, timeout): + """Wait until pred(latest_status) is true. Returns (ok, elapsed).""" + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + st = self.latest + if st and pred(st): + return True, time.monotonic() - t0 + time.sleep(0.05) + return False, timeout + + def stop(self): + self.send("quit") + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +# ---------------------------------------------------------------- factories + +def reference_cmd(audio=False, name="Ref"): + bindir = os.environ["REFERENCE_BIN_DIR"] + base = [sys.executable, os.path.join(HERE, "hut_adapter.py")] + if audio: + return base + ["--binary", os.path.join(bindir, "LinkAudioHut"), + "--audio", "--name", name] + return base + ["--binary", os.path.join(bindir, "LinkHutSilent")] + + +def make_reference(audio=False, name="Ref"): + p = Peer("reference", reference_cmd(audio, name)) + p.ready.wait(10) + return p + + +def make_candidate(audio=False): + env_key = "CANDIDATE_AUDIO_CMD" if audio else "CANDIDATE_CMD" + cmd = os.environ.get(env_key) or (not audio and os.environ.get("CANDIDATE_CMD")) + if audio and not os.environ.get("CANDIDATE_AUDIO_CMD") \ + and not os.environ.get("CANDIDATE_CMD"): + cmd = None + if cmd: + p = Peer("candidate", cmd, shell=True) + else: + # self-test mode: the candidate is a second reference peer + p = Peer("candidate(self-test)", + reference_cmd(audio=audio, name="Cand")) + p.ready.wait(10) + return p + + +def candidate_features(): + feats = os.environ.get("CANDIDATE_FEATURES") + if feats is None and not os.environ.get("CANDIDATE_CMD"): + # self-test: audio capability is decided by the wrapper (jackd present) + feats = "audio" if os.environ.get("CONFORMANCE_AUDIO") == "1" else "" + return {f.strip() for f in (feats or "").split(",") if f.strip()} + + +# ---------------------------------------------------------------- scenarios + +def scenario_discovery_join_leave(): + s = "discovery-join-leave" + ref, cand = make_reference(), make_candidate() + try: + ref.send("enable") + ok, _ = ref.wait(lambda st: True, 5) + cand.send("enable") + ok, dt = ref.wait(lambda st: st.get("peers", 0) >= 1, 8) + obs(s, ok, f"reference reported >=1 peer {dt:.1f}s after candidate enable") + ok, dt = cand.wait(lambda st: st.get("peers", 0) >= 1, 8) + obs(s, ok, f"candidate reported >=1 peer {dt:.1f}s after its enable") + cand.stop() + ok, dt = ref.wait(lambda st: st.get("peers", 1) == 0, 10) + obs(s, ok, f"reference peer count returned to 0 {dt:.1f}s after candidate quit" + " (departure announcement or timeout)") + finally: + ref.stop() + cand.stop() + + +def join(ref, cand, s): + ref.send("enable") + cand.send("enable") + ok1, _ = ref.wait(lambda st: st.get("peers", 0) >= 1, 8) + ok2, _ = cand.wait(lambda st: st.get("peers", 0) >= 1, 8) + if not (ok1 and ok2): + obs(s, False, "peers failed to join a common session (setup)") + return False + return True + + +def scenario_tempo_follow(): + s = "tempo-follow" + ref, cand = make_reference(), make_candidate() + try: + if not join(ref, cand, s): + return + cand.send("tempo 100") + ok, dt = ref.wait(lambda st: abs(st.get("tempo", 0) - 100.0) < 0.01, 5) + obs(s, ok, f"reference adopted candidate's tempo 100 bpm in {dt:.1f}s" + f" (reference reports {ref.latest.get('tempo')})") + ref.send("tempo 124") + ok, dt = cand.wait(lambda st: abs(st.get("tempo", 0) - 124.0) < 0.01, 5) + obs(s, ok, f"candidate adopted reference's tempo 124 bpm in {dt:.1f}s" + f" (candidate reports {cand.latest.get('tempo')})") + finally: + ref.stop() + cand.stop() + + +def scenario_start_stop(): + s = "start-stop" + ref, cand = make_reference(), make_candidate() + try: + if not join(ref, cand, s): + return + ref.send("startstop-sync 1") + cand.send("startstop-sync 1") + time.sleep(1) + cand.send("start") + ok, dt = ref.wait(lambda st: st.get("playing") == 1, 5) + obs(s, ok, f"reference started playing {dt:.1f}s after candidate start") + cand.send("stop") + ok, dt = ref.wait(lambda st: st.get("playing") == 0, 5) + obs(s, ok, f"reference stopped {dt:.1f}s after candidate stop") + ref.send("start") + ok, dt = cand.wait(lambda st: st.get("playing") == 1, 5) + obs(s, ok, f"candidate started playing {dt:.1f}s after reference start") + finally: + ref.stop() + cand.stop() + + +def scenario_beat_alignment(): + s = "beat-alignment" + ref, cand = make_reference(), make_candidate() + try: + if not join(ref, cand, s): + return + time.sleep(2) # settle + q = 4.0 + samples = [] + for _ in range(6): + st1, t1 = ref.latest, ref.latest_t + st2, t2 = cand.latest, cand.latest_t + if not (st1 and st2 and "beat" in st1 and "beat" in st2): + time.sleep(0.5) + continue + tempo = st1.get("tempo", 120.0) + # compensate the candidate's beat for the sampling-time skew + b2 = st2["beat"] + (t1 - t2) * tempo / 60.0 + diff = (st1["beat"] - b2) % q + if diff > q / 2: + diff -= q + samples.append(abs(diff)) + time.sleep(0.5) + if not samples: + obs(s, False, "no concurrent beat reports from both peers") + return + best = min(samples) + obs(s, best <= 0.3, + f"phase difference at quantum {q:g}: best {best:.3f} beats over " + f"{len(samples)} samples (tolerance 0.3)") + finally: + ref.stop() + cand.stop() + + +def scenario_audio_stream(): + s = "audio-stream" + if "audio" not in candidate_features(): + obs(s, None, "candidate does not declare the audio feature") + return + ref, cand = make_reference(audio=True, name="RefPub"), make_candidate(audio=True) + try: + if not join(ref, cand, s): + return + ref.send("audio-enable") + cand.send("audio-enable") + ok, dt = cand.wait(lambda st: st.get("channels", 0) >= 1, 8) + obs(s, ok, f"candidate saw the reference's announced channel in {dt:.1f}s") + ok2, dt = ref.wait(lambda st: st.get("channels", 0) >= 1, 8) + obs(s, ok2, f"reference saw the candidate's announced channel in {dt:.1f}s") + if ok: + cand.send("audio-subscribe 0") + okr, dt = cand.wait(lambda st: st.get("receiving") == 1, 12) + obs(s, okr, f"candidate received streamed audio {dt:.1f}s after subscribing") + cand.send("audio-unsubscribe") + if ok2: + ref.send("audio-subscribe 0") + okr, dt = ref.wait(lambda st: st.get("receiving") == 1, 12) + obs(s, okr, f"reference received streamed audio {dt:.1f}s after " + "subscribing to the candidate's channel") + ref.send("audio-unsubscribe") + cand.send("audio-disable") + ok, dt = ref.wait(lambda st: st.get("channels", 1) == 0, 8) + obs(s, ok, f"reference's channel list emptied {dt:.1f}s after candidate " + "withdrew (channel byes)") + finally: + ref.stop() + cand.stop() + + +SCENARIOS = [ + scenario_discovery_join_leave, + scenario_tempo_follow, + scenario_start_stop, + scenario_beat_alignment, + scenario_audio_stream, +] + + +def main(): + if "REFERENCE_BIN_DIR" not in os.environ: + print("REFERENCE_BIN_DIR not set (see conformance/README.md)", file=sys.stderr) + return 2 + wanted = sys.argv[1:] + for fn in SCENARIOS: + name = fn.__name__.replace("scenario_", "").replace("_", "-") + if wanted and name not in wanted: + continue + fn() + if FAILED: + print(f"\n{len(FAILED)} observation(s) FAILED:", file=sys.stderr) + for f in FAILED: + print(f" - {f}", file=sys.stderr) + return 1 + print("\nall observations passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spec/00-overview.md b/spec/00-overview.md index 711c0bd..137de2c 100644 --- a/spec/00-overview.md +++ b/spec/00-overview.md @@ -2,7 +2,7 @@ | | | |---|---| -| Spec version | 0.1.0-draft | +| Spec version | 0.1.0 | | Upstream reference | Ableton/link @ `902aef95bf94af49746fdda5369b42cdcfa1e6d2` | | License | CC-BY-4.0 | @@ -25,6 +25,21 @@ LinkAudio v1 extension: This chapter defines terminology, the transport layout, and the **common serialization rules** that every other chapter depends on. +### 1.1 Evidence classes + +So that no statement in this specification can drift from what was actually verified, +claims are tagged with how they are known. The tags appear throughout all chapters: + +| Tag | Class | Meaning | +|---|---|---| +| **[W]** | wire-observed | Demonstrated by a released capture in `vectors/`; the auto-generated manifest (`vectors/manifests/`) and the structural checks (`tools/check_vectors.py`) pin the fact to packet bytes. | +| **[B]** | behavioral | Determined by dirty-side analysis of the reference implementation or by runtime experiment, but **not exercised** by the released captures. Reliable, but not currently conformance-testable from the vectors alone. | +| **[N]** | normative | A requirement this specification imposes for interoperability or safety. May be stricter than what the reference enforces. | + +Untagged statements describing byte layouts are [W] wherever the message type appears +in any vector, [B] otherwise. The changelog records, for every resolved open +question, which class its verdict rests on. + ## 2. Terminology | Term | Definition | @@ -88,9 +103,12 @@ bytes (the reference treats this as a parse failure for the whole containing ite | 4 | `N` | bytes | string contents, raw bytes (no NUL terminator, no padding) | The reference treats strings as opaque byte sequences; no character-set validation is -performed. OPEN QUESTION: the reference decoder does not visibly bound-check `N` -against the remaining bytes of the enclosing region before constructing the string; -implementations MUST treat `N` greater than the remaining byte count as a parse error. +performed [B]. The reference decoder does not bound-check `N` against the remaining +bytes of the enclosing region before constructing the string [B]; a hostile `N` +larger than the available bytes is a memory-safety hazard in a naive port. No string +in any captured vector exceeds its enclosing region [W]. **[N] Requirement:** +implementations MUST treat `N` greater than the remaining byte count as a parse error +and MUST NOT read past the buffer. ### 4.3 Fixed-size arrays @@ -136,8 +154,13 @@ Rules, stated as protocol requirements derived from observed behavior: 6. Receivers MUST NOT assume any particular entry order. (The reference dispatches entries through a key-indexed table.) 7. Duplicate keys: each occurrence is dispatched in stream order; a later occurrence - of the same key overwrites the effect of an earlier one in the reference. - OPEN QUESTION: whether emitting duplicates is ever legitimate. + of the same key overwrites the effect of an earlier one in the reference + (last-one-wins) [B]. No message in any captured vector emits a duplicate key [W]. + **[N] Requirement:** senders MUST NOT emit duplicate entry keys within one + payload; receivers SHOULD apply last-one-wins defensively. (One systematic + exception exists in the sync protocol, where a Pong echoes the Ping's payload + bytes after its own entries — see Chapter 2 §4.1; the echoed keys do not collide + with the Pong's own in practice.) ### 4.6 Tuples / composite structures diff --git a/spec/01-discovery.md b/spec/01-discovery.md index 02ff596..678c8b1 100644 --- a/spec/01-discovery.md +++ b/spec/01-discovery.md @@ -1,8 +1,8 @@ -# Chapter 1 — Link Peer Discovery Protocol (STUB) +# Chapter 1 — Link Peer Discovery Protocol | | | |---|---| -| Spec version | 0.1.0-draft | +| Spec version | 0.1.0 | | Upstream reference | Ableton/link @ `902aef95bf94af49746fdda5369b42cdcfa1e6d2` | | License | CC-BY-4.0 | @@ -10,25 +10,254 @@ This document describes protocol facts determined from observation and analysis interoperability purposes. It contains no copied expression from the reference implementation. +All encodings in this chapter use the common serialization rules of Chapter 0 §4 +(big-endian integers, the tagged payload container, 8-byte identifiers). Claims are +tagged with the evidence classes of Chapter 0 §1.1 ([W] wire-observed / [B] +behavioral / [N] normative); every vector under `vectors/` contains discovery +traffic, with per-capture facts in `vectors/manifests/`. + --- -## Scope - -This chapter will specify how Link peers find each other and gossip peer state on a -local network: the multicast announcement protocol on `224.76.78.75:20808` (IPv4) and -`ff12::8080` port 20808 (IPv6), framed with the `_asdp_v` + `0x01` magic, with message -types Alive=1, Response=2, ByeBye=3; the peer-state payload (session membership, -timeline, start/stop state, measurement endpoint, and the audio endpoint extension -referenced by Chapter 3 §2); and the ttl-based peer timeout model. - -## TODO - -- [ ] Discovery message framing: magic, header fields (type, ttl, groupId, NodeId), 512-byte size limit. -- [ ] Message types and triggers: Alive (periodic + on state change), Response (unicast reply to a newly seen peer), ByeBye (shutdown). -- [ ] Announcement cadence: ttl value, ttl-ratio derived period, minimum spacing. -- [ ] Peer-state payload entries: `sess`, `tmln`, `stst` (start/stop), `mep4`/`mep6`, `aep4`/`aep6`; exact value layouts and the family-switch optional rule. -- [ ] Peer table maintenance: ttl expiry, bye handling, gateway (per-interface) tracking, session membership changes. -- [ ] IPv6 specifics: link-local multicast scope, scope-id handling on responses. -- [ ] Self-message and group filtering rules. -- [ ] Interaction with sync (Chapter 2) session formation and with LinkAudio endpoint learning (Chapter 3). -- [ ] Constants table and open questions for pcap verification. +## 1. Protocol summary + +Link peer discovery is a gossip protocol over UDP. Each peer periodically multicasts +its complete current state (an *Alive* message); any peer that hears an Alive from a +previously unknown-or-known peer replies with the same state dump as a unicast +*Response* so that the newcomer learns about it without waiting for the next multicast +round. A *ByeBye* message announces departure. Peer liveness is tracked with a +time-to-live (ttl) carried in every state message. + +Discovery is the only multicast traffic in the protocol family; everything that +discovery bootstraps (sync measurement, Chapter 2; LinkAudio, Chapter 3) is unicast to +endpoints advertised inside the discovery payload. + +## 2. Transport + +| Parameter | Value | +|---|---| +| IPv4 multicast group | `224.76.78.75`, UDP port `20808` | +| IPv6 multicast group | `ff12::8080`, UDP port `20808` (transient, link-local scope per RFC 4291) | +| Maximum message size | 512 bytes (see §3.1) | + +Per network interface ("gateway", Chapter 0 §2) a peer operates **two** UDP sockets: + +1. a **multicast receive socket** bound to port 20808 and joined to the group, and +2. a **unicast socket** bound to an OS-assigned (ephemeral) port, used for *all + sending* (both multicast transmissions and unicast responses) and for receiving + unicast responses. + +Wire-visible consequence: Alive/ByeBye datagrams have source port = the sender's +ephemeral unicast port and destination port 20808; Response datagrams travel from +ephemeral port to ephemeral port. A peer therefore learns another peer's unicast +discovery endpoint from the *source address* of any message it receives from it. + +Socket configuration facts with wire-visible consequences [B]: + +- The multicast receive socket is bound to the wildcard address at port 20808 with + address reuse enabled (several Link processes on one host share the port), joins + the group on the specific gateway interface, and on Linux restricts delivery to + groups joined on that socket. +- **Multicast loopback is enabled only on a gateway whose address is itself a + loopback address.** On other gateways a peer's own multicast transmissions are not + delivered to sockets on the same host. Consequently, two peers on one host + discover each other through the loopback gateway (this is how every vector in + `vectors/` was captured [W]); a shared non-loopback interface does not provide + same-host discovery by itself. +- The outbound multicast interface is pinned per gateway, so a multi-homed peer's + Alives go out each interface separately (vector `multi-gateway-discovery.pcap` + shows one NodeId announcing from two source addresses [W]). + +Self-messages are suppressed by node id (§5) [B]. The reference scans the interface +list every **5 seconds** and opens/closes gateways as interfaces come and go [B]. +IPv4 is used on every running interface; link-local IPv6 is additionally used on +interfaces that also have IPv4 [B] (no IPv6 vector has been captured yet — see +`vectors/README.md`). + +## 3. Message framing + +Every discovery datagram has this layout: + +| Offset | Size | Type | Description | +|---|---|---|---| +| 0 | 8 | bytes | frame magic: `5F 61 73 64 70 5F 76 01` (ASCII `_asdp_v` followed by version byte `0x01`) | +| 8 | 1 | `u8` | message type (§4) | +| 9 | 1 | `u8` | `ttl` — seconds the carried state stays valid (0 for ByeBye) | +| 10 | 2 | `u16` | `groupId` — session group; always 0 in this version | +| 12 | 8 | id | sender's NodeId | +| 20 | varies | — | payload (message-type specific) | + +Receiver admission rules (reference behavior [B], stated as requirements [N]): + +- Datagrams shorter than 20 bytes, or not beginning with the 8-byte magic, MUST be + ignored without error. +- Datagrams whose header NodeId equals the receiver's own NodeId MUST be ignored + (loopback suppression). +- Datagrams with `groupId` ≠ 0 MUST be ignored. (Every captured message carries + `groupId` 0 [W].) +- Unknown message types MUST be ignored (the receiver keeps listening). + +### 3.1 Size limit + +The maximum discovery message size is 512 bytes. The reference encoder rejects a +message whose total size is **512 or more** (strict less-than check), so the largest +datagram it can emit is 511 bytes; its receive buffers are 512 bytes, so longer +datagrams are truncated and will fail magic/parse checks only if the cut falls inside +a field [B]. **[N]** Interoperating senders MUST keep discovery messages ≤ 511 bytes; +receivers MUST accept any message up to 512 bytes. In practice a full peer state is +107–121 bytes (§6.1) [W], far below the limit. + +## 4. Message types + +| Value | Name | ttl sent by reference | Payload | +|---|---|---|---| +| 0 | Invalid | — | never transmitted; reserved as the parse-failure marker | +| 1 | Alive | 5 | peer-state payload container (§6) | +| 2 | Response | 5 | peer-state payload container (§6) — identical encoding to Alive | +| 3 | ByeBye | 0 | empty (zero bytes) | + +### 4.1 Alive + +Sent to the multicast group(s) of the gateway: + +- **periodically**, with a nominal period of **250 ms** [B, and consistent with the + inter-Alive spacing in every discovery vector], derived as `ttl × 1000 / ttlRatio` + milliseconds with `ttl = 5` (seconds) and `ttlRatio = 20`; +- **immediately on any state change** (timeline, session membership, start/stop + state, endpoints), subject to a minimum spacing of **50 ms** between sends — a + state-change broadcast that would violate the spacing is delayed by the + remainder [B]. + +A gateway with both IPv4 and IPv6 sends one Alive per address family per round [B]. + +### 4.2 Response + +On receiving an Alive from another peer (valid header, not self, `groupId` 0), a peer +MUST send a Response containing its own current peer state **unicast to the source +endpoint** of the Alive datagram [W: every discovery vector contains Responses to +ephemeral source ports]. For IPv6 sources, the destination's scope (zone) identifier +is set to that of the receiving interface before sending [B]. + +The Response is sent regardless of whether the Alive's payload parses, and it is sent +*before* the receiver processes the Alive's payload [B]. Responses received are +processed exactly like Alives (state dump) but do not themselves trigger a +Response — this is what terminates the exchange [B]. + +### 4.3 ByeBye + +Sent to the multicast group(s) when a peer shuts down a gateway (application exit or +Link disable). Header `ttl` is 0 and the payload is empty [W: 20-byte ByeBye +datagrams in `discovery-join-leave.pcap`, see its manifest]: the message means only +"forget node `ident` on this gateway". A ByeBye is best-effort; peers that miss it +fall back to ttl expiry (§7). + +## 5. Receive state machine + +For each gateway, per incoming datagram: + +```mermaid +stateDiagram-v2 + [*] --> Validate + Validate --> Drop : bad magic / short /\nown NodeId / groupId ≠ 0 + Validate --> Alive : type 1 + Validate --> Response : type 2 + Validate --> ByeBye : type 3 + Validate --> Drop : other type + Alive --> ProcessState : after unicasting own\nstate to source endpoint + Response --> ProcessState + ProcessState --> Drop : payload parse failure\n(message discarded) + ProcessState --> PeerTable : peer state + header ttl + ByeBye --> PeerTable : remove sender +``` + +## 6. Peer-state payload + +The Alive/Response payload is a payload container (Chapter 0 §4.5). The reference +emits entries in the order below; receivers MUST NOT rely on order. Every entry is +optional on receive — a missing entry leaves the corresponding state at its default +(all-zero) value — but the reference always sends the first four. + +| Key (fourcc) | `u32` value | Value size | Value encoding | Meaning | +|---|---|---|---|---| +| `tmln` | `0x746d6c6e` | 24 | tempo `i64` µs/beat, beat origin `i64` µbeats, time origin `i64` µs (ghost time) | session timeline (Chapter 2 §6) | +| `sess` | `0x73657373` | 8 | 8-byte identifier | session membership (founding peer's NodeId) | +| `stst` | `0x73747374` | 17 | `isPlaying` `u8` (0/1), beats `i64` µbeats, timestamp `i64` µs (ghost time) | start/stop state (Chapter 2 §8) | +| `mep4` | `0x6d657034` | 6 | IPv4 address `u32` BE + port `u16` BE | measurement endpoint, IPv4 (Chapter 2 §3) | +| `mep6` | `0x6d657036` | 18 | 16 address bytes + port `u16` BE | measurement endpoint, IPv6 | +| `aep4` | `0x61657034` | 6 | IPv4 address `u32` BE + port `u16` BE | audio endpoint, IPv4 (Chapter 3 §2) | +| `aep6` | `0x61657036` | 18 | 16 address bytes + port `u16` BE | audio endpoint, IPv6 | + +Encoding rules: + +- **Family switch:** for each endpoint pair (`mep4`/`mep6`, `aep4`/`aep6`) exactly + one entry is emitted, matching the address family of the gateway the message is + sent on [B; the IPv4 side is [W] in every vector]. Per Chapter 0 §4.5 rule 5, the + mismatched-family entry serializes to zero bytes and is therefore omitted entirely. +- The measurement endpoint is always advertised (every Link peer runs a measurement + socket per gateway, Chapter 2 §3) [W]. The audio endpoint is advertised only when + LinkAudio is enabled (Chapter 3 §2); plain Link peers emit neither `aep4` nor + `aep6` [W: compare the peer-state shapes in the join-leave vs. + audio-channel-lifecycle manifests]. +- IPv6 endpoint values do not carry a scope (zone) identifier; the receiver + substitutes the scope of the interface the message arrived on [B]. +- The advertised endpoints are meaningful only on the gateway the message was + received on; receivers track them per (peer, gateway) [B; per-gateway endpoint + advertisement is [W] in `multi-gateway-discovery.pcap`]. + +### 6.1 Peer-state message sizes + +With 8 bytes of entry header (key + size) per entry: + +| Configuration | Payload | Total datagram | Evidence | +|---|---|---|---| +| plain Link, IPv4 gateway | 32 + 16 + 25 + 14 = 87 | **107** bytes | [W] every discovery vector | +| LinkAudio enabled, IPv4 gateway | 87 + 14 = 101 | **121** bytes | [W] `audio-channel-lifecycle.pcap` | +| plain Link, IPv6 gateway | 32 + 16 + 25 + 26 = 99 | **119** bytes | derived by arithmetic; no IPv6 capture exists yet | + +The [W] sizes are pinned by the per-vector manifests +(`vectors/manifests/*.md`, "Discovery peer-state shapes"). + +## 7. Peer table maintenance + +Each gateway keeps a table of known peers with an expiry deadline per peer: + +| Event | Effect on (peer, gateway) entry | +|---|---| +| Alive / Response received | entry created or refreshed; deadline := now + header `ttl` seconds; state replaced by the message's payload | +| ByeBye received | entry removed immediately | +| deadline passed | entry removed by the prune timer | +| gateway closed locally | all entries for that gateway removed | + +The prune timer is scheduled for the **earliest deadline + 1 second** (padding +against over-eager timeouts); each pruning pass removes every entry whose deadline +has passed and re-arms the timer for the next-earliest survivor [B]. Effective peer +lifetime after the last refresh is therefore between `ttl` and `ttl + 1` seconds plus +timer latency. With the reference's ttl of 5 s and period of 250 ms, a peer survives +~20 missed announcements. + +A visible consequence of peer-table emptiness appears in `discovery-join-leave.pcap` +[W]: after the second peer's ByeBye, the survivor continues announcing under a **new +NodeId** — losing the last session peer triggers the full state reset of Chapter 2 +§7.3 (fresh NodeId, fresh session). The vector's manifest accordingly lists three +NodeIds for two processes. + +A peer reachable over several gateways has one entry per gateway and disappears from +the application-visible peer set only when its last entry is gone. The +session-membership, timeline, start/stop and audio-endpoint information carried in +peer state feeds the session machinery specified in Chapter 2 §7 and the LinkAudio +endpoint learning of Chapter 3 §2. + +## 8. Constants summary + +| Constant | Value | +|---|---| +| Frame magic | `5F 61 73 64 70 5F 76 01` (`_asdp_v` + `0x01`) | +| Message types | Invalid=0, Alive=1, Response=2, ByeBye=3 | +| IPv4 group / port | `224.76.78.75` / `20808` | +| IPv6 group / port | `ff12::8080` / `20808` | +| Max message size | 512 bytes (encoder limit 511) | +| ttl | 5 s (ByeBye: 0) | +| ttl ratio / nominal period | 20 / 250 ms | +| Minimum broadcast spacing | 50 ms | +| Prune-timer padding | 1 s | +| Interface rescan period | 5 s | +| Payload entry keys | `tmln` `0x746d6c6e`, `sess` `0x73657373`, `stst` `0x73747374`, `mep4` `0x6d657034`, `mep6` `0x6d657036`, `aep4` `0x61657034`, `aep6` `0x61657036` | diff --git a/spec/02-sync.md b/spec/02-sync.md index f9fd146..80c9801 100644 --- a/spec/02-sync.md +++ b/spec/02-sync.md @@ -1,34 +1,409 @@ -# Chapter 2 — Link Clock Sync and Timeline Protocol (STUB) +# Chapter 2 — Link Clock Sync and Timeline Protocol | | | |---|---| -| Spec version | 0.1.0-draft | +| Spec version | 0.1.0 | | Upstream reference | Ableton/link @ `902aef95bf94af49746fdda5369b42cdcfa1e6d2` | | License | CC-BY-4.0 | This document describes protocol facts determined from observation and analysis for interoperability purposes. It contains no copied expression from the reference -implementation. +implementation. For the rationale of the algorithms standardized here, see F. Goltz, +*"Ableton Link — A technology to synchronize music software"*, Proceedings of the +Linux Audio Conference 2018 (cited below as [Goltz 2018]); that paper is public and +non-GPL. + +All encodings use the common serialization rules of Chapter 0 §4. Claims are tagged +with the evidence classes of Chapter 0 §1.1 ([W] wire-observed / [B] behavioral / +[N] normative); every vector under `vectors/` contains sync measurement traffic, +with per-capture facts in `vectors/manifests/`. --- -## Scope - -This chapter will specify how peers in a session converge on a shared timeline: the -unicast UDP ping/pong measurement protocol served at each peer's advertised -measurement endpoint (`mep4`/`mep6`), the host-time/global-host-time payload entries -(`__ht`, `__gt`, `_pgt`), the clock-offset filtering model, the timeline payload -(`tmln`: tempo, beat origin, time origin), session election and merging, and the -start/stop state (`stst`) propagation rules. - -## TODO - -- [ ] Measurement message framing and the ping/pong exchange sequence. -- [ ] Payload entries `__ht` (0x5f5f6874), `__gt` (0x5f5f6774), `_pgt` (0x5f706774): layouts and roles in offset estimation. -- [ ] Measurement scheduling: number of pings, intervals, retry/timeout behavior, measurement completion criteria. -- [ ] Clock-offset estimation and filtering as a normative algorithm description. -- [ ] Timeline encoding (`tmln`): tempo as µs/beat, beat origin in micro-beats, time origin in µs; 24-byte layout. -- [ ] Session identity, election (which peer's timeline wins), and merge behavior when sessions meet. -- [ ] Start/stop state (`stst`) encoding and propagation, including timestamps for conflict resolution. -- [ ] Quantum and phase model shared with Chapter 3 §6 (phase, nextPhaseMatch, closestPhaseMatch, phase-encoded beats). -- [ ] Constants table and open questions for pcap verification. +## 1. Model overview + +Peers in a Link session agree on a **session timeline**: a mapping between *beats* +and a shared time base. Because peers have independent, unsynchronized clocks, the +shared time base is a virtual one — called **ghost time** in this specification — +defined per session. Each peer maintains: + +1. a **ghost transform** `G` mapping its local microsecond clock to ghost time + (obtained by measuring another session member, §4–§5), and +2. the **session timeline** `T = (tempo, beatOrigin, timeOrigin)` (§6), gossiped + through discovery (Chapter 1 §6) and expressed in ghost time. + +Local beat position at local time `t` is then `T.beats(G(t))`. Synchronization +quality therefore reduces to how well `G` is estimated; Link measures it pairwise +with a unicast ping/pong protocol and a median filter ([Goltz 2018]). + +**Quantum and phase are local.** No quantum value is ever transmitted; phase +alignment between peers with different quanta works because all peers align their +quantum grids to beat 0 of the session timeline ([Goltz 2018]). See §9. + +## 2. Ghost time and the ghost transform + +A ghost transform is a pair `(slope, intercept)`: + +``` +ghost(t) = round(slope · t) + intercept (t, intercept in µs) +host(g) = round((g − intercept) / slope) +``` + +In the current protocol version the slope is always **1**; only the offset is +measured. A peer that founds a session (on enable, or when it loses all peers) +creates the transform `(1, −now)`, i.e. ghost time 0 is the founding moment, and +ghost time advances at the rate of the founder's clock. + +The ghost transform is never transmitted as such; it is the *output* of the +measurement procedure (§5) on the measuring side, and the thing the responder uses +to answer with its own ghost time (§4.3). + +## 3. Measurement transport + +Each peer runs one **measurement responder** socket per gateway: a unicast UDP +socket with an OS-assigned port, advertised in discovery as `mep4`/`mep6` +(Chapter 1 §6). The same socket is used to initiate measurements of other peers. + +IPv6 endpoint values carry no scope id; an initiator sets the scope of the target +address to that of its own gateway interface before sending. + +### 3.1 Message framing + +| Offset | Size | Type | Description | +|---|---|---|---| +| 0 | 8 | bytes | frame magic: `5F 6C 69 6E 6B 5F 76 01` (ASCII `_link_v` followed by version byte `0x01`) | +| 8 | 1 | `u8` | message type: 1 = Ping, 2 = Pong | +| 9 | varies | — | payload container (Chapter 0 §4.5) | + +Note that unlike discovery (Chapter 1 §3) and LinkAudio (Chapter 3 §3) framing, +measurement messages carry **no ttl, no groupId and no NodeId** — the conversation +is identified only by the UDP 5-tuple. Datagrams shorter than 9 bytes or without the +magic MUST be ignored. Maximum message size is 512 bytes (encoder limit 511, as in +Chapter 1 §3.1). + +### 3.2 Payload entries + +| Key (fourcc) | `u32` value | Value size | Value | Meaning | +|---|---|---|---|---| +| `__ht` | `0x5f5f6874` | 8 | `i64` µs | **host time** — the *initiator's* local clock at ping transmit | +| `__gt` | `0x5f5f6774` | 8 | `i64` µs | **ghost time** — the *responder's* ghost clock at pong transmit | +| `_pgt` | `0x5f706774` | 8 | `i64` µs | **previous ghost time** — the `__gt` value of the previous pong, echoed back by the initiator | +| `sess` | `0x73657373` | 8 | 8-byte identifier | the responder's current session | + +## 4. The ping/pong exchange + +### 4.1 Sequence + +One *measurement* is a rapid chain of ping/pong round trips against one peer's +measurement endpoint: + +``` +Initiator Responder + │ Ping {__ht = h₁} │ + ├────────────────────────────────────────────►│ + │ Pong {sess, __gt = g₁} ⧺ {__ht = h₁} + │◄────────────────────────────────────────────┤ + │ Ping {__ht = h₂, _pgt = g₁} │ + ├────────────────────────────────────────────►│ + │ Pong {sess, __gt = g₂} ⧺ {__ht = h₂, _pgt = g₁} + │◄────────────────────────────────────────────┤ + │ … repeats until enough data (§5) … │ +``` + +`⧺` denotes byte concatenation: the responder **echoes the entire ping payload +verbatim** (uninterpreted bytes) after its own `sess` and `__gt` entries. Because +payload entries are order-independent and duplicate-free in practice, the result is +one well-formed payload container. The echo is what lets the initiator recover its +own send time (`__ht`) and the previous ghost time (`_pgt`) without keeping +per-ping state. + +Datagram sizes and entry shapes [W]: first ping 25 bytes (9 + 16) with `{__ht}`, +its pong 57 (9 + 32 + 16) with `{sess, __gt}` + echo; subsequent pings 41 (9 + 32) +with `{__ht, _pgt}`, pongs 73 (9 + 32 + 32). All four shapes are pinned in every +discovery vector's manifest ("Sync message shapes") and asserted by +`tools/check_vectors.py`. + +### 4.2 Initiator behavior + +All [B] except as noted; the resulting message shapes and the ~104-ping chain per +measurement are [W] in every discovery vector. + +- Send the first ping immediately with `{__ht = now}`. +- On each valid pong for the current measurement, *immediately* send the next ping + `{__ht = now, _pgt = pong.__gt}` — the chain is paced by the network round trip, + not by a timer. +- **Retry/timeout:** a 50 ms timer is re-armed on every ping. If it fires (no pong + within 50 ms), send a fresh ping `{__ht = now}` (without `_pgt`). After **5** such + timer-driven retries, the measurement **fails** and collected data is discarded. +- **Session check:** if a pong's `sess` differs from the session id of the peer + being measured (as known at measurement start), the measurement fails immediately. + +### 4.3 Responder behavior + +A responder MUST answer any Ping whose payload is at most **32 bytes** (the size of +a `__ht` + `_pgt` container) with a Pong to the datagram's source endpoint, +containing its current session id (`sess`), its current ghost time (`__gt = +G(now)`), and the verbatim ping payload appended [W: pong = 32 bytes of own entries ++ exact echo, visible in all vectors]. Pings with larger payloads are ignored [B]. +The responder is stateless and answers every valid ping, regardless of session +membership [B]. + +## 5. Offset estimation and filtering + +The initiator accumulates *offset samples* (estimates of `ghost − host`, in µs, as +floating-point values). After each pong, with + +| Symbol | Source | +|---|---| +| `HT` | initiator clock at pong receipt | +| `GT` | the pong's `__gt` (responder ghost time) | +| `PHT` | the echoed `__ht` (initiator clock at the matching ping transmit) | +| `PGT` | the echoed `_pgt` (responder ghost time of the *previous* pong), when present | + +two samples are appended (the first from pong `n`, the second pairing pong `n` with +ping `n+1`'s reference — both are standard midpoint estimators assuming symmetric +network delay): + +``` +sample₁ = GT − (HT + PHT)/2 (if GT ≠ 0 and PHT ≠ 0) +sample₂ = (GT + PGT)/2 − PHT (additionally, if PGT ≠ 0) +``` + +The measurement completes as soon as **more than 100** samples are collected +(i.e. at the 101st; ≈ 51 round trips, well under a second on a LAN). The resulting +ghost transform is: + +``` +G = (slope = 1, intercept = round(median(samples))) +``` + +The median across the whole chain discards outliers from asymmetric or delayed +round trips ([Goltz 2018]). The sampling formulas, the >100 threshold, and the +median are [B] — the filter runs inside the initiator and leaves no distinct wire +trace beyond the chain length. Implementations MAY use a different robust estimator +[N]; the wire format does not constrain the filter, only the message exchange. + +## 6. The session timeline (`tmln`) + +The 24-byte `tmln` payload value (Chapter 1 §6) is, in order: + +| Offset | Size | Type | Description | +|---|---|---|---| +| 0 | 8 | `i64` | tempo, in µs per beat (Chapter 0 §4.7) | +| 8 | 8 | `i64` | beat origin, in micro-beats | +| 16 | 8 | `i64` | time origin, in **ghost time** µs | + +with the bijection (all integer µs / µbeats; division rounds to nearest): + +``` +beats(g) = beatOrigin + (g − timeOrigin) / microsPerBeat +ghost(b) = timeOrigin + (b − beatOrigin) · microsPerBeat +``` + +Rules, stated as protocol requirements derived from reference behavior: + +1. **Tempo range:** tempo values outside 20–999 bpm are clamped by receivers into + that range [B]. (Senders also clamp; the µs/beat encoding makes exact bpm values + slightly lossy — e.g. 999 bpm → 60060 µs/beat → 999.000999… bpm — so receivers + re-clamp after decoding.) +2. **Beat-origin priority:** within a session, a received timeline **replaces** the + currently held one iff its `beatOrigin` is **strictly greater**. Otherwise it is + ignored [B]. The beat origin thus acts as a logical clock / priority stamp for + timeline modifications. +3. **Modification rule:** a peer changing the session timeline (tempo change or beat + re-anchor) MUST emit a timeline whose `beatOrigin` exceeds the current one. The + reference uses `max(beats-at-now-on-old-timeline, old beatOrigin + 1 µbeat)`, + keeping the origin near the present so priority roughly tracks recency. [W: + `sync-tempo-change.pcap` — its manifest shows each tempo change gossiped with a + strictly increased beatOrigin, asserted by `check_vectors.py`.] +4. **Beat 0 is the phase reference:** the time origin is the ghost time of beat + `beatOrigin`; the ghost time of beat 0, `ghost(0)`, anchors every peer's quantum + grid (§9). Timeline changes preserve this anchoring. +5. A timeline (`tmln`) is interpreted in the context of the `sess` entry of the same + peer-state message: it is *that session's* timeline proposal. + +A new session's timeline starts at `(initial tempo, beat 0, ghost time 0)`; with the +founder's `G = (1, −foundingTime)` this makes beat 0 fall on the founding moment. + +## 7. Session identity, election, and merging + +A session is identified by the NodeId of its founder (Chapter 0 §2). Every enabled +peer is always a member of exactly one session — initially its own (sessionId = +own NodeId; enabling Link always founds a fresh session and never imports prior +state, so a returning peer cannot hijack an existing session's tempo). + +State per peer: the current session `(sessionId, timeline, G)` plus a set of *other* +known sessions seen in gossip. + +### 7.1 Discovering a foreign session + +When peer-state gossip (Chapter 1 §6) reports a peer whose `sess` differs from the +current session: + +1. The observer launches a **measurement** (§4) against that session, choosing as + target the session's *founding peer* if it is visible (the peer whose NodeId + equals the session id), otherwise any known member of it. +2. On measurement failure, the foreign session is forgotten (it will be re-measured + if seen again). On success the observer now has `G_new` for the foreign session + and decides whether to join (§7.2). + +### 7.2 Join rule + +Let `g_cur = G_cur(now)` and `g_new = G_new(now)` — the current time expressed in +both sessions' ghost times. With `ε = 500,000 µs`: + +``` +join the foreign session iff (g_new − g_cur) > ε + or (|g_new − g_cur| < ε and newSessionId < curSessionId) +``` + +That is: **the session with the greater ghost time wins** — ghost time measures how +long a session has existed, so newcomers always join the older, established session +([Goltz 2018]) — with the byte-wise lesser session id as tie-breaker when the +ghost times are within ε of each other. If the rule does not fire, the peer stays +and the foreign session remains cached with its measurement. + +Both sides evaluate the same rule on **independently measured** ghost-time +differences, which are noisy. Away from the ±ε boundary the two evaluations are +anti-symmetric and exactly one side joins; near the boundary, measurement noise can +transiently make both or neither side join. Convergence is still guaranteed: the +post-join membership is re-gossiped immediately, surviving disagreement re-triggers +measurement, and the id tie-break is deterministic [B]. + +On joining: adopt the foreign `(sessionId, timeline, G)`, reset start/stop state +(§8), and gossip the new membership immediately (Chapter 1 §4.1). Timelines of a +joined session are then maintained per §6 rule 2. + +### 7.3 Re-measurement and loss of peers + +- The current session's ghost transform is **re-measured every 30 seconds** + (against the founder or another member, as in §7.1); a failed re-measurement + schedules another attempt 30 s later [B]. This bounds clock drift between session + members (slope is fixed at 1, so drift appears as a slowly changing offset). +- When the last other member of the session disappears (Chapter 1 §7), the peer + **founds a fresh session**: new random NodeId, sessionId = NodeId, new transform + `(1, −now)`, and a new timeline constructed so the local beat/tempo continue + seamlessly. [W: in `discovery-join-leave.pcap` the surviving peer reappears under + a new NodeId after the other's ByeBye — three NodeIds for two processes in the + manifest.] + +### 7.4 Election state machine (per peer) + +```mermaid +stateDiagram-v2 + [*] --> OwnSession : enable\n(sessionId = NodeId,\nG = (1, −now), beat 0 = now) + OwnSession --> Measuring : gossip shows foreign session + Measuring --> OwnSession : measurement failed\nor join rule false + Measuring --> Joined : join rule true\n(adopt sessionId, tmln, G) + Joined --> Measuring : gossip shows another\nforeign session + Joined --> Joined : every 30 s re-measure\ncurrent session + Joined --> OwnSession : all session peers lost\n(found fresh session) +``` + +## 8. Start/stop state (`stst`) + +The 17-byte `stst` payload value (Chapter 1 §6) is, in order: + +| Offset | Size | Type | Description | +|---|---|---|---| +| 0 | 1 | `u8` | `isPlaying`: 0 = stopped, nonzero = playing | +| 1 | 8 | `i64` | beats: the session-timeline beat position at which the transport starts/stopped, in µbeats | +| 9 | 8 | `i64` | timestamp: ghost time of the user action that produced this state, in µs | + +Propagation rules (reference behavior [B] unless noted, stated as requirements; the +encoding and both transport states on the wire are [W] in `sync-start-stop.pcap`, +asserted by `check_vectors.py`): + +1. A received `stst` is considered only if the same message's `sess` matches the + receiver's current session. +2. It replaces the held start/stop state iff its `timestamp` is **strictly + greater** (latest user action wins; the ghost-time timestamp gives a session-wide + total order). +3. A peer that adopts a new `stst` MUST re-gossip it (Chapter 1 §4.1) even if the + application has start/stop sync disabled — every peer relays, so the state + reaches members without a direct multicast path. A default (all-zero) `stst` is + relayed but not surfaced to the application. +4. Joining a session resets the local start/stop state; the joiner picks up the + session's state from subsequent gossip. + +The `beats` field lets a receiving application schedule the transport change on the +beat grid (e.g. start playback at the next quantum boundary after that beat); it is +informational for relays. + +## 9. Quantum, phase, and the session beat grid + +These equations are shared with Chapter 3 §6 and given here as the normative +definition [B: beat-grid arithmetic of the reference; its session-grid consequences +are [W] in the audio vectors]. All values in beats (µbeats on the wire); `q > 0` is +the local quantum. The two alignment operations are written `alignUp` and +`alignNear` in this specification. + +``` +phase(b, q) = b mod q, shifted into [0, q) (negative b handled by adding a + sufficient whole multiple of q before the mod; phase(b, 0) = 0) + +alignUp(x, t, q) = x + ((phase(t,q) − phase(x,q) + q) mod q) + least value ≥ x having the phase of t + +alignNear(x, t, q) = alignUp(x − q/2, t, q) + value with t's phase nearest to x (deviation ≤ q/2; + ties at exactly q/2 resolve downward) +``` + +Beat 0 of the session timeline is the origin of every peer's quantum grid: a peer +with quantum `q` places its bar boundaries at session beats `0, q, 2q, …` +([Goltz 2018]). Hence peers with different quanta still phase-align at common +multiples, and the beat value a peer reports to its application for time `t` is +phase-encoded against its own quantum: + +``` +b_app(t) = alignNear(B(t), B(t) − beatOrigin, q) + where B(t) = beats(G(t)) (§6 bijection) +``` + +The inverse mapping (application beat `b` → time) must invert the phase encoding +with the *opposite* tie-break — rounding up at exactly `q/2` — or the two directions +do not compose. The reference computes it as follows [B], and implementations MUST +reproduce the tie-break behavior [N]: + +``` +r = b − beatOrigin +cycle = r − phase(r, q) (start of r's quantum cycle) +δ = alignNear(q − phase(r, q), q − phase(b, q), q) +t_app(b) = ghost⁻¹(time(beatOrigin + cycle + q − δ)) (§6 bijection, then G⁻¹) +``` + +Chapter 3 §6 builds the origin-independent "session beat time" used by LinkAudio +from the same construction. + +## 10. Relationship of host, ghost, and client time + +Wire messages use two time bases: local host time (only inside `__ht`, never +interpreted remotely) and ghost time (`__gt`, `_pgt`, `tmln.timeOrigin`, +`stst.timestamp`). Applications additionally see beat values that are pure timeline +arithmetic. An implementation needs exactly one conversion, its own `G`, applied at +the protocol boundary; no other peer's host clock is ever observable. + +## 11. Constants summary + +| Constant | Value | +|---|---| +| Frame magic | `5F 6C 69 6E 6B 5F 76 01` (`_link_v` + `0x01`) | +| Message types | Ping=1, Pong=2 | +| Max message size | 512 bytes (encoder limit 511) | +| Responder max accepted ping payload | 32 bytes | +| Retry timer / max retries | 50 ms / 5 | +| Samples required | > 100 (two per round trip after the first) | +| Offset filter | median | +| Ghost transform slope | 1 (fixed) | +| Session re-measurement period | 30 s | +| Join threshold ε | 500,000 µs | +| Tempo clamp | 20–999 bpm | +| Payload entry keys | `__ht` `0x5f5f6874`, `__gt` `0x5f5f6774`, `_pgt` `0x5f706774`, `sess` `0x73657373` | +| `tmln` / `stst` value sizes | 24 / 17 bytes | + +## 12. References + +- F. Goltz, "Ableton Link — A technology to synchronize music software," + *Proceedings of the Linux Audio Conference 2018*, c-base, Berlin. + diff --git a/spec/03-audio.md b/spec/03-audio.md index 18ae4b5..9dc2175 100644 --- a/spec/03-audio.md +++ b/spec/03-audio.md @@ -2,7 +2,7 @@ | | | |---|---| -| Spec version | 0.1.0-draft | +| Spec version | 0.1.0 | | Upstream reference | Ableton/link @ `902aef95bf94af49746fdda5369b42cdcfa1e6d2` | | License | CC-BY-4.0 | @@ -12,7 +12,10 @@ implementation. All encodings in this chapter use the common serialization rules of Chapter 0 §4 (big-endian integers, length-prefixed strings and vectors, the tagged payload -container, 8-byte identifiers). +container, 8-byte identifiers). Claims are tagged with the evidence classes of +Chapter 0 §1.1 ([W] wire-observed / [B] behavioral / [N] normative); the primary +wire evidence for this chapter is `vectors/audio-channel-lifecycle.pcap` and its +manifest. --- @@ -94,9 +97,14 @@ Receiver admission rules (observed, stated as requirements): | Maximum name size | 256 | peer and channel name byte limit (see §8) | Note the 4-byte discrepancy: the on-wire fixed prefix is 20 bytes, but the reference -budgets 24, so the effective payload never exceeds 1176 bytes even though 1180 would -fit. Interoperating senders SHOULD apply the 1176-byte payload budget. -OPEN QUESTION: whether any receiver enforces an upper payload bound of 1176 vs 1180. +budgets 24, so the effective payload it *emits* never exceeds 1176 bytes even though +1180 would fit. Interoperating senders SHOULD apply the 1176-byte payload budget. + +**Resolved (v0.1.0) [B]:** the receive path applies **no** payload ceiling check; an +incoming datagram is bounded only by the receive socket buffer, which is the +1200-byte maximum message size (i.e. up to 1180 bytes of payload). The 24-byte budget +is purely sender-side conservatism. **[N]** Receivers MUST accept payloads up to 1180 +bytes; senders SHOULD stay within 1176. ### 3.2 Message types @@ -172,11 +180,16 @@ Both carry a payload container with a single entry: - The requesting peer is identified by the message header's NodeId. - ChannelRequest header `ttl` declares for how many seconds the request remains valid at the sink. The reference sends `ttl = 5` and re-sends the request every **5 - seconds** for as long as the source exists (keepalive by repetition). -- StopChannelRequest is sent once when a source is destroyed, with header `ttl = 0`. - Its effect is immediate removal of the requester (see §7.2). + seconds** for as long as the source exists (keepalive by repetition). [W: + `audio-channel-lifecycle.pcap` holds a subscription across multiple keepalive + periods and contains the repeated ChannelRequests, asserted by + `check_vectors.py`.] +- A request is dispatched to the sink whose channel id matches `chid`; requests for + unknown channel ids are dropped [B]. +- StopChannelRequest is sent once when a source is destroyed, with header `ttl = 0` + [W]. Its effect is immediate removal of the requester (see §7.2) [B]. - Requests are sent unicast to the audio endpoint of the peer that announced the - channel, over the best-quality path (§4.2). + channel, over the best-quality path (§4.2) [B]. ### 4.4 ChannelByes (type 2) @@ -204,8 +217,9 @@ Unlike all control messages, the audio payload is **not** wrapped in a payload container: the structure below begins directly at message offset 20, with no key/size prefix. The fourcc `_abu` = `0x5f616275` is associated with this structure as a constant in the reference, but is not written on the wire by the v1 encoding -path. OPEN QUESTION: confirm by pcap that no `_abu` entry header precedes the -structure in traffic from shipping implementations. +path. **Resolved (v0.1.0):** confirmed by `vectors/audio-channel-lifecycle.pcap` — +every AudioBuffer datagram's payload begins directly with the 8-byte channel id; no +`_abu` (`5f 61 62 75`) entry header precedes the structure. ### 5.2 Payload layout @@ -243,7 +257,8 @@ Frames are assigned to chunks in order: chunk 0 covers the first `numFrames₀` of the sample data, chunk 1 the next `numFrames₁`, etc. The total frame count of the buffer is the sum of all chunks' frame counts. -Observed sender chunking rules: +Sender chunking and flush rules [B; chunks carrying two distinct tempo values across +a mid-stream tempo change are [W] in `audio-channel-lifecycle.pcap`]: - A new chunk is started when the tempo changes *and* the new material's beat position is not exactly contiguous with the previous chunk's end; otherwise material is @@ -251,7 +266,12 @@ Observed sender chunking rules: - The end beat of a chunk is `beginBeats + (numFrames / sampleRate) / (60 / bpm)` beats (see §6 for the µs-per-beat ↔ bpm relation); contiguity is judged against that value. -- Sequence numbers let a receiver detect loss/reordering per channel. +- A pending buffer is **flushed** (encoded and transmitted) when its cached samples + reach the per-datagram cap (§5.6) — continuing material then starts a fresh chunk + at the previous chunk's end beat — and a sample-rate, channel-count, or session + change flushes the pending buffer before the new material starts its own. +- Sequence numbers let a receiver detect loss/reordering per channel; the first + chunk a sender creates on a channel has sequence number 1. ### 5.4 Codec values @@ -264,9 +284,13 @@ Receiver validation (observed): codec 0 → reject the buffer. For codec 1 the r additionally checks `total_frames × numChannels × 2 == numBytes` and rejects on mismatch. Codec values other than 0 and 1 are *accepted by the parser*; the reference then decodes the sample data as if it were PCM i16 (it has no other decoder and does -not re-check the codec). OPEN QUESTION: implementations should probably discard -buffers with unknown codec values instead; confirm intended behavior before relying -on this for format negotiation. +not re-check the codec). **Resolved (v0.1.0):** this is confirmed reference behavior; +all AudioBuffers in `vectors/audio-channel-lifecycle.pcap` use `codec = 1`, and no +codec other than 1 is ever transmitted. Because the codec field cannot currently be +used for format negotiation (an unknown value is silently mis-decoded), implementations +SHOULD reject buffers whose codec is neither 0 nor 1 rather than imitate the +reference's fall-through. Codec values remain a v1 extension point reserved for a +future spec version. ### 5.5 Sample encoding (codec 1) @@ -285,13 +309,25 @@ on this for format negotiation. | Maximum sample bytes (capacity) | 1126 | 1176 − 50; capacity of the sample area a receiver must accept | | Sender's per-datagram sample-byte cap | 502 | 576 − 24 − 50; the reference conservatively sizes audio datagrams to RFC 791's 576-byte minimum-reassembly guarantee, i.e. ≤ 251 samples per datagram | -With the 502-byte cap, a stereo 48 kHz stream is sent as ≈ 125-frame datagrams -(roughly one datagram every 2.6 ms per channel). +The 502-byte cap is [W]: every AudioBuffer in `audio-channel-lifecycle.pcap` carries +`numBytes` ≤ 502 (the manifest reports the observed range). With the cap, a stereo +48 kHz stream is sent as ≈ 125-frame datagrams (roughly one datagram every 2.6 ms +per channel); the captured mono stream carries 251 frames per datagram [W]. + +Note that the 576-byte aspiration holds exactly only for single-chunk datagrams +(20 + 28 + 26 + 502 = 576); each additional chunk record adds 26 bytes beyond it. +The flush condition counts sample bytes only [B], so multi-chunk datagrams slightly +exceed 576 while remaining far below the 1200-byte limit. Note: the fixed non-chunk fields total 28 bytes and each chunk adds 26, so the actual minimum non-audio overhead with one chunk is 54 bytes, not 50; the reference's chunk bookkeeping dynamically subtracts the real chunk-list size when computing how many -frames fit. OPEN QUESTION: the exact derivation of the constant 50. +frames fit. **Resolved (v0.1.0):** the value 50 is a **hand-chosen fixed allowance**, +not a computed minimum — it is intentionally loose headroom for the non-sample fields, +and the encoder subtracts the *actual* chunk-list size at runtime, so correctness does +not depend on 50 being exact. Implementations need not reproduce the constant 50; they +need only ensure each datagram's total size stays within the message limit. The +constant matters only as the basis for the capacity figures below. ### 5.7 Transmission conditions @@ -321,23 +357,24 @@ grid** that is origin-independent, so any session member can interpret them. ### 6.2 Phase arithmetic -For beat value `b` and quantum `q` (both in beats; `q > 0`): +For beat value `b` and quantum `q` (both in beats; `q > 0`), using the alignment +operations defined normatively in Chapter 2 §9: ``` -phase(b, q) ∈ [0, q): b mod q, computed so negative b is handled - by shifting b up by a whole multiple of q first. +phase(b, q) ∈ [0, q): b mod q, computed so negative b is handled + by shifting b up by a whole multiple of q first. -nextPhaseMatch(x, t, q) = x + ((phase(t,q) − phase(x,q) + q) mod q) - (least value ≥ x with the phase of t) +alignUp(x, t, q) = x + ((phase(t,q) − phase(x,q) + q) mod q) + (least value ≥ x with the phase of t) -closestPhaseMatch(x,t,q) = nextPhaseMatch(x − q/2, t, q) - (value with the phase of t nearest to x; deviates ≤ q/2) +alignNear(x, t, q) = alignUp(x − q/2, t, q) + (value with the phase of t nearest to x; deviates ≤ q/2) ``` A peer's **session offset** for quantum `q` is: ``` -Δ = closestPhaseMatch(B0, B0 − beatOrigin, q) where B0 = beats(timeOrigin) +Δ = alignNear(B0, B0 − beatOrigin, q) where B0 = beats(timeOrigin) ``` i.e. the phase-encoded beat value the local timeline assigns to its own time origin. @@ -454,9 +491,12 @@ beginBeats, tempo, count, sessionId, sampleRate, numChannels) unit. - Names are length-prefixed strings (Chapter 0 §4.2), opaque bytes, no terminator. - Names are display-only and may change over the lifetime of a peer/channel; the 8-byte identifiers are the stable keys. -- OPEN QUESTION: the receive path has no observed length check; behavior of shipping - receivers when presented with names longer than 256 bytes is unverified (the - payload budget caps a single name at well under 1176 bytes regardless). +- **Resolved (v0.1.0):** the 256-byte cap is **sender-side only** (the public API + truncates before transmit). The receive path applies no name-length check: a name is + decoded as a length-prefixed string (Chapter 0 §4.2) bounded only by the enclosing + payload. A receiver therefore accepts names longer than 256 bytes, up to the payload + budget. Implementations MAY impose their own display-length cap but MUST parse the + full length-prefixed field to stay byte-aligned with the rest of the payload. ## 9. Forward-compatibility behavior (observed in the reference) @@ -470,7 +510,7 @@ beginBeats, tempo, count, sessionId, sampleRate, numChannels) unit. | Recognized entry not consuming exactly its declared size | parse error, message dropped | | AudioBuffer with zero chunks | rejected | | AudioBuffer with codec 0 | rejected | -| AudioBuffer with unknown nonzero codec | parsed and decoded as PCM i16 (no dedicated check); see §5.4 OPEN QUESTION | +| AudioBuffer with unknown nonzero codec | parsed and decoded as PCM i16 (no dedicated check); implementations SHOULD reject — see §5.4 | | AudioBuffer where remaining bytes ≠ `numBytes` | rejected | | Malformed announcement / request payloads | message logged and ignored | @@ -501,20 +541,36 @@ beginBeats, tempo, count, sessionId, sampleRate, numChannels) unit. ## 11. Open questions (tracking list) -1. **OPEN QUESTION:** `_abu` (`0x5f616275`) is defined as the audio-buffer key but the - v1 encoder writes the structure bare, with no payload-entry wrapper — verify by - pcap. -2. **OPEN QUESTION:** header budget 24 vs actual 20-byte header — do receivers - enforce a 1176- or 1180-byte payload ceiling? -3. **OPEN QUESTION:** exact derivation of the 50-byte non-audio allowance (computed - minimum with one chunk is 54). -4. **OPEN QUESTION:** receiver behavior for names longer than 256 bytes. -5. **OPEN QUESTION:** intended handling of unknown nonzero codec values (reference - parses them and decodes as PCM i16). -6. **OPEN QUESTION:** semantics of nonzero `groupId` (reserved field; reference sends - 0 and drops everything else). -7. **OPEN QUESTION:** whether duplicate payload-container entries are ever legitimate - (reference applies last-one-wins). -8. **OPEN QUESTION:** cross-host usability of advertised IPv6 (`aep6`) addresses - given that scope ids are not transmitted (receiver substitutes its own interface - scope) — verify with v6-only pcap. +Resolved at v0.1.0, each with the evidence class (Chapter 0 §1.1) its verdict rests +on; the CHANGELOG carries the same table: + +1. **Resolved [W]** — `_abu` is **not** written on the wire; AudioBuffer payloads + begin bare with the channel id. Asserted over every AudioBuffer in + `audio-channel-lifecycle.pcap` by `check_vectors.py`. See §5.1. +2. **Resolved [B]** — receivers enforce **no** payload ceiling beyond the 1200-byte + socket buffer (≤1180 payload); the 24-byte budget is sender-side only. Not + exercised by any vector (no >1176 payload has been observed). See §3.1. +3. **Resolved [B]** — the 50-byte allowance is a hand-chosen fixed constant, not a + computed minimum; the encoder subtracts the real chunk-list size at runtime. The + resulting 502-byte sample cap is [W]. See §5.6. +4. **Resolved [B]** — the 256-byte name cap is sender-side only; receivers accept + longer names (length-prefixed, bounded by the payload). No over-long name appears + in any vector. See §8. +5. **Resolved [B]** — the reference parses unknown nonzero codecs and decodes them + as PCM i16. That only codec 1 is ever transmitted is [W]. Implementations SHOULD + reject unknown codecs [N]. See §5.4. +6. **Resolved** — `groupId` is a reserved field; the reference sends 0 [W: all + captured traffic] and drops any nonzero value [B]. Implementations MUST send 0 + and MUST ignore messages with a nonzero `groupId` [N]. See §3 and §9. +7. **Resolved** — duplicate payload-container entries are never emitted [W] (modulo + the sync-pong echo, Chapter 0 §4.5 rule 7); receivers apply last-one-wins [B]. + Senders MUST NOT emit duplicates [N]. + +Deferred: + +8. **OPEN QUESTION:** cross-host usability of advertised IPv6 (`aep6`) addresses given + that scope ids are not transmitted (receiver substitutes its own interface scope). + Requires the `discovery-ipv6.pcap` vector, which the v0.1.0 capture environment + could not produce (its kernel has no IPv6 support). The capture script detects + IPv6 availability and emits this vector automatically where present; the question + is carried forward to the next release. diff --git a/tools/analyze_pcap.py b/tools/analyze_pcap.py new file mode 100644 index 0000000..f50fb74 --- /dev/null +++ b/tools/analyze_pcap.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""analyze_pcap.py — decode Ableton Link wire protocols from a capture and +emit an auto-derived manifest of OBSERVED facts. + +This tool exists to enforce one rule: every observable claim in the spec and +the vector docs is generated from the bytes of a capture, never narrated from +intent. It decodes the three protocol families by frame magic, dumps every +field, and summarizes what a capture actually contains (topology, message-type +counts, sizes, payload-entry sets, audio-buffer parameters). + +It reads only packet bytes — no reference source is involved. Output is safe to +quote in released artifacts (it contains no reference file/class/symbol names). + +Usage: + analyze_pcap.py CAPTURE.pcap [--manifest] [--dump TYPE] [--json] + (default) human-readable manifest of observed facts + --dump all per-message field decode (all protocols) + --dump audio per-message field decode (one protocol family) + --json machine-readable manifest + +License: MIT +""" +import sys +import os +import struct +import json +import argparse +from collections import Counter, defaultdict + +# ---- pcap container --------------------------------------------------------- + +LINKTYPES = {0: "NULL/BSD-loopback", 1: "EN10MB", 113: "LINUX_SLL", 276: "LINUX_SLL2"} + + +def read_pcap(path): + data = open(path, "rb").read() + magic = data[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\xa1\xb2\xc3\xd4"): + le = magic == b"\xd4\xc3\xb2\xa1" + else: + raise ValueError(f"not a pcap file: {path}") + endian = "<" if le else ">" + linktype = struct.unpack(endian + "I", data[20:24])[0] + off, pkts = 24, [] + while off + 16 <= len(data): + ts, tu, caplen, length = struct.unpack(endian + "IIII", data[off : off + 16]) + if caplen > 262144 or off + 16 + caplen > len(data): + break # truncated/corrupt trailer — stop cleanly + pkts.append((ts + tu / 1e6, data[off + 16 : off + 16 + caplen])) + off += 16 + caplen + return linktype, pkts + + +def l3(frame, linktype): + """Return (src_ip, dst_ip, l4proto, l4bytes) or None.""" + if linktype == 1: # Ethernet + if len(frame) < 14: + return None + et = struct.unpack(">H", frame[12:14])[0] + ip = frame[14:] + elif linktype == 0: # BSD loopback: 4-byte family + fam = struct.unpack("H", frame[14:16] if linktype == 113 else frame[0:2])[0] + ip = frame[hdr:] + else: + return None + if et == 0x0800: # IPv4 + if len(ip) < 20 or (ip[0] >> 4) != 4: + return None + ihl = (ip[0] & 0xF) * 4 + proto = ip[9] + src = ".".join(str(b) for b in ip[12:16]) + dst = ".".join(str(b) for b in ip[16:20]) + return src, dst, proto, ip[ihl:] + if et == 0x86DD: # IPv6 + if len(ip) < 40: + return None + proto = ip[6] + src = ip[8:24].hex(":", 2) if hasattr(bytes, "hex") else ip[8:24].hex() + dst = ip[24:40].hex() + return src, dst, proto, ip[40:] + return None + + +def udp(l4): + if len(l4) < 8: + return None + sp, dp, ln, _ = struct.unpack(">HHHH", l4[:8]) + return sp, dp, l4[8:] + + +# ---- payload-entry container (Chapter 0 §4.5) ------------------------------- + +def parse_entries(buf): + """Decode a tagged key/size/value payload container. Returns list of + (fourcc:str, size:int, value:bytes); stops on a length that overruns.""" + out, i = [], 0 + while i + 8 <= len(buf): + key = buf[i : i + 4] + size = struct.unpack(">I", buf[i + 4 : i + 8])[0] + if i + 8 + size > len(buf): + out.append((key.decode("latin1"), size, None)) # overrun marker + break + out.append((key.decode("latin1"), size, buf[i + 8 : i + 8 + size])) + i += 8 + size + return out + + +def dec_endpoint4(v): + if len(v) != 6: + return {"raw": v.hex()} + return {"addr": ".".join(str(b) for b in v[:4]), "port": struct.unpack(">H", v[4:6])[0]} + + +def dec_endpoint6(v): + if len(v) != 18: + return {"raw": v.hex()} + return {"addr": v[:16].hex(), "port": struct.unpack(">H", v[16:18])[0]} + + +def dec_tmln(v): + if len(v) != 24: + return {"raw": v.hex()} + tempo, beat, time = struct.unpack(">qqq", v) + return {"tempo_us_per_beat": tempo, "beatOrigin_ubeats": beat, "timeOrigin_us": time, + "bpm_approx": round(60e6 / tempo, 3) if tempo else None} + + +def dec_stst(v): + if len(v) != 17: + return {"raw": v.hex()} + playing = v[0] + beats, ts = struct.unpack(">qq", v[1:17]) + return {"isPlaying": playing, "beats_ubeats": beats, "timestamp_us": ts} + + +def dec_i64(v): + return {"i64": struct.unpack(">q", v)[0]} if len(v) == 8 else {"raw": v.hex()} + + +def dec_id(v): + return {"id": v.hex()} if len(v) == 8 else {"raw": v.hex()} + + +def dec_pi(v): # peer info: length-prefixed string + if len(v) >= 4: + n = struct.unpack(">I", v[:4])[0] + return {"name_len": n, "name": v[4 : 4 + n].decode("latin1", "replace")} + return {"raw": v.hex()} + + +def dec_auca(v): # channel announcements: u32 count, then [str name][8-byte id]* + if len(v) < 4: + return {"raw": v.hex()} + n = struct.unpack(">I", v[:4])[0] + chans, i = [], 4 + for _ in range(n): + if i + 4 > len(v): + break + ln = struct.unpack(">I", v[i : i + 4])[0] + name = v[i + 4 : i + 4 + ln].decode("latin1", "replace") + cid = v[i + 4 + ln : i + 12 + ln] + chans.append({"name": name, "id": cid.hex()}) + i += 12 + ln + return {"count": n, "channels": chans} + + +def dec_aucb(v): # channel byes: u32 count, then 8-byte ids + if len(v) < 4: + return {"raw": v.hex()} + n = struct.unpack(">I", v[:4])[0] + ids = [v[4 + 8 * k : 12 + 8 * k].hex() for k in range(n)] + return {"count": n, "ids": ids} + + +ENTRY_DECODERS = { + "tmln": dec_tmln, "sess": dec_id, "stst": dec_stst, + "mep4": dec_endpoint4, "mep6": dec_endpoint6, + "aep4": dec_endpoint4, "aep6": dec_endpoint6, + "__ht": dec_i64, "__gt": dec_i64, "_pgt": dec_i64, + "__pi": dec_pi, "auca": dec_auca, "aucb": dec_aucb, "chid": dec_id, +} + +MAGIC = { + b"_asdp_v\x01": "discovery", + b"_link_v\x01": "sync", + b"chnnlsv\x01": "audio", +} +DISC_TYPES = {0: "Invalid", 1: "Alive", 2: "Response", 3: "ByeBye"} +SYNC_TYPES = {1: "Ping", 2: "Pong"} +AUDIO_TYPES = {0: "Invalid", 1: "PeerAnnouncement", 2: "ChannelByes", 3: "Pong", + 4: "ChannelRequest", 5: "StopChannelRequest", 6: "AudioBuffer"} +ABU_FOURCC = bytes.fromhex("5f616275") # '_abu' + + +def classify(pl): + return MAGIC.get(pl[:8]) + + +def decode_msg(pl, proto): + """Decode one protocol message into a structured dict (header + payload).""" + m = {"proto": proto, "len": len(pl)} + if proto == "sync": + m["type"] = pl[8] + m["type_name"] = SYNC_TYPES.get(pl[8], f"unknown({pl[8]})") + m["entries"] = _entries(pl[9:]) + return m + # discovery & audio share the 20-byte header layout + m["type"] = pl[8] + m["ttl"] = pl[9] + m["groupId"] = struct.unpack(">H", pl[10:12])[0] + m["nodeId"] = pl[12:20].hex() + body = pl[20:] + if proto == "discovery": + m["type_name"] = DISC_TYPES.get(pl[8], f"unknown({pl[8]})") + m["entries"] = _entries(body) + else: # audio + m["type_name"] = AUDIO_TYPES.get(pl[8], f"unknown({pl[8]})") + if pl[8] == 6: # AudioBuffer: bare structure, no container + m["abu_prefix_present"] = body[:4] == ABU_FOURCC + m["audiobuffer"] = _audiobuffer(body) + else: + m["entries"] = _entries(body) + return m + + +def _entries(body): + res = [] + for key, size, val in parse_entries(body): + d = {"key": key, "size": size} + if val is None: + d["OVERRUN"] = True + elif key in ENTRY_DECODERS: + d["value"] = ENTRY_DECODERS[key](val) + else: + d["raw"] = val.hex() + res.append(d) + return res + + +def _audiobuffer(body): + if len(body) < 20: + return {"truncated": True} + cid = body[:8].hex() + sess = body[8:16].hex() + n = struct.unpack(">I", body[16:20])[0] + chunks, pos = [], 20 + for _ in range(n): + if pos + 26 > len(body): + break + count = struct.unpack(">Q", body[pos : pos + 8])[0] + nframes = struct.unpack(">H", body[pos + 8 : pos + 10])[0] + beats = struct.unpack(">q", body[pos + 10 : pos + 18])[0] + tempo = struct.unpack(">q", body[pos + 18 : pos + 26])[0] + chunks.append({"count": count, "numFrames": nframes, + "beginBeats_ubeats": beats, "tempo_us_per_beat": tempo}) + pos += 26 + trailer = {} + if pos + 8 <= len(body): + trailer = { + "codec": body[pos], + "sampleRate": struct.unpack(">I", body[pos + 1 : pos + 5])[0], + "numChannels": body[pos + 5], + "numBytes": struct.unpack(">H", body[pos + 6 : pos + 8])[0], + } + trailer["sample_bytes_remaining"] = len(body) - (pos + 8) + trailer["numBytes_matches_remaining"] = ( + trailer["numBytes"] == trailer["sample_bytes_remaining"] + ) + total_frames = sum(c["numFrames"] for c in chunks) + trailer["total_frames"] = total_frames + return {"channelId": cid, "sessionId": sess, "chunkCount": n, + "chunks": chunks, **trailer} + + +# ---- manifest --------------------------------------------------------------- + +def build_manifest(path): + linktype, pkts = read_pcap(path) + msgs = [] + topo = defaultdict(lambda: {"src": set(), "dst": set()}) # proto -> ip sets + gateways = defaultdict(set) # nodeId -> set(src_ip) (one gw per src ip) + ports = defaultdict(set) # proto -> set(dst_port) + for ts, frame in pkts: + r = l3(frame, linktype) + if not r: + continue + src, dst, proto_num, l4 = r + if proto_num != 17: + continue + u = udp(l4) + if not u: + continue + sp, dp, pl = u + proto = classify(pl) + if not proto: + continue + m = decode_msg(pl, proto) + m.update({"ts": ts, "src": f"{src}:{sp}", "dst": f"{dst}:{dp}"}) + msgs.append(m) + topo[proto]["src"].add(src) + topo[proto]["dst"].add(dst) + ports[proto].add(dp) + if "nodeId" in m: + gateways[m["nodeId"]].add(src) + + type_counts = Counter((m["proto"], m.get("type_name")) for m in msgs) + peers = sorted(gateways.keys()) + + man = { + "file": os.path.basename(path), + "linktype": LINKTYPES.get(linktype, str(linktype)), + "frames_total": len(pkts), + "protocol_messages": len(msgs), + "peers_by_nodeId": { + nid: {"gateways": sorted(gateways[nid])} for nid in peers + }, + "gateways_per_peer": {nid: len(gateways[nid]) for nid in peers}, + "destination_ports": {p: sorted(ports[p]) for p in ports}, + "message_type_counts": { + f"{proto}/{name}": c for (proto, name), c in sorted(type_counts.items()) + }, + } + + # discovery: distinct entry-key sets and datagram sizes per set + disc = [m for m in msgs if m["proto"] == "discovery" and m["type"] in (1, 2)] + disc_sets = defaultdict(set) + for m in disc: + keys = tuple(e["key"] for e in m.get("entries", [])) + disc_sets[keys].add(m["len"]) + man["discovery_peerstate"] = [ + {"entry_keys": list(k), "datagram_sizes": sorted(v)} + for k, v in disc_sets.items() + ] + + # discovery: timeline and start/stop content summaries + tmln_tempos, beat_origins, stst_states = set(), [], set() + for m in disc: + for e in m.get("entries", []): + v = e.get("value", {}) + if e["key"] == "tmln" and "tempo_us_per_beat" in v: + tmln_tempos.add(v["tempo_us_per_beat"]) + beat_origins.append(v["beatOrigin_ubeats"]) + if e["key"] == "stst" and "isPlaying" in v: + stst_states.add((v["isPlaying"], v["timestamp_us"])) + man["discovery_tmln"] = { + "distinct_tempos_us_per_beat": sorted(tmln_tempos), + "beatOrigin_min_ubeats": min(beat_origins) if beat_origins else None, + "beatOrigin_max_ubeats": max(beat_origins) if beat_origins else None, + } + man["discovery_stst"] = { + "isPlaying_values_seen": sorted({s[0] for s in stst_states}), + "distinct_states": len(stst_states), + } + + # sync: ping/pong sizes and entry-key sets + sync_sets = defaultdict(set) + for m in (m for m in msgs if m["proto"] == "sync"): + keys = (m["type_name"],) + tuple(e["key"] for e in m.get("entries", [])) + sync_sets[keys].add(m["len"]) + man["sync_messages"] = [ + {"shape": list(k), "datagram_sizes": sorted(v)} for k, v in sync_sets.items() + ] + + # audio: announcement entry sets, channels seen, audiobuffer params + abufs = [m["audiobuffer"] for m in msgs + if m["proto"] == "audio" and m["type"] == 6 and "audiobuffer" in m] + man["audio_buffer"] = { + "count": len(abufs), + "abu_prefix_ever_present": any( + m.get("abu_prefix_present") for m in msgs + if m["proto"] == "audio" and m["type"] == 6 + ), + "codecs": sorted({a.get("codec") for a in abufs if "codec" in a}), + "sample_rates": sorted({a.get("sampleRate") for a in abufs if "sampleRate" in a}), + "num_channels": sorted({a.get("numChannels") for a in abufs if "numChannels" in a}), + "chunk_counts": sorted({a.get("chunkCount") for a in abufs}), + "numBytes_range": ( + [min(a["numBytes"] for a in abufs if "numBytes" in a), + max(a["numBytes"] for a in abufs if "numBytes" in a)] + if any("numBytes" in a for a in abufs) else [] + ), + "numBytes_always_matches_remaining": all( + a.get("numBytes_matches_remaining", True) for a in abufs + ), + "tempo_values_us_per_beat": sorted( + {c["tempo_us_per_beat"] for a in abufs for c in a.get("chunks", [])} + ), + } + announce = [m for m in msgs if m["proto"] == "audio" and m["type"] == 1] + ann_sets = defaultdict(set) + chans = {} + for m in announce: + ann_sets[tuple(e["key"] for e in m.get("entries", []))].add(m["len"]) + for e in m.get("entries", []): + if e["key"] == "auca" and "value" in e: + for ch in e["value"].get("channels", []): + chans[ch["id"]] = ch["name"] + man["audio_announcements"] = [ + {"entry_keys": list(k), "datagram_sizes": sorted(v)} for k, v in ann_sets.items() + ] + man["audio_channels_announced"] = chans + man["audio_groupIds"] = sorted( + {m["groupId"] for m in msgs if m["proto"] == "audio"} + ) + return man, msgs + + +def print_manifest_md(man): + p = print + p(f"# Observed-fact manifest: `{man['file']}`\n") + p(f"- Link type: **{man['linktype']}**") + p(f"- Frames captured: **{man['frames_total']}**, " + f"decoded protocol messages: **{man['protocol_messages']}**") + p(f"- Distinct peers (by NodeId): **{len(man['peers_by_nodeId'])}**") + p(f"- Gateways per peer: " + f"**{sorted(set(man['gateways_per_peer'].values())) or ['n/a']}** " + f"(distinct source IPs each NodeId transmits from)") + for nid, info in man["peers_by_nodeId"].items(): + p(f" - `{nid}` via {info['gateways']}") + p(f"- Destination ports by protocol: {man['destination_ports']}\n") + p("## Message-type counts\n") + for k, c in man["message_type_counts"].items(): + p(f"- `{k}`: {c}") + p("\n## Discovery peer-state shapes\n") + for s in man["discovery_peerstate"]: + p(f"- entries {s['entry_keys']} -> datagram sizes {s['datagram_sizes']} bytes") + t = man["discovery_tmln"] + p(f"- timeline tempos seen (us/beat): {t['distinct_tempos_us_per_beat']}; " + f"beatOrigin range (ubeats): [{t['beatOrigin_min_ubeats']}, " + f"{t['beatOrigin_max_ubeats']}]") + s = man["discovery_stst"] + p(f"- start/stop isPlaying values seen: {s['isPlaying_values_seen']} " + f"({s['distinct_states']} distinct states)") + p("\n## Sync message shapes\n") + for s in man["sync_messages"]: + p(f"- {s['shape']} -> {s['datagram_sizes']} bytes") + if man["message_type_counts"].get("audio/AudioBuffer") or man["audio_announcements"]: + ab = man["audio_buffer"] + p("\n## Audio\n") + p(f"- groupIds seen: {man['audio_groupIds']}") + p(f"- channels announced: {man['audio_channels_announced']}") + for s in man["audio_announcements"]: + p(f"- announcement entries {s['entry_keys']} -> {s['datagram_sizes']} bytes") + p(f"- AudioBuffers: {ab['count']}; `_abu` prefix ever present: " + f"**{ab['abu_prefix_ever_present']}**") + p(f" - codecs={ab['codecs']} rates={ab['sample_rates']} " + f"channels={ab['num_channels']} chunkCounts={ab['chunk_counts']}") + p(f" - numBytes range={ab['numBytes_range']}, " + f"always == trailing bytes: **{ab['numBytes_always_matches_remaining']}**") + p(f" - chunk tempo values (us/beat): {ab['tempo_values_us_per_beat']}") + + +def print_dump(msgs, which): + for m in msgs: + if which != "all" and m["proto"] != which: + continue + head = f"{m['ts']:.6f} {m['src']} > {m['dst']} {m['proto']}/{m.get('type_name')}" + extra = {k: v for k, v in m.items() + if k not in ("ts", "src", "dst", "proto", "type_name", "type")} + print(head) + print(" " + json.dumps(extra, default=str)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("pcap") + ap.add_argument("--dump", choices=["all", "discovery", "sync", "audio"]) + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + man, msgs = build_manifest(args.pcap) + if args.dump: + print_dump(msgs, args.dump) + elif args.json: + print(json.dumps(man, indent=2, default=str)) + else: + print_manifest_md(man) + + +if __name__ == "__main__": + main() diff --git a/tools/build-reference.sh b/tools/build-reference.sh new file mode 100755 index 0000000..4da6e70 --- /dev/null +++ b/tools/build-reference.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# build-reference.sh — clone and build the pinned Ableton Link reference +# implementation OUTSIDE the repository (never vendored; see PROVENANCE.md). +# Shared by the capture rig (tools/capture-vectors.sh) and the conformance +# harness (conformance/). +# +# Env: +# LINK_CAPTURE_WORK work dir for the clone/build (default /tmp/link-wire-capture) +# LINK_UPSTREAM_URL upstream git URL (default github.com/Ableton/link) +# +# Prints the binary directory on stdout. License: MIT + +set -euo pipefail + +REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +PIN=$(tr -d '[:space:]' <"$REPO_DIR/LAST_REVIEWED_SHA") +WORK=${LINK_CAPTURE_WORK:-/tmp/link-wire-capture} +SRC="$WORK/link" +BIN="$SRC/build/bin" +UPSTREAM_URL=${LINK_UPSTREAM_URL:-https://github.com/Ableton/link.git} + +if [ ! -x "$BIN/LinkHutSilent" ] || [ ! -x "$BIN/LinkAudioHut" ]; then + echo "[build-reference] cloning reference at $PIN" >&2 + mkdir -p "$WORK" + if [ ! -d "$SRC/.git" ]; then + git clone "$UPSTREAM_URL" "$SRC" >&2 + fi + git -C "$SRC" fetch origin "$PIN" >&2 || true + git -C "$SRC" checkout --force "$PIN" >&2 + git -C "$SRC" submodule update --init --recursive >&2 + echo "[build-reference] building LinkHutSilent + LinkAudioHut (JACK audio platform)" >&2 + cmake -S "$SRC" -B "$SRC/build" -DCMAKE_BUILD_TYPE=Release \ + -DLINK_BUILD_JACK=ON -DLINK_BUILD_TESTS=OFF >/dev/null + cmake --build "$SRC/build" --target LinkHutSilent LinkAudioHut \ + -j"$(nproc)" >/dev/null +fi +echo "[build-reference] binaries ready: $BIN" >&2 +echo "$BIN" diff --git a/tools/capture-vectors.sh b/tools/capture-vectors.sh new file mode 100755 index 0000000..475e48c --- /dev/null +++ b/tools/capture-vectors.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# capture-vectors.sh — build the pinned Ableton Link reference implementation +# and record protocol test vectors (vectors/*.pcap) by running scripted +# scenarios between reference peers. +# +# Each scenario runs inside an isolated network namespace so the topology is +# controlled and documented, not inherited from the host: the default +# environment is loopback-only (every peer has exactly one gateway), and the +# multi-gateway scenario adds a second interface deliberately. After capture, +# tools/analyze_pcap.py generates an observed-fact manifest per vector and +# tools/check_vectors.py asserts each capture structurally contains the +# events its scenario exists to demonstrate — a silently failed scenario +# fails the run instead of shipping a hollow vector. +# +# The reference source is cloned OUTSIDE the repository (default: /tmp) and +# is never vendored or redistributed; only packet captures of its runtime +# behavior are stored. See PROVENANCE.md. +# +# Requirements: git, cmake, g++, tcpdump, iproute2, unshare (util-linux), +# python3, jackd (dummy backend) + libjack-dev. Run as root (or with +# CAP_NET_ADMIN + CAP_NET_RAW). +# +# Usage: tools/capture-vectors.sh [scenario ...] +# scenarios: discovery-join-leave sync-tempo-change sync-start-stop +# audio-channel-lifecycle multi-gateway-discovery +# discovery-ipv6 (default: all) +# +# License: MIT + +set -euo pipefail +trap '' PIPE # writing to a peer that already quit must not kill the script +export PATH="$PATH:/usr/sbin:/sbin" + +REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +PIN=$(tr -d '[:space:]' <"$REPO_DIR/LAST_REVIEWED_SHA") +WORK=${LINK_CAPTURE_WORK:-/tmp/link-wire-capture} +SRC="$WORK/link" +BIN="$SRC/build/bin" +OUT=${LINK_CAPTURE_OUT:-$REPO_DIR/vectors} +UPSTREAM_URL=${LINK_UPSTREAM_URL:-https://github.com/Ableton/link.git} + +log() { echo "[capture] $*" >&2; } + +# ---------------------------------------------------------------- build + +build_reference() { + BIN=$(LINK_CAPTURE_WORK="$WORK" LINK_UPSTREAM_URL="$UPSTREAM_URL" \ + "$REPO_DIR/tools/build-reference.sh") +} + +# ---------------------------------------------------------------- peers + +# Peers are driven through named pipes; each key press is one byte on the +# pipe (the huts read unbuffered single characters; channel selection reads +# one full line). +declare -A PEER_FD PEER_PID +TCPDUMP_PID="" +JACK_PID="" + +cleanup() { + local pid + for pid in "${PEER_PID[@]:-}" "${TCPDUMP_PID:-}" "${JACK_PID:-}"; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT + +spawn() { # spawn NAME BINARY [ARGS...] + local name=$1 binary=$2 + shift 2 + local fifo="$WORK/$name.in" + rm -f "$fifo" + mkfifo "$fifo" + "$binary" "$@" <"$fifo" >"$WORK/$name.log" 2>&1 & + PEER_PID[$name]=$! + exec {fd}>"$fifo" + PEER_FD[$name]=$fd +} + +send() { # send NAME STRING + printf '%s' "$2" >&"${PEER_FD[$1]}" 2>/dev/null || true +} + +quit_all() { + local name fd + for name in "${!PEER_FD[@]}"; do + if kill -0 "${PEER_PID[$name]}" 2>/dev/null; then + send "$name" q + fi + done + for name in "${!PEER_PID[@]}"; do + wait "${PEER_PID[$name]}" 2>/dev/null || true + fd=${PEER_FD[$name]} + eval "exec $fd>&-" 2>/dev/null || true + done + PEER_FD=() + PEER_PID=() +} + +start_capture() { # start_capture FILE [IFACE [FILTER]] + local file=$1 iface=${2:-lo} filter=${3:-udp} + tcpdump -i "$iface" -w "$file" -U $filter >/dev/null 2>&1 & + TCPDUMP_PID=$! + sleep 1 +} + +stop_capture() { + sleep 1 + kill "$TCPDUMP_PID" 2>/dev/null || true + wait "$TCPDUMP_PID" 2>/dev/null || true + TCPDUMP_PID="" +} + +start_jack() { + JACK_NO_AUDIO_RESERVATION=1 jackd -r -d dummy -r 48000 -p 256 \ + >"$WORK/jackd.log" 2>&1 & + JACK_PID=$! + sleep 2 +} + +stop_jack() { + kill "$JACK_PID" 2>/dev/null || true + wait "$JACK_PID" 2>/dev/null || true + JACK_PID="" +} + +# ---------------------------------------------------------------- scenarios +# All scenario functions run INSIDE an isolated network namespace with lo up +# (see inner_main). Default topology: loopback only -> one gateway per peer. + +# Two plain Link peers. B joins 3 s after A, leaves first (its ByeBye is on +# the wire); A then re-founds a session alone (observable as a fresh NodeId) +# and keeps announcing. +scenario_discovery_join_leave() { + start_capture "$OUT/discovery-join-leave.pcap" + spawn a "$BIN/LinkHutSilent" + sleep 0.5 + send a a # enable Link + sleep 3 + spawn b "$BIN/LinkHutSilent" + sleep 0.5 + send b a # enable Link: discovery, measurement, session merge + sleep 4 + send b q # quit: ByeBye + wait "${PEER_PID[b]}" 2>/dev/null || true + sleep 2 + quit_all + stop_capture +} + +# Two synced peers; each changes tempo (one key = 1 bpm) while the other +# follows. Shows tmln entries with increasing beatOrigin priority stamps. +scenario_sync_tempo_change() { + start_capture "$OUT/sync-tempo-change.pcap" + spawn a "$BIN/LinkHutSilent" + spawn b "$BIN/LinkHutSilent" + sleep 0.5 + send a a + send b a + sleep 3 # discover + measure + settle at 120 bpm + send a eeee # a: 120 -> 124 bpm + sleep 2 + send b ww # b: 124 -> 122 bpm + sleep 2 + quit_all + stop_capture +} + +# Two peers with start/stop sync enabled; transport started then stopped on +# one of them. Shows stst entries with ghost-time ordering timestamps. +scenario_sync_start_stop() { + start_capture "$OUT/sync-start-stop.pcap" + spawn a "$BIN/LinkHutSilent" + spawn b "$BIN/LinkHutSilent" + sleep 0.5 + send a a + send b a + sleep 3 + send a s # enable start/stop sync + send b s + sleep 1 + send a ' ' # start transport + sleep 3 + send a ' ' # stop transport + sleep 2 + quit_all + stop_capture +} + +# Two LinkAudio peers (JACK dummy backend): audio endpoints advertised in +# discovery, unicast PeerAnnouncements with channel lists and ping/pong, +# channel request + its 5 s keepalive repetitions, audio streaming including +# a mid-stream tempo change, stop-request, channel byes. +scenario_audio_channel_lifecycle() { + if ! command -v jackd >/dev/null; then + log "SKIP audio-channel-lifecycle: jackd not installed" + return + fi + start_jack + start_capture "$OUT/audio-channel-lifecycle.pcap" + spawn alice "$BIN/LinkAudioHut" Alice + spawn bob "$BIN/LinkAudioHut" Bob + sleep 1 + send alice a + send bob a + sleep 3 # Link session established + send alice c # Alice publishes her sink channel + send bob c # Bob announces too (audio endpoints both ways) + sleep 3 # announcements + pings/pongs flow + send alice ' ' # transport start: audible metronome in the stream + sleep 1 + send bob o # Bob: create source... + sleep 0.5 + send bob $'0\n' # ...for channel index 0 (Alice | A Sink) + sleep 6 # stream; first request keepalive at +5 s + send alice e # tempo change while streaming (new tempo in chunks) + sleep 5 # stream at new tempo; second keepalive at +10 s + send bob o # Bob removes the source: StopChannelRequest + sleep 1 + send alice c # Alice disables LinkAudio: ChannelByes + sleep 1 + quit_all + stop_capture + stop_jack +} + +# Two peers, each running on TWO gateways: loopback plus a second interface +# added inside the namespace. Shows per-gateway announcement (each NodeId +# transmits from both source addresses, each advertising a gateway-specific +# measurement endpoint). +scenario_multi_gateway_discovery() { + # veth pair: bringing both ends up gives gw1 carrier (IFF_RUNNING), which + # the reference's interface scanner requires + ip link add gw1 type veth peer name gw1p 2>/dev/null \ + || { log "SKIP multi-gateway-discovery: cannot create veth interface"; return; } + ip addr add 192.168.77.1/24 dev gw1 + ip link set gw1 up + ip link set gw1p up + start_capture "$OUT/multi-gateway-discovery.pcap" any + spawn a "$BIN/LinkHutSilent" + spawn b "$BIN/LinkHutSilent" + sleep 0.5 + send a a + send b a + sleep 6 + quit_all + stop_capture + ip link delete gw1 2>/dev/null || true +} + +# IPv6 variant of discovery: requires kernel IPv6 plus an interface with +# both an IPv4 and a link-local IPv6 address (the reference only uses +# link-local v6, and only on interfaces that also run v4). Skipped when +# unavailable. +scenario_discovery_ipv6() { + if [ ! -e /proc/net/if_inet6 ]; then + log "SKIP discovery-ipv6: kernel IPv6 not available" + return + fi + ip link add gw6 type veth peer name gw6p 2>/dev/null \ + || { log "SKIP discovery-ipv6: cannot create veth interface"; return; } + ip addr add 192.168.78.1/24 dev gw6 + ip link set gw6 up + ip link set gw6p up + sleep 1 # let the kernel assign the link-local v6 address + if ! ip -6 addr show dev gw6 scope link | grep -q fe80; then + log "SKIP discovery-ipv6: no link-local IPv6 on test interface" + ip link delete gw6 2>/dev/null || true + return + fi + start_capture "$OUT/discovery-ipv6.pcap" any "ip6 and udp" + spawn a6 "$BIN/LinkHutSilent" + spawn b6 "$BIN/LinkHutSilent" + sleep 0.5 + send a6 a + send b6 a + sleep 6 + quit_all + stop_capture + ip link delete gw6 2>/dev/null || true +} + +# ---------------------------------------------------------------- netns glue + +ALL_SCENARIOS=(discovery-join-leave sync-tempo-change sync-start-stop + audio-channel-lifecycle multi-gateway-discovery discovery-ipv6) + +inner_main() { # runs inside `unshare --net` + local s=$1 + ip link set lo up + "scenario_${s//-/_}" +} + +outer_main() { + local scenarios=("$@") + [ ${#scenarios[@]} -eq 0 ] && scenarios=("${ALL_SCENARIOS[@]}") + + mkdir -p "$WORK" "$OUT" + build_reference + + for s in "${scenarios[@]}"; do + log "scenario: $s (isolated netns)" + unshare --net "$BASH" "$0" --inner "$s" + done + + log "generating observed-fact manifests" + mkdir -p "$OUT/manifests" + for f in "$OUT"/*.pcap; do + [ -e "$f" ] || continue + python3 "$REPO_DIR/tools/analyze_pcap.py" "$f" \ + >"$OUT/manifests/$(basename "${f%.pcap}").md" + done + + log "running structural assertions" + python3 "$REPO_DIR/tools/check_vectors.py" "$OUT" + + log "captures written to $OUT:" + ls -la "$OUT"/*.pcap >&2 || true +} + +if [ "${1:-}" = "--inner" ]; then + shift + inner_main "$@" +else + outer_main "$@" +fi diff --git a/tools/check_vectors.py b/tools/check_vectors.py new file mode 100644 index 0000000..be11aa7 --- /dev/null +++ b/tools/check_vectors.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""check_vectors.py — structural assertions on captured test vectors. + +A capture is only a valid vector if it actually demonstrates what its scenario +claims. This checker decodes each pcap (via analyze_pcap) and asserts the +presence and shape of the protocol events the scenario exists to show; a +scenario that silently failed (peer never joined, subscription never made) +fails here instead of shipping a hollow vector. + +Assertions are structural — message types, entry shapes, field invariants — +never identifier values or timing, which vary per run. + +Usage: check_vectors.py [VECTORS_DIR] (default: repo's vectors/) +Exit nonzero on any failure. License: MIT +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from analyze_pcap import build_manifest # noqa: E402 + +FAILURES = [] + + +def check(name, cond, desc): + status = "ok" if cond else "FAIL" + print(f" [{status}] {desc}") + if not cond: + FAILURES.append(f"{name}: {desc}") + + +def counts(man): + return man["message_type_counts"] + + +def single_gateway(man): + return set(man["gateways_per_peer"].values()) <= {1} + + +def check_discovery_join_leave(man): + c = counts(man) + check("join-leave", c.get("discovery/Alive", 0) >= 20, "Alive messages present (>=20)") + check("join-leave", c.get("discovery/Response", 0) >= 1, "unicast Response present") + check("join-leave", c.get("discovery/ByeBye", 0) >= 1, "ByeBye present") + check("join-leave", c.get("sync/Ping", 0) >= 50 and c.get("sync/Pong", 0) >= 50, + "measurement ping/pong chain present (>=50 each)") + shapes = {tuple(s["entry_keys"]) for s in man["discovery_peerstate"]} + check("join-leave", ("tmln", "sess", "stst", "mep4") in shapes, + "peer-state entries tmln,sess,stst,mep4 observed") + sizes = {z for s in man["discovery_peerstate"] for z in s["datagram_sizes"]} + check("join-leave", 107 in sizes, "107-byte plain peer-state datagram observed") + ping_shapes = {tuple(s["shape"]) for s in man["sync_messages"]} + check("join-leave", ("Ping", "__ht") in ping_shapes, "initial Ping {__ht} observed") + check("join-leave", ("Ping", "__ht", "_pgt") in ping_shapes, + "steady-state Ping {__ht,_pgt} observed") + check("join-leave", ("Pong", "sess", "__gt", "__ht", "_pgt") in ping_shapes, + "Pong {sess,__gt} + echoed {__ht,_pgt} observed") + check("join-leave", single_gateway(man), "every peer on exactly one gateway") + + +def check_sync_tempo_change(man): + t = man["discovery_tmln"] + check("tempo", len(t["distinct_tempos_us_per_beat"]) >= 3, + f"≥3 distinct tempos gossiped (saw {t['distinct_tempos_us_per_beat']})") + check("tempo", t["beatOrigin_max_ubeats"] > t["beatOrigin_min_ubeats"], + "beatOrigin increased across timeline changes") + check("tempo", single_gateway(man), "every peer on exactly one gateway") + + +def check_sync_start_stop(man): + s = man["discovery_stst"] + check("startstop", set(s["isPlaying_values_seen"]) >= {0, 1}, + f"both playing and stopped states gossiped (saw {s['isPlaying_values_seen']})") + check("startstop", s["distinct_states"] >= 3, + "≥3 distinct (isPlaying, timestamp) states (initial, start, stop)") + check("startstop", single_gateway(man), "every peer on exactly one gateway") + + +def check_audio_channel_lifecycle(man): + c = counts(man) + shapes = {tuple(s["entry_keys"]) for s in man["discovery_peerstate"]} + check("audio", any("aep4" in s for s in shapes), + "audio endpoint (aep4) advertised in discovery") + check("audio", c.get("audio/PeerAnnouncement", 0) >= 10, "PeerAnnouncements present") + check("audio", c.get("audio/Pong", 0) >= 10, "audio Pongs present") + check("audio", c.get("audio/ChannelRequest", 0) >= 2, + "ChannelRequest re-sent (keepalive by repetition, >=2)") + check("audio", c.get("audio/StopChannelRequest", 0) >= 1, "StopChannelRequest present") + check("audio", c.get("audio/ChannelByes", 0) >= 1, "ChannelByes present") + ab = man["audio_buffer"] + check("audio", ab["count"] >= 100, f"AudioBuffer stream present ({ab['count']})") + check("audio", ab["abu_prefix_ever_present"] is False, + "no _abu wrapper before AudioBuffer structure") + check("audio", ab["codecs"] == [1], f"codec PCM i16 only (saw {ab['codecs']})") + check("audio", ab["numBytes_always_matches_remaining"], + "numBytes always equals trailing sample bytes") + check("audio", len(ab["tempo_values_us_per_beat"]) >= 2, + f"≥2 chunk tempos (mid-stream tempo change; saw " + f"{ab['tempo_values_us_per_beat']})") + check("audio", man["audio_groupIds"] == [0], "groupId always 0") + check("audio", len(man["audio_channels_announced"]) >= 1, "channel announced via auca") + check("audio", single_gateway(man), "every peer on exactly one gateway") + + +def check_multi_gateway_discovery(man): + gw = man["gateways_per_peer"] + check("multigw", any(v >= 2 for v in gw.values()), + f"at least one peer announces from 2+ gateways (saw {gw})") + shapes = {tuple(s["entry_keys"]) for s in man["discovery_peerstate"]} + check("multigw", ("tmln", "sess", "stst", "mep4") in shapes, + "peer-state entries present on multi-gateway capture") + + +CHECKS = { + "discovery-join-leave.pcap": check_discovery_join_leave, + "sync-tempo-change.pcap": check_sync_tempo_change, + "sync-start-stop.pcap": check_sync_start_stop, + "audio-channel-lifecycle.pcap": check_audio_channel_lifecycle, + "multi-gateway-discovery.pcap": check_multi_gateway_discovery, +} + + +def main(): + vdir = sys.argv[1] if len(sys.argv) > 1 else os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "vectors") + seen = 0 + for fname, fn in CHECKS.items(): + path = os.path.join(vdir, fname) + if not os.path.exists(path): + print(f"{fname}: not present, skipping") + continue + seen += 1 + print(f"{fname}:") + man, _ = build_manifest(path) + fn(man) + if seen == 0: + print("no vectors found", file=sys.stderr) + return 1 + if FAILURES: + print(f"\n{len(FAILURES)} assertion(s) failed:", file=sys.stderr) + for f in FAILURES: + print(f" - {f}", file=sys.stderr) + return 1 + print(f"\nall structural assertions passed across {seen} vector(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vectors/README.md b/vectors/README.md new file mode 100644 index 0000000..6f6fc44 --- /dev/null +++ b/vectors/README.md @@ -0,0 +1,112 @@ +# Test vectors + +Packet captures (`*.pcap`) of **reference** Ableton Link peers built from the pinned +upstream commit (`LAST_REVIEWED_SHA`), driven through scripted scenarios by +[`tools/capture-vectors.sh`](../tools/capture-vectors.sh). They are golden traces of +real protocol behavior, used to validate the spec text and as conformance fixtures. + +These captures are **protocol facts** (uncopyrightable observations of wire behavior) +and contain **no reference source code**. To remove any doubt they are dedicated to +the public domain under **CC0** (see [PROVENANCE.md](../PROVENANCE.md)). + +## How to read this directory + +Three artifacts per scenario: + +| Artifact | Authority | +|---|---| +| `.pcap` | the evidence itself | +| [`manifests/.md`](manifests/) | facts **generated from the pcap bytes** by [`tools/analyze_pcap.py`](../tools/analyze_pcap.py) — topology, peers, gateways, message-type counts, datagram shapes and sizes. Regenerated with the capture; cannot drift from the capture. | +| the scenario description below | the **script's intent** (which peers were started, which keys were pressed, when) — useful context, but not evidence. Where intent and manifest disagree, the manifest wins. | + +Every capture must additionally pass the structural assertions in +[`tools/check_vectors.py`](../tools/check_vectors.py), which verify the pcap really +contains the protocol events its scenario exists to demonstrate (a scenario that +silently failed cannot ship a hollow vector). + +## How they were produced + +Each scenario runs inside an **isolated network namespace**. The baseline topology is +loopback-only: every peer has exactly one network interface (`lo`), hence exactly one +Link gateway — the manifests confirm this per capture ("gateways per peer"). The +multi-gateway scenario deliberately adds a second interface (a veth device) inside +the namespace. Reference binaries: `LinkHutSilent` (Link only, dummy audio) and +`LinkAudioHut` (LinkAudio, JACK dummy backend), built from the pinned SHA; captures +made with `tcpdump -U` inside the namespace. + +Regenerate with: + +``` +sudo tools/capture-vectors.sh # all scenarios +sudo tools/capture-vectors.sh sync-tempo-change # one scenario +``` + +Node identifiers, ephemeral ports, clocks, and round counts vary per run, so captures +are reproducible at the level of *which messages appear and how their fields are laid +out*, not byte-for-byte. Conformance checks should assert on structure (as +`check_vectors.py` does), never on identifiers or timing. + +## Protocols by frame magic + +| Magic (bytes 0–7) | Protocol | Chapter | +|---|---|---| +| `5F 61 73 64 70 5F 76 01` (`_asdp_v\x01`) | discovery (multicast `224.76.78.75:20808`) | 1 | +| `5F 6C 69 6E 6B 5F 76 01` (`_link_v\x01`) | sync ping/pong (unicast) | 2 | +| `63 68 6E 6E 6C 73 76 01` (`chnnlsv\x01`) | LinkAudio v1 (unicast) | 3 | + +## Scenarios + +### `discovery-join-leave` + +Script: peer A enables Link; ~3 s later peer B enables; ~4 s later B quits; A runs on +~2 s more, then quits. Demonstrates — and `check_vectors.py` asserts — multicast +Alive cadence, the unicast Response to a newly heard peer, ByeBye on departure, and +the complete sync measurement chain (initial `Ping{__ht}` 25 B → `Pong` 57 B → +steady-state 41 B/73 B; Chapters 1–2). + +Reading note: the manifest reports **three** NodeIds for two processes. That is the +session-reset rule in action (Chapter 2 §7.3): when B's departure leaves A with zero +session peers, A founds a fresh session under a **new random NodeId** and keeps +announcing under it. + +### `sync-tempo-change` + +Script: two synced peers; A raises the tempo 4 bpm in 1-bpm steps, then B lowers it +2 bpm. Asserted: ≥3 distinct `tmln` tempo values gossiped (manifest lists the exact +µs/beat values) and a strictly increased `beatOrigin` across the changes — the +timeline-priority stamp of Chapter 2 §6. + +### `sync-start-stop` + +Script: two peers enable start/stop sync; A starts the transport, ~3 s later stops +it. Asserted: `stst` entries with both `isPlaying` values and ≥3 distinct +(isPlaying, timestamp) states — the ghost-time-ordered start/stop propagation of +Chapter 2 §8. + +### `audio-channel-lifecycle` + +Script: LinkAudio peers "Alice" and "Bob" join a session and enable LinkAudio; Alice +starts her transport; Bob subscribes to Alice's channel and holds the subscription +~12 s; Alice changes tempo mid-stream; Bob unsubscribes; Alice disables LinkAudio; +both quit. Asserted (Chapter 3): `aep4` advertisement in discovery, unicast +PeerAnnouncements and Pongs, ChannelRequest **re-sent** (the 5 s keepalive — ≥2 +requests on the wire), the AudioBuffer stream with **no `_abu` wrapper**, codec 1 +only, `numBytes` always equal to the trailing sample bytes, chunks carrying **two +distinct tempo values** (the mid-stream change), StopChannelRequest, ChannelByes, and +`groupId` 0 throughout. + +### `multi-gateway-discovery` + +Script: a second interface is added inside the namespace before two peers enable +Link, so each peer runs **two gateways**. Asserted: at least one NodeId transmits +from two distinct source addresses. The manifest shows each peer announcing +per-gateway with gateway-specific measurement endpoints (Chapter 1 §2's +one-gateway-per-interface model). + +### `discovery-ipv6` — not yet captured + +The IPv6 link-local variant of discovery. The capture environment's kernel has no +IPv6 support, so this vector could not be produced (the capture script detects +support and emits it automatically where present). Chapter 3's open question 8 +(cross-host usability of advertised `aep6` endpoints, given scope ids are not +transmitted) remains open pending this capture. diff --git a/vectors/audio-channel-lifecycle.pcap b/vectors/audio-channel-lifecycle.pcap new file mode 100644 index 0000000..f968ccf Binary files /dev/null and b/vectors/audio-channel-lifecycle.pcap differ diff --git a/vectors/discovery-join-leave.pcap b/vectors/discovery-join-leave.pcap new file mode 100644 index 0000000..97fab4c Binary files /dev/null and b/vectors/discovery-join-leave.pcap differ diff --git a/vectors/manifests/audio-channel-lifecycle.md b/vectors/manifests/audio-channel-lifecycle.md new file mode 100644 index 0000000..2c309ae --- /dev/null +++ b/vectors/manifests/audio-channel-lifecycle.md @@ -0,0 +1,48 @@ +# Observed-fact manifest: `audio-channel-lifecycle.pcap` + +- Link type: **EN10MB** +- Frames captured: **2919**, decoded protocol messages: **2919** +- Distinct peers (by NodeId): **3** +- Gateways per peer: **[1]** (distinct source IPs each NodeId transmits from) + - `2b6d307053433b39` via ['127.0.0.1'] + - `3f667a683b374669` via ['127.0.0.1'] + - `4b48305b30312929` via ['127.0.0.1'] +- Destination ports by protocol: {'discovery': [20808, 37659, 38602], 'sync': [35720, 57657], 'audio': [34751, 51311]} + +## Message-type counts + +- `audio/AudioBuffer`: 2098 +- `audio/ChannelByes`: 1 +- `audio/ChannelRequest`: 3 +- `audio/PeerAnnouncement`: 132 +- `audio/Pong`: 132 +- `audio/StopChannelRequest`: 1 +- `discovery/Alive`: 171 +- `discovery/ByeBye`: 2 +- `discovery/Response`: 171 +- `sync/Ping`: 104 +- `sync/Pong`: 104 + +## Discovery peer-state shapes + +- entries ['tmln', 'sess', 'stst', 'mep4'] -> datagram sizes [107] bytes +- entries ['tmln', 'sess', 'stst', 'mep4', 'aep4'] -> datagram sizes [121] bytes +- timeline tempos seen (us/beat): [495868, 500000]; beatOrigin range (ubeats): [1998382, 29038996] +- start/stop isPlaying values seen: [0] (1 distinct states) + +## Sync message shapes + +- ['Ping', '__ht'] -> [25] bytes +- ['Pong', 'sess', '__gt', '__ht'] -> [57] bytes +- ['Ping', '__ht', '_pgt'] -> [41] bytes +- ['Pong', 'sess', '__gt', '__ht', '_pgt'] -> [73] bytes + +## Audio + +- groupIds seen: [0] +- channels announced: {'4270605271233878': 'A Sink', '7027443e657e465f': 'A Sink'} +- announcement entries ['sess', '__pi', 'auca', '__ht'] -> [97, 99] bytes +- AudioBuffers: 2098; `_abu` prefix ever present: **False** + - codecs=[1] rates=[48000] channels=[1] chunkCounts=[1, 2] + - numBytes range=[502, 502], always == trailing bytes: **True** + - chunk tempo values (us/beat): [495868, 500000] diff --git a/vectors/manifests/discovery-join-leave.md b/vectors/manifests/discovery-join-leave.md new file mode 100644 index 0000000..e66137a --- /dev/null +++ b/vectors/manifests/discovery-join-leave.md @@ -0,0 +1,31 @@ +# Observed-fact manifest: `discovery-join-leave.pcap` + +- Link type: **EN10MB** +- Frames captured: **302**, decoded protocol messages: **302** +- Distinct peers (by NodeId): **3** +- Gateways per peer: **[1]** (distinct source IPs each NodeId transmits from) + - `457a3b5f4c6e7b4c` via ['127.0.0.1'] + - `633e262161542163` via ['127.0.0.1'] + - `6841524e5a542331` via ['127.0.0.1'] +- Destination ports by protocol: {'discovery': [20808, 47239, 60739], 'sync': [42802, 51606]} + +## Message-type counts + +- `discovery/Alive`: 58 +- `discovery/ByeBye`: 2 +- `discovery/Response`: 34 +- `sync/Ping`: 104 +- `sync/Pong`: 104 + +## Discovery peer-state shapes + +- entries ['tmln', 'sess', 'stst', 'mep4'] -> datagram sizes [107] bytes +- timeline tempos seen (us/beat): [500000]; beatOrigin range (ubeats): [999822, 16034038] +- start/stop isPlaying values seen: [0] (1 distinct states) + +## Sync message shapes + +- ['Ping', '__ht'] -> [25] bytes +- ['Pong', 'sess', '__gt', '__ht'] -> [57] bytes +- ['Ping', '__ht', '_pgt'] -> [41] bytes +- ['Pong', 'sess', '__gt', '__ht', '_pgt'] -> [73] bytes diff --git a/vectors/manifests/multi-gateway-discovery.md b/vectors/manifests/multi-gateway-discovery.md new file mode 100644 index 0000000..85c3fb8 --- /dev/null +++ b/vectors/manifests/multi-gateway-discovery.md @@ -0,0 +1,30 @@ +# Observed-fact manifest: `multi-gateway-discovery.pcap` + +- Link type: **LINUX_SLL2** +- Frames captured: **418**, decoded protocol messages: **418** +- Distinct peers (by NodeId): **2** +- Gateways per peer: **[2]** (distinct source IPs each NodeId transmits from) + - `41684d28793c3a72` via ['127.0.0.1', '192.168.77.1'] + - `5d3628713b443555` via ['127.0.0.1', '192.168.77.1'] +- Destination ports by protocol: {'discovery': [20808, 45567, 59282], 'sync': [43213, 55891]} + +## Message-type counts + +- `discovery/Alive`: 153 +- `discovery/ByeBye`: 6 +- `discovery/Response`: 51 +- `sync/Ping`: 104 +- `sync/Pong`: 104 + +## Discovery peer-state shapes + +- entries ['tmln', 'sess', 'stst', 'mep4'] -> datagram sizes [107] bytes +- timeline tempos seen (us/beat): [500000]; beatOrigin range (ubeats): [1000554, 1007262] +- start/stop isPlaying values seen: [0] (1 distinct states) + +## Sync message shapes + +- ['Ping', '__ht'] -> [25] bytes +- ['Pong', 'sess', '__gt', '__ht'] -> [57] bytes +- ['Ping', '__ht', '_pgt'] -> [41] bytes +- ['Pong', 'sess', '__gt', '__ht', '_pgt'] -> [73] bytes diff --git a/vectors/manifests/sync-start-stop.md b/vectors/manifests/sync-start-stop.md new file mode 100644 index 0000000..6f6fb90 --- /dev/null +++ b/vectors/manifests/sync-start-stop.md @@ -0,0 +1,31 @@ +# Observed-fact manifest: `sync-start-stop.pcap` + +- Link type: **EN10MB** +- Frames captured: **363**, decoded protocol messages: **363** +- Distinct peers (by NodeId): **3** +- Gateways per peer: **[1]** (distinct source IPs each NodeId transmits from) + - `2d7629642b242627` via ['127.0.0.1'] + - `3833783e483a5d5c` via ['127.0.0.1'] + - `433b772b6423615b` via ['127.0.0.1'] +- Destination ports by protocol: {'discovery': [20808, 36941, 45556], 'sync': [35462, 56326]} + +## Message-type counts + +- `discovery/Alive`: 77 +- `discovery/ByeBye`: 2 +- `discovery/Response`: 76 +- `sync/Ping`: 104 +- `sync/Pong`: 104 + +## Discovery peer-state shapes + +- entries ['tmln', 'sess', 'stst', 'mep4'] -> datagram sizes [107] bytes +- timeline tempos seen (us/beat): [500000]; beatOrigin range (ubeats): [1000144, 19022334] +- start/stop isPlaying values seen: [0, 1] (3 distinct states) + +## Sync message shapes + +- ['Ping', '__ht'] -> [25] bytes +- ['Pong', 'sess', '__gt', '__ht'] -> [57] bytes +- ['Ping', '__ht', '_pgt'] -> [41] bytes +- ['Pong', 'sess', '__gt', '__ht', '_pgt'] -> [73] bytes diff --git a/vectors/manifests/sync-tempo-change.md b/vectors/manifests/sync-tempo-change.md new file mode 100644 index 0000000..a019d41 --- /dev/null +++ b/vectors/manifests/sync-tempo-change.md @@ -0,0 +1,30 @@ +# Observed-fact manifest: `sync-tempo-change.pcap` + +- Link type: **EN10MB** +- Frames captured: **344**, decoded protocol messages: **344** +- Distinct peers (by NodeId): **2** +- Gateways per peer: **[1]** (distinct source IPs each NodeId transmits from) + - `3a222d7934583c2a` via ['127.0.0.1'] + - `5840706a4d546f64` via ['127.0.0.1'] +- Destination ports by protocol: {'discovery': [20808, 33835, 57068], 'sync': [45545, 59318]} + +## Message-type counts + +- `discovery/Alive`: 67 +- `discovery/ByeBye`: 2 +- `discovery/Response`: 67 +- `sync/Ping`: 104 +- `sync/Pong`: 104 + +## Discovery peer-state shapes + +- entries ['tmln', 'sess', 'stst', 'mep4'] -> datagram sizes [107] bytes +- timeline tempos seen (us/beat): [483871, 487805, 491803, 495868, 500000]; beatOrigin range (ubeats): [1000178, 11141773] +- start/stop isPlaying values seen: [0] (1 distinct states) + +## Sync message shapes + +- ['Ping', '__ht'] -> [25] bytes +- ['Pong', 'sess', '__gt', '__ht'] -> [57] bytes +- ['Ping', '__ht', '_pgt'] -> [41] bytes +- ['Pong', 'sess', '__gt', '__ht', '_pgt'] -> [73] bytes diff --git a/vectors/multi-gateway-discovery.pcap b/vectors/multi-gateway-discovery.pcap new file mode 100644 index 0000000..d33a0f2 Binary files /dev/null and b/vectors/multi-gateway-discovery.pcap differ diff --git a/vectors/sync-start-stop.pcap b/vectors/sync-start-stop.pcap new file mode 100644 index 0000000..8380106 Binary files /dev/null and b/vectors/sync-start-stop.pcap differ diff --git a/vectors/sync-tempo-change.pcap b/vectors/sync-tempo-change.pcap new file mode 100644 index 0000000..3e9c4f5 Binary files /dev/null and b/vectors/sync-tempo-change.pcap differ