diff --git a/.github/actions/analysis/afl-fuzz/action.yml b/.github/actions/analysis/afl-fuzz/action.yml
index e36c34e..c1b0f2d 100644
--- a/.github/actions/analysis/afl-fuzz/action.yml
+++ b/.github/actions/analysis/afl-fuzz/action.yml
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD-3-Clause
#
name: 'AFL Fuzz'
-description: 'Build and run AFL++ fuzzing for config parser harness'
+description: 'Build and run AFL++ fuzzing for transmitter and receiver config parsers'
inputs:
max-seconds:
@@ -19,7 +19,7 @@ runs:
shell: bash
run: |
sudo apt-get update -qq
- sudo apt-get install -y -qq afl++ libavutil-dev libavformat-dev libavcodec-dev libswscale-dev pkg-config
+ sudo apt-get install -y -qq afl++ libavutil-dev libavformat-dev libavcodec-dev libswscale-dev libjson-c-dev zlib1g-dev pkg-config
- name: Verify AFL environment
shell: bash
@@ -40,7 +40,7 @@ runs:
- name: Build AFL fuzz harness
shell: bash
run: |
- cd fuzz
+ cd dvledtx/fuzz
export CC=afl-clang-fast
$CC -g -O1 -fno-omit-frame-pointer -I../include -c fuzz_config_reader.c -o fuzz_config_reader.o
$CC -g -O1 -fno-omit-frame-pointer -I../include -c ../src/util/config_reader.c -o config_reader.o
@@ -48,12 +48,19 @@ runs:
$CC -o fuzz_config_reader fuzz_config_reader.o config_reader.o logger.o -lavutil -lm
echo "Build successful: $(file fuzz_config_reader)"
+ cd ../../dvledrx/fuzz
+ $CC -g -O1 -fno-omit-frame-pointer -I../include \
+ fuzz_config_reader.c ../src/config_reader.c ../src/logger.c \
+ -pthread $(pkg-config --cflags --libs json-c zlib) \
+ -o fuzz_config_reader
+ echo "Receiver build successful: $(file fuzz_config_reader)"
+
- name: Run AFL fuzzer
shell: bash
env:
MAX_SECONDS: ${{ inputs.max-seconds }}
run: |
- cd fuzz
+ cd dvledtx/fuzz
if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then
echo "ERROR: max-seconds must be a positive integer"
exit 1
@@ -66,7 +73,7 @@ runs:
- name: Check AFL crashes
shell: bash
run: |
- cd fuzz
+ cd dvledtx/fuzz
CRASH_COUNT=$(find findings/default/crashes -type f ! -name "README.txt" 2>/dev/null | wc -l)
echo "Crashes found: $CRASH_COUNT"
if [ "$CRASH_COUNT" -gt 0 ]; then
@@ -76,11 +83,41 @@ runs:
fi
echo "No crashes found - fuzzing passed."
+ - name: Run receiver AFL fuzzer
+ shell: bash
+ env:
+ MAX_SECONDS: ${{ inputs.max-seconds }}
+ run: |
+ cd dvledrx/fuzz
+ if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: max-seconds must be a positive integer"
+ exit 1
+ fi
+ mkdir -p findings
+ export AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1
+ export AFL_SKIP_CPUFREQ=1
+ timeout "$MAX_SECONDS" afl-fuzz -i corpus/ -o findings/ -V "$MAX_SECONDS" -- ./fuzz_config_reader @@ || true
+
+ - name: Check receiver AFL crashes
+ shell: bash
+ run: |
+ cd dvledrx/fuzz
+ CRASH_COUNT=$(find findings/default/crashes -type f ! -name "README.txt" 2>/dev/null | wc -l)
+ echo "Receiver crashes found: $CRASH_COUNT"
+ if [ "$CRASH_COUNT" -gt 0 ]; then
+ echo "::error::AFL found $CRASH_COUNT receiver crash(es)!"
+ ls -la findings/default/crashes/
+ exit 1
+ fi
+ echo "No receiver crashes found - fuzzing passed."
+
- name: Sanitize AFL filenames
if: always()
shell: bash
run: |
- cd fuzz/findings
- find . -name '*:*' | while read -r f; do
- mv "$f" "$(echo "$f" | tr ':' '_')"
+ for FINDINGS_DIR in dvledtx/fuzz/findings dvledrx/fuzz/findings; do
+ [ -d "$FINDINGS_DIR" ] || continue
+ find "$FINDINGS_DIR" -name '*:*' -print0 | while IFS= read -r -d '' f; do
+ mv "$f" "$(echo "$f" | tr ':' '_')"
+ done
done
diff --git a/.github/actions/analysis/checksec/action.yml b/.github/actions/analysis/checksec/action.yml
index b5a6d7d..583e2be 100644
--- a/.github/actions/analysis/checksec/action.yml
+++ b/.github/actions/analysis/checksec/action.yml
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD-3-Clause
#
name: 'checksec Analysis'
-description: 'Run checksec static hardening analysis on dvledtx binaries'
+description: 'Run checksec static hardening analysis on dvledtx and dvledrx binaries'
runs:
using: composite
@@ -40,7 +40,7 @@ runs:
echo "=================================================="
echo " checksec � Binary Hardening Analysis"
echo "=================================================="
- for BIN in bins/dvledtx bins/dvledtx-mtl; do
+ for BIN in bins/dvledtx bins/dvledtx-mtl bins/dvledrx; do
if [ -f "$BIN" ]; then
echo ""
echo "--- $BIN ---"
@@ -59,7 +59,7 @@ runs:
run: |
REPORT_DIR="$GITHUB_WORKSPACE/reports"
mkdir -p "$REPORT_DIR"
- for BIN in bins/dvledtx bins/dvledtx-mtl; do
+ for BIN in bins/dvledtx bins/dvledtx-mtl bins/dvledrx; do
if [ -f "$BIN" ]; then
LABEL=$(basename "$BIN")
checksec --no-banner file -o json "$BIN" 2>/dev/null \
diff --git a/.github/actions/analysis/codeql/action.yml b/.github/actions/analysis/codeql/action.yml
index 6888a9b..b410b27 100644
--- a/.github/actions/analysis/codeql/action.yml
+++ b/.github/actions/analysis/codeql/action.yml
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD-3-Clause
#
name: 'CodeQL Scan'
-description: 'Initialize CodeQL, build dvledtx, and perform CodeQL analysis for C/C++'
+description: 'Initialize CodeQL, build dvledtx and dvledrx, and perform CodeQL analysis for C/C++'
runs:
using: composite
@@ -16,7 +16,7 @@ runs:
build-mode: manual
# c-cpp requires a manual build so CodeQL can trace the meson/ninja compiler invocations.
- - name: Build dvledtx
+ - name: Build applications
uses: $/.github/actions/build-dvledtx
- name: Perform CodeQL Analysis
diff --git a/.github/actions/analysis/cppcheck/action.yml b/.github/actions/analysis/cppcheck/action.yml
index 254f8b5..c5e9314 100644
--- a/.github/actions/analysis/cppcheck/action.yml
+++ b/.github/actions/analysis/cppcheck/action.yml
@@ -31,5 +31,6 @@ runs:
--enable=warning,style,performance,portability \
--std=c11 --force --inline-suppr \
--suppress=missingIncludeSystem \
- -Iinclude src 2>&1 | tee "$REPORT_DIR/cppcheck-report.txt" || true
+ -Idvledtx/include -Idvledrx/include \
+ dvledtx/src dvledrx/src 2>&1 | tee "$REPORT_DIR/cppcheck-report.txt" || true
echo "cppcheck scan complete. Report: $REPORT_DIR/cppcheck-report.txt"
\ No newline at end of file
diff --git a/.github/actions/analysis/libfuzzer/action.yml b/.github/actions/analysis/libfuzzer/action.yml
index 7f27c06..0cb234b 100644
--- a/.github/actions/analysis/libfuzzer/action.yml
+++ b/.github/actions/analysis/libfuzzer/action.yml
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD-3-Clause
#
name: 'libFuzzer'
-description: 'Build and run libFuzzer with ASan and UBSan for config parser harness'
+description: 'Build and run libFuzzer with ASan and UBSan for transmitter and receiver config parsers'
inputs:
max-seconds:
@@ -19,7 +19,7 @@ runs:
shell: bash
run: |
sudo apt-get update -qq
- sudo apt-get install -y -qq clang libavutil-dev libavformat-dev libavcodec-dev libswscale-dev pkg-config
+ sudo apt-get install -y -qq clang libavutil-dev libavformat-dev libavcodec-dev libswscale-dev libjson-c-dev zlib1g-dev pkg-config
- name: Build and run libFuzzer (ASan)
shell: bash
@@ -30,7 +30,7 @@ runs:
echo "ERROR: max-seconds must be a positive integer"
exit 1
fi
- cd fuzz
+ cd dvledtx/fuzz
export CC=clang
$CC -fsanitize=fuzzer,address -g -O1 -fno-omit-frame-pointer -I../include \
fuzz_config_reader_libfuzzer.c \
@@ -58,7 +58,7 @@ runs:
echo "ERROR: max-seconds must be a positive integer"
exit 1
fi
- cd fuzz
+ cd dvledtx/fuzz
export CC=clang
$CC -fsanitize=fuzzer,undefined -g -O1 -fno-omit-frame-pointer -I../include \
fuzz_config_reader_libfuzzer.c \
@@ -76,3 +76,59 @@ runs:
echo "::error::libFuzzer+UBSan found an issue (exit code $FUZZ_EXIT)!"
exit 1
fi
+
+ - name: Build and run receiver libFuzzer (ASan)
+ shell: bash
+ env:
+ MAX_SECONDS: ${{ inputs.max-seconds }}
+ run: |
+ if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: max-seconds must be a positive integer"
+ exit 1
+ fi
+ cd dvledrx/fuzz
+ export CC=clang
+ $CC -fsanitize=fuzzer,address -g -O1 -fno-omit-frame-pointer -I../include \
+ fuzz_config_reader_libfuzzer.c \
+ ../src/config_reader.c \
+ ../src/logger.c \
+ -pthread -o fuzz_config_reader_libfuzzer \
+ $(pkg-config --cflags --libs json-c zlib)
+
+ mkdir -p libfuzzer_corpus
+ ./fuzz_config_reader_libfuzzer \
+ libfuzzer_corpus/ corpus/ \
+ -max_total_time="$MAX_SECONDS" \
+ -print_final_stats=1 || FUZZ_EXIT=$?
+ if [ "${FUZZ_EXIT:-0}" -ne 0 ]; then
+ echo "::error::Receiver libFuzzer+ASan found an issue (exit code $FUZZ_EXIT)!"
+ exit 1
+ fi
+
+ - name: Build and run receiver libFuzzer (UBSan)
+ shell: bash
+ env:
+ MAX_SECONDS: ${{ inputs.max-seconds }}
+ run: |
+ if ! [[ "$MAX_SECONDS" =~ ^[0-9]+$ ]]; then
+ echo "ERROR: max-seconds must be a positive integer"
+ exit 1
+ fi
+ cd dvledrx/fuzz
+ export CC=clang
+ $CC -fsanitize=fuzzer,undefined -g -O1 -fno-omit-frame-pointer -I../include \
+ fuzz_config_reader_libfuzzer.c \
+ ../src/config_reader.c \
+ ../src/logger.c \
+ -pthread -o fuzz_config_reader_ubsan \
+ $(pkg-config --cflags --libs json-c zlib)
+
+ mkdir -p ubsan_corpus
+ ./fuzz_config_reader_ubsan \
+ ubsan_corpus/ corpus/ \
+ -max_total_time="$MAX_SECONDS" \
+ -print_final_stats=1 || FUZZ_EXIT=$?
+ if [ "${FUZZ_EXIT:-0}" -ne 0 ]; then
+ echo "::error::Receiver libFuzzer+UBSan found an issue (exit code $FUZZ_EXIT)!"
+ exit 1
+ fi
diff --git a/.github/actions/build-dvledtx/action.yml b/.github/actions/build-dvledtx/action.yml
index e740de8..db91dd6 100644
--- a/.github/actions/build-dvledtx/action.yml
+++ b/.github/actions/build-dvledtx/action.yml
@@ -3,8 +3,8 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: BSD-3-Clause
#
-name: 'Build dvledtx'
-description: 'Build dvledtx with FFMPEG and MTL TX APIs, keep both binaries'
+name: 'Build dvledtx and dvledrx'
+description: 'Build both applications with FFMPEG and MTL TX APIs'
runs:
using: composite
@@ -14,23 +14,33 @@ runs:
run: |
echo "===== Build: FFMPEG TX APIs ====="
./scripts/build.sh
- if [ ! -f "./build/dvledtx" ]; then
+ if [ ! -f "./build/dvledtx/dvledtx" ]; then
echo "ERROR: dvledtx not found after FFMPEG build!"
exit 1
fi
+ if [ ! -f "./build/dvledrx/dvledrx" ]; then
+ echo "ERROR: dvledrx not found after FFMPEG build!"
+ exit 1
+ fi
mkdir -p ./bins
- cp ./build/dvledtx ./bins/dvledtx
+ cp ./build/dvledtx/dvledtx ./bins/dvledtx
+ cp ./build/dvledrx/dvledrx ./bins/dvledrx
echo "FFMPEG build saved: $(file ./bins/dvledtx)"
+ echo "Receiver build saved: $(file ./bins/dvledrx)"
- name: Build with MTL TX APIs
shell: bash
run: |
echo "===== Build: MTL TX APIs ====="
./scripts/build.sh -Denable_mtl_tx=true
- if [ ! -f "./build/dvledtx" ]; then
+ if [ ! -f "./build/dvledtx/dvledtx" ]; then
echo "ERROR: dvledtx not found after MTL build!"
exit 1
fi
+ if [ ! -f "./build/dvledrx/dvledrx" ]; then
+ echo "ERROR: dvledrx not found after MTL build!"
+ exit 1
+ fi
mkdir -p ./bins
- cp ./build/dvledtx ./bins/dvledtx-mtl
+ cp ./build/dvledtx/dvledtx ./bins/dvledtx-mtl
echo "MTL build saved: $(file ./bins/dvledtx-mtl)"
diff --git a/.github/actions/environment-check/action.yml b/.github/actions/environment-check/action.yml
index 99f9c99..2ab565a 100644
--- a/.github/actions/environment-check/action.yml
+++ b/.github/actions/environment-check/action.yml
@@ -10,11 +10,11 @@ inputs:
dpdk-version:
description: 'DPDK version to build'
required: false
- default: '25.11'
+ default: '26.07'
mtl-commit:
description: 'MTL commit ID to build'
required: false
- default: '7bee24231c864d12da6db2c2ffbfd3986717af5a'
+ default: '9762c314ed24bd628d3f808b95a94c3aa8525723'
ffmpeg-version:
description: 'FFmpeg release branch to build'
required: false
@@ -25,7 +25,7 @@ inputs:
cached environment (e.g. new APT packages needed by the FFmpeg build)
so runners rebuild instead of restoring a stale cache.
required: false
- default: '2'
+ default: '3'
runs:
using: composite
@@ -111,7 +111,7 @@ runs:
shell: bash
run: |
echo "===== APT Package Setup ====="
- PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev"
+ PKGS="git gcc meson python3 python3-pip pkg-config libnuma-dev libjson-c-dev libpcap-dev libgtest-dev libssl-dev systemtap-sdt-dev llvm clang flex byacc libcmocka-dev nasm wget patch unzip shellcheck cppcheck libx11-dev libxext-dev libxrandr-dev zlib1g-dev xvfb xauth libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev"
MISSING=""
for pkg in $PKGS; do
if dpkg -s "$pkg" &>/dev/null; then
diff --git a/.github/actions/smoke-tests/action.yml b/.github/actions/smoke-tests/action.yml
index 64d6f74..c265647 100644
--- a/.github/actions/smoke-tests/action.yml
+++ b/.github/actions/smoke-tests/action.yml
@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD-3-Clause
#
name: 'Smoke Tests'
-description: 'Run smoke tests on dvledtx and dvledtx-mtl binaries. Results written to $GITHUB_WORKSPACE/reports/'
+description: 'Run smoke tests on dvledtx and dvledrx binaries. Results written to $GITHUB_WORKSPACE/reports/'
runs:
using: composite
@@ -15,15 +15,19 @@ runs:
REPORT_DIR="$GITHUB_WORKSPACE/reports"
mkdir -p "$REPORT_DIR"
- for BINARY in ./bins/dvledtx ./bins/dvledtx-mtl; do
+ for BINARY in ./bins/dvledtx ./bins/dvledtx-mtl ./bins/dvledrx; do
NAME=$(basename "$BINARY")
echo "===== Smoke Test: $NAME ====="
OUTFILE="$REPORT_DIR/smoke-test-${NAME}.txt"
# version check
VERSION_OUTPUT=$(timeout 5s "$BINARY" -v 2>&1)
- if ! echo "$VERSION_OUTPUT" | grep -qE "^dvledtx version [0-9]+\.[0-9]+\.[0-9]+"; then
- echo "ERROR: $NAME -v output did not match expected format 'dvledtx version X.Y.Z'"
+ VERSION_PREFIX=dvledtx
+ if [ "$NAME" = "dvledrx" ]; then
+ VERSION_PREFIX=dvledrx
+ fi
+ if ! echo "$VERSION_OUTPUT" | grep -qE "^${VERSION_PREFIX} version [0-9]+\.[0-9]+\.[0-9]+"; then
+ echo "ERROR: $NAME -v output did not match expected format '${VERSION_PREFIX} version X.Y.Z'"
echo "Got: $VERSION_OUTPUT"
exit 1
fi
diff --git a/.github/workflows/daily_build.yml b/.github/workflows/daily_build.yml
index 96927ad..b58e7cc 100644
--- a/.github/workflows/daily_build.yml
+++ b/.github/workflows/daily_build.yml
@@ -75,8 +75,8 @@ jobs:
afl-fuzz:
name: Daily AFL Fuzz (${{ github.event.repository.default_branch }})
runs-on: ubuntu-latest
- # 2h of fuzzing plus headroom for dependency install and the harness build.
- timeout-minutes: 138
+ # 4h of TX+RX fuzzing plus headroom for dependency install and builds.
+ timeout-minutes: 258
permissions:
contents: read
@@ -98,15 +98,17 @@ jobs:
if: always()
with:
name: afl-fuzz-results-${{ github.event.repository.default_branch }}-${{ github.run_id }}
- path: fuzz/findings/
+ path: |
+ dvledtx/fuzz/findings/
+ dvledrx/fuzz/findings/
retention-days: 14
libfuzzer:
name: Daily libFuzzer (${{ github.event.repository.default_branch }})
runs-on: ubuntu-latest
- # max-seconds applies per sanitizer mode (ASan + UBSan), so the fuzzing
- # itself takes ~2h; the rest covers the two builds.
- timeout-minutes: 138
+ # max-seconds applies per target and sanitizer mode (TX+RX, ASan+UBSan),
+ # so fuzzing takes ~4h; the rest covers the four builds.
+ timeout-minutes: 258
permissions:
contents: read
@@ -129,8 +131,10 @@ jobs:
with:
name: libfuzzer-results-${{ github.event.repository.default_branch }}-${{ github.run_id }}
path: |
- fuzz/libfuzzer_corpus/
- fuzz/ubsan_corpus/
+ dvledtx/fuzz/libfuzzer_corpus/
+ dvledtx/fuzz/ubsan_corpus/
+ dvledrx/fuzz/libfuzzer_corpus/
+ dvledrx/fuzz/ubsan_corpus/
retention-days: 14
diff --git a/.gitignore b/.gitignore
index 397b4a7..64d7c11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
*.log
+/build/
+/build-tests/
diff --git a/README.md b/README.md
index 12bfc22..05f6b69 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://github.com/OpenVisualCloud/directview-led-software-toolkit/actions/workflows/ci.yml)
[](https://releases.ubuntu.com/jammy/)
-[](https://github.com/OpenVisualCloud/Media-Transport-Library)
+[](https://github.com/OpenVisualCloud/Media-Transport-Library/releases/tag/v26.09)
[](https://securityscorecards.dev/viewer/?uri=github.com/OpenVisualCloud/directview-led-software-toolkit)
## Table of Contents
@@ -60,9 +60,10 @@ dvledtx reads a video source file (e.g., MP4), decodes it using FFmpeg, and tran
### Deployment Security Model
-dvledtx follows a layered security model. The application validates and bounds every input it
-owns — configuration parsing, log file destinations, decoder behaviour and resource limits — and
-is covered by static analysis, fuzzing and dependency CVE tracking in CI. Transport-level
+dvledtx and dvledrx follow a layered security model. The applications validate and bound their
+inputs — including configuration parsing, log file destinations, decoder behaviour and resource
+limits — and are covered by static analysis, AFL/libFuzzer fuzzing and dependency CVE tracking in
+CI. Transport-level
security is a separate layer: SMPTE ST 2110-20 and the PTP timing it depends on do not define
authentication, encryption or integrity protection, which is a property of the standards common
to all ST 2110 equipment. The deployment architecture supplies that layer.
@@ -111,20 +112,22 @@ FFmpeg is an open source project licensed under LGPL and GPL. See https://www.ff
> **Note:** This toolkit has been validated against **Ubuntu 22.04 LTS** but should work on Ubuntu 24.04 LTS and higher versions.
- Ubuntu [22.04](https://releases.ubuntu.com/jammy/) or [24.04](https://releases.ubuntu.com/noble/) LTS
-- [Media Transport Library (MTL) v26.01+](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md)
+- Receiver build dependencies: `libx11-dev`, `libxext-dev`, `libxrandr-dev`, `libjson-c-dev`, and `zlib1g-dev`.
+- Receiver X11/session unit tests use `xvfb-run`; install `xvfb` and `xauth` to run the full receiver test suite.
+- [Media Transport Library (MTL) v26.09](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md)
- Follow these steps
- - [Install APT packages](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#111-ubuntudebian)
+ - [Install APT packages](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#111-ubuntudebian)
- Clone Media-Transport-Library
```
git clone https://github.com/OpenVisualCloud/Media-Transport-Library.git
cd Media-Transport-Library
- git checkout 7bee24231c864d12da6db2c2ffbfd3986717af5a
+ git checkout 9762c314ed24bd628d3f808b95a94c3aa8525723
cd ..
export mtl_source_code=${PWD}/Media-Transport-Library
```
- - [Build and install DPDK](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#2-dpdk-build-and-install)
- - [Build and install MTL](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/build.md#3-build-media-transport-library-and-app)
-- [FFmpeg 7.0 with MTL Plugin](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/ecosystem/ffmpeg_plugin/README.md#1-build)
+ - [Build and install DPDK](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#2-dpdk-build-and-install)
+ - [Build and install MTL](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/build.md#3-build-media-transport-library-and-app)
+ - [FFmpeg 7.0 with MTL Plugin](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/ecosystem/ffmpeg_plugin/README.md#1-build)
- **Screen capture support (`input_mode: screen_capture`) requires FFmpeg's `x11grab` device.** It is auto-detected and compiled in by FFmpeg's `./configure` script, but only if these packages are installed *before* building FFmpeg:
```bash
sudo apt-get install -y libx11-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev
@@ -146,7 +149,7 @@ FFmpeg is an open source project licensed under LGPL and GPL. See https://www.ff
vainfo | grep VAEntrypointVLD
```
If either prints nothing, hardware decode is unavailable and dvledtx silently falls back to CPU decode. See [Hardware Decode (VA-API)](#hardware-decode-va-api).
- - **12-bit formats (`yuv422p12le`, `yuv444p12le`, `gbrp12le`) require both MTL and FFmpeg to be built from the pinned commit above.** The plugin sources are copied into FFmpeg's `libavdevice/` and compiled in, so rebuilding MTL alone is not enough — FFmpeg must be rebuilt against the same MTL commit or these formats are rejected at session setup.
+ - **12-bit formats (`yuv422p12le`, `yuv444p12le`, `gbrp12le`) require both MTL and FFmpeg to be built from the pinned v26.09 commit above.** The plugin sources are copied into FFmpeg's `libavdevice/` and compiled in, so rebuilding MTL alone is not enough — FFmpeg must be rebuilt against the same MTL commit or these formats are rejected at session setup.
- At the pinned commit the `mtl_st20p` muxer exposes `p_port`/`r_port` plus `p2_port`..`p7_port` (and matching `p2_sip`..`p7_sip`), so up to 8 NICs work with the default (non-`ENABLE_MTL_TX`) build with no local patching. Older MTL revisions exposed only `p_port`/`r_port`, which capped `nic_count` at 2 for the FFmpeg TX path; the `ENABLE_MTL_TX` direct-pipeline build has always supported up to 8 NICs.
### Build Steps
@@ -165,15 +168,15 @@ To build with MTL TX support enabled:
bash scripts/build.sh -Denable_mtl_tx=true
```
-The built binary will be available at `build/dvledtx`.
+The build produces `build/dvledtx/dvledtx` and `build/dvledrx/dvledrx`.
## Usage
### Binding Ethernet Controller to DPDK PMD and Hugepage Setup
- Ensure VFIO group exists [follow](#vfio-group-setup)
-- [DPDK PMD Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/run.md#3-dpdk-pmd-setup)
-- [Hugepage Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/7bee24231c864d12da6db2c2ffbfd3986717af5a/doc/run.md#4-setup-hugepage)
+- [DPDK PMD Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/run.md#3-dpdk-pmd-setup)
+- [Hugepage Setup](https://github.com/OpenVisualCloud/Media-Transport-Library/blob/9762c314ed24bd628d3f808b95a94c3aa8525723/doc/run.md#4-setup-hugepage)
### JSON Configuration
@@ -204,7 +207,7 @@ dvledtx uses a JSON config file with the following sections:
| | `payload_type` | (Optional) RTP payload type — defaults to `96` if not present |
| | `crop` | Region to transmit: `x`, `y`, `w`, `h` in pixels |
-#### Example: MP4 file input (`config/tx_fullhd_single_session.json`)
+#### Example: MP4 file input (`dvledtx/config/tx_fullhd_single_session.json`)
```json
{
"log_file": "dvledtx.log",
@@ -228,7 +231,7 @@ dvledtx uses a JSON config file with the following sections:
}
```
-Multiple sessions can be defined in `tx_sessions` to transmit different crop regions of the same video simultaneously (see `config/tx_fullhd_multi_session.json`).
+Multiple sessions can be defined in `tx_sessions` to transmit different crop regions of the same video simultaneously (see `dvledtx/config/tx_fullhd_multi_session.json`).
#### PTP Timing
@@ -422,7 +425,7 @@ Your physical keyboard and mouse now control the virtual display directly.
> DISPLAY=:99 PULSE_SERVER=unix:/run/user/$(id -u)/pulse/native google-chrome &
> ```
-#### Example: screen-capture input (`config/tx_fullhd_screen_capture.json`)
+#### Example: screen-capture input (`dvledtx/config/tx_fullhd_screen_capture.json`)
```json
{
@@ -445,7 +448,7 @@ Your physical keyboard and mouse now control the virtual display directly.
}
```
-#### Example: multi-session screen capture (`config/tx_fullhd_screen_capture_multi_session.json`)
+#### Example: multi-session screen capture (`dvledtx/config/tx_fullhd_screen_capture_multi_session.json`)
Splits a 1920×1080 screen capture into 3 vertical strips, each transmitted as a separate ST2110 session:
@@ -476,7 +479,7 @@ Splits a 1920×1080 screen capture into 3 vertical strips, each transmitted as a
Run dvledtx as usual — `x11grab` will capture whatever is rendered on the display (desktop, windows, applications) and transmit it, exactly as it would for a physical display:
```bash
-./build/dvledtx --config config/tx_fullhd_screen_capture.json
+./build/dvledtx/dvledtx --config dvledtx/config/tx_fullhd_screen_capture.json
```
**Tear down** the virtual display when done:
@@ -485,7 +488,7 @@ pkill -f "gnome-session --session=ubuntu"
sudo pkill -f "Xorg :99"
```
-`interfaces[]` supports up to 8 NICs (MTL's port limit); each `tx_sessions[]` entry picks its NIC via `nic_index` (see `config/tx_fullhd_multi_nic.json` for an 8-NIC/8-session example). Using more than 2 NICs with the default FFmpeg TX path requires the `p2_port`..`p7_port` AVOptions described above, which are present at the pinned MTL commit.
+`interfaces[]` supports up to 8 NICs (MTL's port limit); each `tx_sessions[]` entry picks its NIC via `nic_index` (see `dvledtx/config/tx_fullhd_multi_nic.json` for an 8-NIC/8-session example). Using more than 2 NICs with the default FFmpeg TX path requires the `p2_port`..`p7_port` AVOptions described above, which are present at the pinned MTL commit.
## Logging
@@ -517,7 +520,7 @@ When `log_file` is set, log output is written to that file in addition to the co
#### Using JSON Configuration (recommended)
```bash
-./build/dvledtx --config config/tx_fullhd_single_session.json
+./build/dvledtx/dvledtx --config dvledtx/config/tx_fullhd_single_session.json
```
## Command-Line Options
@@ -530,9 +533,9 @@ When `log_file` is set, log output is written to that file in addition to the co
#### Show Version
```bash
-./build/dvledtx --version
+./build/dvledtx/dvledtx --version
# or
-./build/dvledtx -v
+./build/dvledtx/dvledtx -v
```
## Supported Formats
@@ -618,7 +621,7 @@ sudo apt install libcmocka-dev
pip install gcovr
```
-To build and run all unit tests with coverage:
+To build and run both the transmitter and receiver unit tests with coverage:
```bash
bash scripts/test.sh
@@ -730,7 +733,7 @@ sudo pkill -9 -f dvledtx
# Check MTL pkg-config file is available
pkg-config --modversion mtl
- # Expected: 26.01.0.REL (or higher)
+ # Expected: 26.09.0.REL
# Check MTL header files exist
ls /usr/local/include/mtl/mtl_api.h
diff --git a/dvledrx/config/rx_2k_1session.json b/dvledrx/config/rx_2k_1session.json
new file mode 100644
index 0000000..5829c27
--- /dev/null
+++ b/dvledrx/config/rx_2k_1session.json
@@ -0,0 +1,24 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ {
+ "name": "0000:03:00.1",
+ "SIP": "192.168.50.65",
+ "DIP": "239.168.85.20"
+ }
+ ],
+ "video": {
+ "width": 2560,
+ "height": 1440,
+ "fps": 30,
+ "fmt": "yuv422p10le"
+ },
+ "rx_sessions": [
+ {
+ "udp_port": 20000,
+ "payload_type": 96,
+ "crop": { "x": 0, "y": 0, "w": 2560, "h": 1440 }
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_4k_1session.json b/dvledrx/config/rx_4k_1session.json
new file mode 100644
index 0000000..071014f
--- /dev/null
+++ b/dvledrx/config/rx_4k_1session.json
@@ -0,0 +1,24 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ {
+ "name": "0000:03:00.1",
+ "SIP": "192.168.50.65",
+ "DIP": "239.168.85.20"
+ }
+ ],
+ "video": {
+ "width": 3840,
+ "height": 2160,
+ "fps": 30,
+ "fmt": "gbrp12le"
+ },
+ "rx_sessions": [
+ {
+ "udp_port": 20000,
+ "payload_type": 96,
+ "crop": { "x": 0, "y": 0, "w": 3840, "h": 2160 }
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_4k_2nic_2sessions_per_output.json b/dvledrx/config/rx_4k_2nic_2sessions_per_output.json
new file mode 100644
index 0000000..7d3259a
--- /dev/null
+++ b/dvledrx/config/rx_4k_2nic_2sessions_per_output.json
@@ -0,0 +1,33 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "tasklet_sleep": true,
+ "interfaces": [
+ { "nic_index": 0, "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" },
+ { "nic_index": 1, "name": "0000:03:00.1", "sip": "192.168.50.66", "dip": "239.168.85.21" }
+ ],
+ "video": {
+ "fps": 30,
+ "fmt": "yuv422p10le"
+ },
+ "rx_sessions": [
+ { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 1920, "h": 2160 } },
+ { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 1920, "y": 0, "w": 1920, "h": 2160 } }
+ ],
+ "displays": [
+ {
+ "output": "DP-1",
+ "sessions": [
+ { "session": 0, "x": 0, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 },
+ { "session": 1, "x": 540, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 }
+ ]
+ },
+ {
+ "output": "HDMI-2",
+ "sessions": [
+ { "session": 0, "x": 0, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 },
+ { "session": 1, "x": 540, "y": 0, "w": 1920, "h": 2160, "scale_w": 540, "scale_h": 1920 }
+ ]
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_8nic_8sessions.json b/dvledrx/config/rx_8nic_8sessions.json
new file mode 100644
index 0000000..1ee6844
--- /dev/null
+++ b/dvledrx/config/rx_8nic_8sessions.json
@@ -0,0 +1,30 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ { "nic_index": 0, "name": "0000:03:10.1", "sip": "192.168.50.60", "dip": "239.168.85.30" },
+ { "nic_index": 1, "name": "0000:03:10.3", "sip": "192.168.50.61", "dip": "239.168.85.31" },
+ { "nic_index": 2, "name": "0000:03:10.5", "sip": "192.168.50.62", "dip": "239.168.85.32" },
+ { "nic_index": 3, "name": "0000:03:10.7", "sip": "192.168.50.63", "dip": "239.168.85.33" },
+ { "nic_index": 4, "name": "0000:03:11.1", "sip": "192.168.50.64", "dip": "239.168.85.34" },
+ { "nic_index": 5, "name": "0000:03:11.3", "sip": "192.168.50.65", "dip": "239.168.85.35" },
+ { "nic_index": 6, "name": "0000:03:11.5", "sip": "192.168.50.66", "dip": "239.168.85.36" },
+ { "nic_index": 7, "name": "0000:03:11.7", "sip": "192.168.50.67", "dip": "239.168.85.37" }
+ ],
+ "video": {
+ "width": 1920,
+ "height": 1080,
+ "fps": 30,
+ "fmt": "yuv422p10le"
+ },
+ "rx_sessions": [
+ { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 240, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 2, "udp_port": 20004, "payload_type": 96, "crop": { "x": 480, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 3, "udp_port": 20006, "payload_type": 96, "crop": { "x": 720, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 4, "udp_port": 20008, "payload_type": 96, "crop": { "x": 960, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 5, "udp_port": 20010, "payload_type": 96, "crop": { "x": 1200, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 6, "udp_port": 20012, "payload_type": 96, "crop": { "x": 1440, "y": 0, "w": 240, "h": 1080 } },
+ { "nic_index": 7, "udp_port": 20014, "payload_type": 96, "crop": { "x": 1680, "y": 0, "w": 240, "h": 1080 } }
+ ]
+}
diff --git a/dvledrx/config/rx_crop_1session.json b/dvledrx/config/rx_crop_1session.json
new file mode 100644
index 0000000..b346b62
--- /dev/null
+++ b/dvledrx/config/rx_crop_1session.json
@@ -0,0 +1,24 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ {
+ "name": "0000:06:00.0",
+ "SIP": "192.168.50.65",
+ "DIP": "239.168.85.20"
+ }
+ ],
+ "video": {
+ "width": 1920,
+ "height": 1080,
+ "fps": 30,
+ "fmt": "yuv444p12le"
+ },
+ "rx_sessions": [
+ {
+ "udp_port": 20000,
+ "payload_type": 96,
+ "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 }
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_crop_3sessions.json b/dvledrx/config/rx_crop_3sessions.json
new file mode 100644
index 0000000..bd52d2e
--- /dev/null
+++ b/dvledrx/config/rx_crop_3sessions.json
@@ -0,0 +1,34 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ {
+ "name": "0000:06:00.0",
+ "SIP": "192.168.50.65",
+ "DIP": "239.168.85.20"
+ }
+ ],
+ "video": {
+ "width": 1920,
+ "height": 1080,
+ "fps": 30,
+ "fmt": "gbrp12le"
+ },
+ "rx_sessions": [
+ {
+ "udp_port": 20000,
+ "payload_type": 96,
+ "crop": { "x": 0, "y": 0, "w": 640, "h": 1080 }
+ },
+ {
+ "udp_port": 20002,
+ "payload_type": 96,
+ "crop": { "x": 640, "y": 0, "w": 640, "h": 1080 }
+ },
+ {
+ "udp_port": 20004,
+ "payload_type": 96,
+ "crop": { "x": 1280, "y": 0, "w": 640, "h": 1080 }
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_crop_3sessions_per_output.json b/dvledrx/config/rx_crop_3sessions_per_output.json
new file mode 100644
index 0000000..11c7cf4
--- /dev/null
+++ b/dvledrx/config/rx_crop_3sessions_per_output.json
@@ -0,0 +1,34 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ { "nic_index": 0, "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" },
+ { "nic_index": 1, "name": "0000:03:00.1", "sip": "192.168.50.66", "dip": "239.168.85.21" }
+ ],
+ "video": {
+ "width": 1920,
+ "height": 1080,
+ "fps": 30,
+ "fmt": "yuv422p10le"
+ },
+ "rx_sessions": [
+ { "nic_index": 0, "udp_port": 20000, "payload_type": 96, "crop": { "x": 0, "y": 0, "w": 960, "h": 1080 } },
+ { "nic_index": 1, "udp_port": 20002, "payload_type": 96, "crop": { "x": 960, "y": 0, "w": 960, "h": 1080 } }
+ ],
+ "displays": [
+ {
+ "output": "DP-1",
+ "sessions": [
+ { "session": 0, "x": 0, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 },
+ { "session": 1, "x": 540, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 }
+ ]
+ },
+ {
+ "output": "HDMI-2",
+ "sessions": [
+ { "session": 0, "x": 0, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 },
+ { "session": 1, "x": 540, "y": 0, "w": 960, "h": 1080, "scale_w": 540, "scale_h": 1920 }
+ ]
+ }
+ ]
+}
diff --git a/dvledrx/config/rx_fullhd_1session.json b/dvledrx/config/rx_fullhd_1session.json
new file mode 100644
index 0000000..0861346
--- /dev/null
+++ b/dvledrx/config/rx_fullhd_1session.json
@@ -0,0 +1,24 @@
+{
+ "log_file": "RxApp.log",
+ "log_level": "info",
+ "interfaces": [
+ {
+ "name": "0000:03:00.1",
+ "SIP": "192.168.50.65",
+ "DIP": "239.168.85.20"
+ }
+ ],
+ "video": {
+ "width": 960,
+ "height": 1080,
+ "fps": 30,
+ "fmt": "gbrp10le"
+ },
+ "rx_sessions": [
+ {
+ "udp_port": 20000,
+ "payload_type": 96,
+ "crop": { "x": 0, "y": 0, "w": 960, "h": 1080 }
+ }
+ ]
+}
diff --git a/dvledrx/fuzz/corpus/displays_mirrors.json b/dvledrx/fuzz/corpus/displays_mirrors.json
new file mode 100644
index 0000000..a2ec897
--- /dev/null
+++ b/dvledrx/fuzz/corpus/displays_mirrors.json
@@ -0,0 +1 @@
+{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","SIP":"192.168.50.65","DIP":"239.168.85.20"}],"video":{"width":1920,"height":1080,"fps":30,"fmt":"gbrp10le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":1080}}],"displays":[{"output":"DP-1","sessions":[{"session":0,"x":0,"y":0,"w":960,"h":1080,"scale_w":960,"scale_h":1080}]},{"output":"HDMI-1","sessions":[{"session":0,"x":960,"y":0,"w":960,"h":1080,"scale_w":960,"scale_h":1080}]}]}
diff --git a/dvledrx/fuzz/corpus/malformed.json b/dvledrx/fuzz/corpus/malformed.json
new file mode 100644
index 0000000..9261d08
--- /dev/null
+++ b/dvledrx/fuzz/corpus/malformed.json
@@ -0,0 +1 @@
+{"interfaces":[{"nic_index":0,"name":"unterminated","SIP":,"DIP":"239.1.1.1"}],"rx_sessions":[}
diff --git a/dvledrx/fuzz/corpus/multi_session.json b/dvledrx/fuzz/corpus/multi_session.json
new file mode 100644
index 0000000..7819bff
--- /dev/null
+++ b/dvledrx/fuzz/corpus/multi_session.json
@@ -0,0 +1 @@
+{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","sip":"192.168.50.65","dip":"239.168.85.20"},{"nic_index":1,"name":"0000:03:00.1","sip":"192.168.50.66","dip":"239.168.85.21"}],"video":{"width":3840,"height":2160,"fps":60,"fmt":"yuv422p12le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":2160}},{"nic_index":1,"udp_port":20002,"payload_type":96,"crop":{"x":1920,"y":0,"w":1920,"h":2160}}]}
diff --git a/dvledrx/fuzz/corpus/valid_single.json b/dvledrx/fuzz/corpus/valid_single.json
new file mode 100644
index 0000000..6ed9f7d
--- /dev/null
+++ b/dvledrx/fuzz/corpus/valid_single.json
@@ -0,0 +1 @@
+{"interfaces":[{"nic_index":0,"name":"0000:03:00.0","sip":"192.168.50.65","dip":"239.168.85.20"}],"video":{"width":1920,"height":1080,"fps":30,"fmt":"yuv422p10le"},"rx_sessions":[{"nic_index":0,"udp_port":20000,"payload_type":96,"crop":{"x":0,"y":0,"w":1920,"h":1080}}]}
diff --git a/dvledrx/fuzz/fuzz_config_reader.c b/dvledrx/fuzz/fuzz_config_reader.c
new file mode 100644
index 0000000..032aa91
--- /dev/null
+++ b/dvledrx/fuzz/fuzz_config_reader.c
@@ -0,0 +1,55 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * AFL++ harness for the receiver JSON config parser.
+ * Build: afl-clang-fast -g -O1 -I../include fuzz_config_reader.c \
+ * ../src/config_reader.c ../src/logger.c -pthread \
+ * $(pkg-config --cflags --libs json-c zlib) -o fuzz_config_reader
+ * Run: afl-fuzz -i corpus/ -o findings/ -- ./fuzz_config_reader @@
+ */
+
+#include "fuzz_config_reader_common.h"
+
+#include
+#include
+
+#ifdef __AFL_HAVE_MANUAL_CONTROL
+__AFL_FUZZ_INIT();
+#endif
+
+static void fuzz_file(const char* filename) {
+ FILE* input = fopen(filename, "rb");
+ if (!input)
+ return;
+
+ uint8_t* data = malloc(RX_FUZZ_MAX_INPUT + 1u);
+ if (!data) {
+ fclose(input);
+ return;
+ }
+
+ size_t size = fread(data, 1, RX_FUZZ_MAX_INPUT + 1u, input);
+ if (!ferror(input) && size <= RX_FUZZ_MAX_INPUT)
+ rx_fuzz_one_input(data, size);
+
+ free(data);
+ fclose(input);
+}
+
+int main(int argc, char** argv) {
+#ifdef __AFL_HAVE_MANUAL_CONTROL
+ __AFL_INIT();
+ unsigned char* data = __AFL_FUZZ_TESTCASE_BUF;
+ while (__AFL_LOOP(1000)) {
+ size_t size = __AFL_FUZZ_TESTCASE_LEN;
+ rx_fuzz_one_input(data, size);
+ }
+#else
+ if (argc != 2) {
+ fprintf(stderr, "Usage: %s \n", argv[0]);
+ return 1;
+ }
+ fuzz_file(argv[1]);
+#endif
+ return 0;
+}
diff --git a/dvledrx/fuzz/fuzz_config_reader_common.h b/dvledrx/fuzz/fuzz_config_reader_common.h
new file mode 100644
index 0000000..a776517
--- /dev/null
+++ b/dvledrx/fuzz/fuzz_config_reader_common.h
@@ -0,0 +1,51 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#pragma once
+
+#include "config_reader.h"
+#include "logger.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#define RX_FUZZ_MAX_INPUT (1u << 20)
+
+static inline void rx_fuzz_one_input(const uint8_t* data, size_t size) {
+ if ((!data && size != 0) || size > RX_FUZZ_MAX_INPUT)
+ return;
+
+ char path[] = "/tmp/dvledrx_fuzz_XXXXXX";
+ int fd = mkstemp(path);
+ if (fd < 0)
+ return;
+
+ size_t written = 0;
+ while (written < size) {
+ ssize_t rc = write(fd, data + written, size - written);
+ if (rc < 0 && errno == EINTR)
+ continue;
+ if (rc <= 0) {
+ close(fd);
+ unlink(path);
+ return;
+ }
+ written += (size_t)rc;
+ }
+
+ if (close(fd) != 0) {
+ unlink(path);
+ return;
+ }
+
+ struct rx_config config;
+ logger_set_level(LOG_LEVEL_ERROR);
+ if (parse_rx_config(path, &config) == 0)
+ (void)validate_rx_config(&config);
+
+ unlink(path);
+}
diff --git a/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c b/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c
new file mode 100644
index 0000000..dcc84b8
--- /dev/null
+++ b/dvledrx/fuzz/fuzz_config_reader_libfuzzer.c
@@ -0,0 +1,12 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * libFuzzer harness for the receiver JSON config parser and validator.
+ */
+
+#include "fuzz_config_reader_common.h"
+
+int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
+ rx_fuzz_one_input(data, size);
+ return 0;
+}
diff --git a/dvledrx/include/config_reader.h b/dvledrx/include/config_reader.h
new file mode 100644
index 0000000..0c846d1
--- /dev/null
+++ b/dvledrx/include/config_reader.h
@@ -0,0 +1,94 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#pragma once
+#include
+#include
+#include
+
+#define MAX_INTERFACES 8 /* MTL_PORT_MAX */
+#define MAX_RX_SESSIONS 16
+#define MAX_MIRRORS 4 /* extra outputs one session can be shown on */
+
+/* Validation limits */
+#define MAX_VIDEO_DIM 3840 /* either orientation, e.g. 3840x2160 or 2160x3840 */
+#define MAX_VIDEO_PIXELS (3840 * 2160) /* 4K UHD */
+#define MAX_FPS 60
+#define NIC_LINK_MBPS 10000 /* warn above this per NIC */
+
+/* Interface configuration (from JSON "interfaces" array) */
+struct interface_config {
+ int nic_index; /* id referenced by rx_sessions[].nic_index */
+ char name[64]; /* PCI address, e.g. "0000:02:00.0" */
+ char sip[32]; /* Source IP of this NIC */
+ char dip[32]; /* Multicast destination IP */
+};
+
+/* Crop region within the full video frame */
+struct crop_config {
+ int x; /* Horizontal offset in pixels */
+ int y; /* Vertical offset in pixels */
+ int w; /* Width in pixels */
+ int h; /* Height in pixels */
+};
+
+/* Additional placement of a session on another output (from "displays") */
+struct display_placement {
+ char output[32];
+ struct crop_config window;
+ int scale_w;
+ int scale_h;
+};
+
+/* Per-stream RX session configuration (from JSON "rx_sessions" array) */
+struct rx_session_config {
+ int nic_index; /* interface this stream arrives on (default 0) */
+ int udp_port; /* UDP port for this stream */
+ int payload_type; /* RTP payload type */
+ struct crop_config crop; /* Crop region within the full frame */
+ char output[32]; /* XRandR output name, e.g. "HDMI-1" (empty = by index) */
+ bool has_window; /* "window" given: fixed-size window instead of fullscreen */
+ struct crop_config window; /* x/y relative to the output; w/h 0 = crop size */
+ int scale_w; /* window/video size; overrides window.w/h (0 = unset) */
+ int scale_h;
+ bool display_assigned; /* placed by the top-level "displays" section */
+ struct display_placement mirrors[MAX_MIRRORS]; /* 2nd+ placements from "displays" */
+ int num_mirrors;
+};
+
+/* Video parameters (from JSON "video" object) */
+struct video_config {
+ int width; /* Full source video width */
+ int height; /* Full source video height */
+ int fps; /* Frames per second */
+ char fmt[32]; /* Pixel format, e.g. "yuv422p10le" */
+};
+
+/* Top-level configuration parsed from the JSON file */
+struct rx_config {
+ struct interface_config interfaces[MAX_INTERFACES];
+ int num_interfaces;
+
+ struct video_config video;
+
+ struct rx_session_config rx_sessions[MAX_RX_SESSIONS];
+ int num_rx_sessions;
+
+ char log_file[256]; /* Optional log file path (empty = console) */
+ char log_level[16]; /* Optional log level: debug|info|warn|error */
+ char display_mode[16]; /* "composite" (default) | "per_output" */
+ bool tasklet_sleep; /* let the MTL scheduler sleep when idle (saves a busy core) */
+};
+
+/* Parse JSON configuration file into rx_config. Returns 0 on success. */
+int parse_rx_config(const char* config_file, struct rx_config* config);
+
+/* Validate a parsed configuration. Returns 0 on success. */
+int validate_rx_config(const struct rx_config* config);
+
+/* Convenience: parse + validate in one call. */
+int load_and_validate_rx_config(const char* config_file, struct rx_config* config);
+
+/* Position in config->interfaces (== MTL port index) of nic_index, or -1. */
+int rx_config_port_for_nic(const struct rx_config* config, int nic_index);
diff --git a/dvledrx/include/frame_converter.h b/dvledrx/include/frame_converter.h
new file mode 100644
index 0000000..70d7649
--- /dev/null
+++ b/dvledrx/include/frame_converter.h
@@ -0,0 +1,25 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#pragma once
+#include
+#include
+
+// Forward declaration for st_frame
+struct st_frame;
+
+// Common YUV to RGB conversion functions
+void yuv_to_rgb(uint8_t y, uint8_t u, uint8_t v, uint8_t* r, uint8_t* g, uint8_t* b);
+
+// Get bit shift value for format
+int get_format_bit_shift(int format);
+
+// Check if format uses YUV422 subsampling
+bool is_yuv422_format(int format);
+
+// X11 display conversion function
+int convert_frame_to_rgb(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t width, uint32_t height);
+
+// Nearest-neighbour resize fused with conversion (YUV422/YUV444/GBR planar only; -1 otherwise)
+int convert_frame_to_rgb_scaled(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t dst_w, uint32_t dst_h);
diff --git a/dvledrx/include/logger.h b/dvledrx/include/logger.h
new file mode 100644
index 0000000..f0cfc0e
--- /dev/null
+++ b/dvledrx/include/logger.h
@@ -0,0 +1,52 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+enum log_level {
+ LOG_LEVEL_DEBUG = 0,
+ LOG_LEVEL_INFO,
+ LOG_LEVEL_WARN,
+ LOG_LEVEL_ERROR,
+};
+
+void logger_set_level(enum log_level level);
+enum log_level logger_get_level(void);
+
+/* Initialize log level from RXAPP_LOG_LEVEL if present. */
+void logger_init_from_env(void);
+
+#define LOGGER_MAX_BYTES (20u * 1024u * 1024u) /* rotate the log file at this size */
+#define LOGGER_MAX_ARCHIVES 10 /* keep .1.gz .. .10.gz */
+
+/* Direct log output to a file. When file_path is NULL or empty, logs go
+ * to stderr (console). When the file reaches max_bytes it is gzip-compressed
+ * in the background to .1.gz (older archives shift up, the oldest beyond
+ * LOGGER_MAX_ARCHIVES is deleted) and a new file is started. Unsafe paths
+ * (see logger_path_is_safe) are refused and logging stays on the console. */
+void logger_set_file(const char* file_path, size_t max_bytes);
+
+/* True when an output file may be created at path: its directory and every
+ * parent are not world-writable (rejects /tmp, /var/tmp, /dev/shm) and are
+ * owned by root or the invoking user, and path is not a symlink or special
+ * file. On false, a reason is written to why. */
+bool logger_path_is_safe(const char* path, char* why, size_t why_len);
+
+/* Close the log file (if any) and revert to console output. */
+void logger_close(void);
+
+/* Parse string level (debug|info|warn|error). Returns true on success. */
+bool logger_parse_level(const char* level_str, enum log_level* out_level);
+
+void logger_vlog(enum log_level level, const char* tag, const char* fmt, va_list args);
+void logger_log(enum log_level level, const char* tag, const char* fmt, ...);
+
+#define log_debug(tag, fmt, ...) logger_log(LOG_LEVEL_DEBUG, (tag), (fmt), ##__VA_ARGS__)
+#define log_info(tag, fmt, ...) logger_log(LOG_LEVEL_INFO, (tag), (fmt), ##__VA_ARGS__)
+#define log_warn(tag, fmt, ...) logger_log(LOG_LEVEL_WARN, (tag), (fmt), ##__VA_ARGS__)
+#define log_error(tag, fmt, ...) logger_log(LOG_LEVEL_ERROR, (tag), (fmt), ##__VA_ARGS__)
diff --git a/dvledrx/include/session_manager.h b/dvledrx/include/session_manager.h
new file mode 100644
index 0000000..3045884
--- /dev/null
+++ b/dvledrx/include/session_manager.h
@@ -0,0 +1,98 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include "mtl_api.h"
+#include "st_pipeline_api.h"
+#include "x11_handler.h"
+#include "config_reader.h"
+
+/* Application state shared by main and the session manager. */
+struct rx_app_context {
+ mtl_handle mtl;
+ uint32_t width; /* full video size */
+ uint32_t height;
+ enum st_fps fps;
+ enum st_frame_fmt fmt;
+ enum st20_fmt transport_fmt;
+ int st20p_sessions;
+ bool exit;
+ int test_time_s; /* 0 = run until stopped */
+ char config_file[256];
+ struct rx_config json_config;
+};
+
+/* ST20P RX session context */
+struct st20p_rx_ctx {
+ struct rx_app_context* app;
+ st20p_rx_handle handle;
+ pthread_t thread;
+ int idx;
+ size_t frame_size;
+ char session_name[32];
+
+ /* Frame statistics */
+ int frames_received;
+ int frames_rx_window; /* frames since the last per-second stats line (atomic) */
+
+ /* Per-session display (used when st20p_count == 1) */
+ struct x11_display_ctx display;
+
+ /* Same frames mirrored onto further outputs (per_output mode) */
+ struct x11_display_ctx mirror_display[MAX_MIRRORS];
+ int num_mirror_displays;
+
+ /* Composite display (used when st20p_count > 1, shared across sessions) */
+ struct composite_x11_ctx* composite;
+
+ /* Per-session crop region (from JSON config; valid when composite != NULL) */
+ struct crop_config crop;
+
+ /* Per-session network overrides (from JSON rx_sessions) */
+ uint16_t session_udp_port;
+ uint8_t session_payload_type;
+ char port_name[MTL_PORT_MAX_LEN]; /* NIC this session receives on */
+ uint8_t dip_addr[MTL_IP_ADDR_LEN]; /* multicast group on that NIC */
+
+ /* Decoupled display pipeline (single-session mode, composite == NULL).
+ * The RX thread hands the newest MTL frame to this dedicated thread, which
+ * converts/blits it and then returns it to MTL. A frame superseded before
+ * it is rendered is returned by the RX thread, so receive never stalls. */
+ pthread_t disp_thread;
+ bool disp_thread_started;
+ pthread_mutex_t disp_lock;
+ pthread_cond_t disp_cond;
+ struct st_frame* disp_pending; /* newest MTL frame awaiting render (owned until put) */
+ int disp_skipped; /* frames superseded before render (under disp_lock) */
+};
+
+/* Session Manager Interface */
+typedef struct {
+ mtl_handle mtl;
+ struct st20p_rx_ctx* st20p_sessions;
+ int st20p_count;
+ bool running;
+
+ /* Composite display (non-NULL in composite mode with st20p_count > 1) */
+ struct composite_x11_ctx* composite;
+} session_manager_t;
+
+/* Initialize MTL and create session manager */
+int session_manager_init(session_manager_t* manager, struct rx_app_context* app);
+
+/* Start all sessions */
+int session_manager_start(session_manager_t* manager, struct rx_app_context* app);
+
+/* Stop all sessions */
+void session_manager_stop(session_manager_t* manager);
+
+/* Cleanup session manager */
+void session_manager_cleanup(session_manager_t* manager);
+
+/* Check if sessions are running */
+bool session_manager_is_running(const session_manager_t* manager);
diff --git a/dvledrx/include/x11_handler.h b/dvledrx/include/x11_handler.h
new file mode 100644
index 0000000..035648e
--- /dev/null
+++ b/dvledrx/include/x11_handler.h
@@ -0,0 +1,149 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#ifndef X11_HANDLER_H
+#define X11_HANDLER_H
+
+#include
+#include
+#include
+#include
+
+// Forward declarations
+struct st_frame;
+struct crop_config;
+
+enum {
+ X11_WALL_NONE = 0, /* ordinary decorated window: Escape does nothing */
+ X11_WALL_FULLSCREEN, /* EWMH fullscreen on an output */
+ X11_WALL_FIXED, /* override-redirect window at a fixed spot on an output */
+};
+
+/* X11 Display context */
+struct x11_display_ctx {
+ void* display; /* Display* - X11 display connection */
+ void* window; /* Window - X11 window handle */
+ void* gc; /* GC - Graphics context */
+ void* visual; /* Visual* - X11 visual info */
+ void* ximage; /* XImage* - X11 image buffer */
+ int screen; /* Screen number */
+ uint32_t width; /* Window width */
+ uint32_t height; /* Window height */
+ uint32_t frame_width; /* Latest incoming frame width */
+ uint32_t frame_height; /* Latest incoming frame height */
+ int depth; /* Color depth */
+ unsigned long colormap; /* Colormap of a 32-bit ARGB window, else 0 */
+ char* image_data; /* Image buffer */
+ char* frame_scratch; /* Frame-sized intermediate BGRA buffer */
+ size_t frame_scratch_cap; /* Allocated bytes in frame_scratch */
+ void* shminfo; /* XShmSegmentInfo* when image_data is MIT-SHM backed, else NULL */
+ bool use_shm; /* Try MIT-SHM when (re)allocating the image buffer */
+ uint32_t frame_counter;
+ char output_name[32]; /* XRandR output the window is on, or "(window)" */
+ int out_x, out_y; /* window position relative to that output */
+ int wall_kind; /* X11_WALL_* : how the window is shown in wall mode */
+ int mon_x, mon_y, mon_w, mon_h; /* output geometry in root coordinates */
+ uint32_t home_w, home_h; /* wall-mode window size */
+ int monitor_idx; /* Xinerama index of the output, -1 if none */
+ bool windowed; /* currently a normal decorated window (Escape toggles) */
+ bool wall_pending; /* waiting for the WM to release the window before going back */
+ long long pending_ms; /* when wall_pending was set (CLOCK_MONOTONIC) */
+ bool initialized; /* Initialization status */
+ pthread_mutex_t xlock; /* Protect Display* from concurrent RX/main-thread access */
+};
+
+/*
+ * Composite display context - single X11 window showing N side-by-side strips.
+ * All N session threads write their strip, hit a barrier, then session 0 flushes.
+ */
+struct composite_x11_ctx {
+ void* display; /* Display* */
+ void* window; /* Window (full composite width) */
+ void* gc; /* GC */
+ void* visual; /* Visual* */
+ void* ximage; /* XImage* (full_width × height) - native composite buffer */
+ int screen;
+ uint32_t full_width; /* native composite width (sum of crop regions) */
+ uint32_t strip_width; /* pixel width of one session's strip (legacy) */
+ uint32_t height; /* native composite height */
+ int depth;
+ char* image_data; /* full_width * height * 4 bytes (BGRA) - native composite buffer */
+ /* Per-frame barrier for the session threads; composite_abort() releases it for shutdown. */
+ pthread_mutex_t bar_lock;
+ pthread_cond_t bar_cond;
+ int bar_count; /* threads waiting in the current round */
+ unsigned bar_round;
+ bool bar_aborted;
+ pthread_mutex_t xlock; /* protect X11 Display* from concurrent access */
+ int num_sessions;
+ bool initialized;
+
+ /* Current on-screen window size. Starts equal to full_width/height, but
+ * diverges when the window manager resizes/fullscreens the window (e.g.
+ * user hits the fullscreen shortcut). When it differs, the native
+ * composite buffer is nearest-neighbour scaled into scaled_image_data /
+ * scaled_ximage before XPutImage, so the composite window fills the
+ * screen the same way a single-session window does. */
+ uint32_t win_width;
+ uint32_t win_height;
+ void* scaled_ximage; /* XImage* (win_width × win_height); NULL when win size == native size */
+ char* scaled_image_data; /* backing store for scaled_ximage */
+};
+
+/* Per-session X11 initialization and cleanup.
+ * fullscreen_on_output=false: normal window of width x height.
+ * fullscreen_on_output=true : borderless fullscreen window on the XRandR output
+ * named output_name (e.g. "HDMI-1"), or the session_idx-th active monitor
+ * when output_name is NULL/empty.
+ * window_rect (only with fullscreen_on_output): non-NULL gives a fixed borderless
+ * window of window_rect->w x h at (x, y) relative to the output, instead of fullscreen. */
+int init_x11_display(struct x11_display_ctx* display, uint32_t width, uint32_t height,
+ int session_idx, const char* output_name, bool fullscreen_on_output,
+ const struct crop_config* window_rect);
+void cleanup_x11_display(struct x11_display_ctx* display);
+
+/* Per-session frame display */
+void display_frame_x11(struct x11_display_ctx* display, struct st_frame* frame);
+
+/* Show src's last rendered image in dst when both windows are the same size.
+ * Returns false (nothing drawn) when sizes differ. */
+bool display_copy_x11(struct x11_display_ctx* dst, struct x11_display_ctx* src);
+
+/* Per-session event processing. Escape in any window toggles every wall window
+ * between wall mode and a normal window with title bar (minimize/maximize/close). */
+void process_x11_events(struct x11_display_ctx* display);
+
+/* True once the user clicked a window's close button. */
+bool x11_close_requested(void);
+
+/* Composite display initialization / cleanup.
+ * strip_width is the total display width (not per-strip). */
+int init_composite_x11_display(struct composite_x11_ctx* comp,
+ uint32_t strip_width, uint32_t height,
+ int num_sessions);
+void cleanup_composite_x11_display(struct composite_x11_ctx* comp);
+
+/* Release every session thread waiting at the composite barrier, now and from now on. */
+void composite_abort(struct composite_x11_ctx* comp);
+
+/*
+ * Write one strip to the composite buffer, wait at barrier, then
+ * session 0 calls XPutImage to flush the complete frame.
+ */
+void composite_write_strip(struct composite_x11_ctx* comp,
+ struct st_frame* frame, int session_idx);
+
+/*
+ * Write a frame into the composite buffer at an arbitrary crop position.
+ * The frame dimensions are crop->w × crop->h and it is placed at (crop->x, crop->y).
+ * After all sessions have called this, the serial-thread flushes the display.
+ */
+void composite_write_crop(struct composite_x11_ctx* comp,
+ struct st_frame* frame,
+ const struct crop_config* crop);
+
+/* Event processing for the composite window */
+void process_composite_x11_events(struct composite_x11_ctx* comp);
+
+#endif /* X11_HANDLER_H */
diff --git a/dvledrx/meson.build b/dvledrx/meson.build
new file mode 100644
index 0000000..6c9b3b7
--- /dev/null
+++ b/dvledrx/meson.build
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 Intel Corporation
+
+# This subdirectory defines the receiver application and its nested tests.
+rx_cc = meson.get_compiler('c')
+
+rx_x11_dep = dependency('x11', required: true)
+rx_xext_dep = dependency('xext', required: true)
+rx_xrandr_dep = dependency('xrandr', required: true)
+rx_json_c_dep = dependency('json-c', required: true)
+rx_zlib_dep = dependency('zlib', required: true)
+
+rx_c_args = [
+ '-DALLOW_EXPERIMENTAL_API',
+ '-DDVLEDRX_VERSION="' + meson.project_version() + '"',
+ '-D_FORTIFY_SOURCE=2',
+ '-O3',
+]
+foreach arg : ['-march=native', '-funroll-loops']
+ if rx_cc.has_argument(arg)
+ rx_c_args += [arg]
+ endif
+endforeach
+rx_mtl_include_args = ['-I/usr/local/include/mtl']
+rx_deps = [mtl_dep, m_dep, pthread_dep, rx_x11_dep, rx_xext_dep,
+ rx_xrandr_dep, rx_json_c_dep, rx_zlib_dep]
+
+executable('dvledrx',
+ sources: [
+ 'src/main.c',
+ 'src/frame_converter.c',
+ 'src/x11_handler.c',
+ 'src/config_reader.c',
+ 'src/session_manager.c',
+ 'src/logger.c',
+ ],
+ include_directories: include_directories('include'),
+ dependencies: rx_deps,
+ c_args: rx_c_args + rx_mtl_include_args,
+ link_args: ['-s'],
+ install: false
+)
+
+subdir('tests')
diff --git a/dvledrx/src/config_reader.c b/dvledrx/src/config_reader.c
new file mode 100644
index 0000000..04ff92d
--- /dev/null
+++ b/dvledrx/src/config_reader.c
@@ -0,0 +1,427 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include "config_reader.h"
+#include "logger.h"
+#include
+#include
+#include
+#include
+#include
+
+#define LOGGER_TAG "config"
+
+/* ------------------------------------------------------------------ */
+/* Helper: safely copy a JSON string value into a fixed-size buffer. */
+/* ------------------------------------------------------------------ */
+static void json_str_copy(json_object* obj, const char* key,
+ char* dst, size_t dst_size) {
+ json_object* val = NULL;
+ if (json_object_object_get_ex(obj, key, &val) &&
+ json_object_is_type(val, json_type_string)) {
+ strncpy(dst, json_object_get_string(val), dst_size - 1);
+ dst[dst_size - 1] = '\0';
+ }
+}
+
+/* Helper: get an integer from a JSON object (returns fallback on miss). */
+static int json_int_get(json_object* obj, const char* key, int fallback) {
+ json_object* val = NULL;
+ if (json_object_object_get_ex(obj, key, &val) &&
+ json_object_is_type(val, json_type_int)) {
+ return json_object_get_int(val);
+ }
+ return fallback;
+}
+
+/* ------------------------------------------------------------------ */
+/* parse_rx_config – read the JSON file and populate struct rx_config. */
+/* ------------------------------------------------------------------ */
+int parse_rx_config(const char* config_file, struct rx_config* config) {
+ if (!config_file || !config) return -1;
+
+ memset(config, 0, sizeof(*config));
+
+ json_object* root = json_object_from_file(config_file);
+ if (!root) {
+ log_error(LOGGER_TAG, "Cannot parse JSON config file %s", config_file);
+ return -1;
+ }
+
+ /* ---------- interfaces ---------- */
+ json_object* ifaces = NULL;
+ if (json_object_object_get_ex(root, "interfaces", &ifaces) &&
+ json_object_is_type(ifaces, json_type_array)) {
+ int n = json_object_array_length(ifaces);
+ if (n > MAX_INTERFACES) {
+ log_warn(LOGGER_TAG, "%d interfaces configured, only the first %d are used",
+ n, MAX_INTERFACES);
+ n = MAX_INTERFACES;
+ }
+ config->num_interfaces = n;
+ for (int i = 0; i < n; i++) {
+ json_object* item = json_object_array_get_idx(ifaces, i);
+ config->interfaces[i].nic_index = json_int_get(item, "nic_index", i);
+ json_str_copy(item, "name", config->interfaces[i].name,
+ sizeof(config->interfaces[i].name));
+ /* Accept both the RX ("SIP") and TX ("sip") spellings. */
+ json_str_copy(item, "SIP", config->interfaces[i].sip,
+ sizeof(config->interfaces[i].sip));
+ if (config->interfaces[i].sip[0] == '\0')
+ json_str_copy(item, "sip", config->interfaces[i].sip,
+ sizeof(config->interfaces[i].sip));
+ json_str_copy(item, "DIP", config->interfaces[i].dip,
+ sizeof(config->interfaces[i].dip));
+ if (config->interfaces[i].dip[0] == '\0')
+ json_str_copy(item, "dip", config->interfaces[i].dip,
+ sizeof(config->interfaces[i].dip));
+ }
+ }
+
+ /* ---------- video ---------- */
+ json_object* video = NULL;
+ if (json_object_object_get_ex(root, "video", &video) &&
+ json_object_is_type(video, json_type_object)) {
+ config->video.width = json_int_get(video, "width", 0);
+ config->video.height = json_int_get(video, "height", 0);
+ config->video.fps = json_int_get(video, "fps", 30);
+ json_str_copy(video, "fmt", config->video.fmt, sizeof(config->video.fmt));
+ }
+
+ /* ---------- log_file ---------- */
+ json_str_copy(root, "log_file", config->log_file, sizeof(config->log_file));
+ json_str_copy(root, "log_level", config->log_level, sizeof(config->log_level));
+ json_str_copy(root, "display_mode", config->display_mode, sizeof(config->display_mode));
+
+ json_object* sleep_obj = NULL;
+ if (json_object_object_get_ex(root, "tasklet_sleep", &sleep_obj))
+ config->tasklet_sleep = json_object_get_boolean(sleep_obj);
+
+ /* ---------- rx_sessions ---------- */
+ json_object* sessions = NULL;
+ if (json_object_object_get_ex(root, "rx_sessions", &sessions) &&
+ json_object_is_type(sessions, json_type_array)) {
+ int n = json_object_array_length(sessions);
+ if (n > MAX_RX_SESSIONS) {
+ log_warn(LOGGER_TAG, "%d rx_sessions configured, only the first %d are used",
+ n, MAX_RX_SESSIONS);
+ n = MAX_RX_SESSIONS;
+ }
+ config->num_rx_sessions = n;
+ for (int i = 0; i < n; i++) {
+ json_object* s = json_object_array_get_idx(sessions, i);
+ config->rx_sessions[i].nic_index = json_int_get(s, "nic_index", 0);
+ config->rx_sessions[i].udp_port = json_int_get(s, "udp_port", 20000);
+ config->rx_sessions[i].payload_type = json_int_get(s, "payload_type", 96);
+ json_str_copy(s, "output", config->rx_sessions[i].output,
+ sizeof(config->rx_sessions[i].output));
+
+ json_object* scale = NULL;
+ if (json_object_object_get_ex(s, "scale", &scale) &&
+ json_object_is_type(scale, json_type_object)) {
+ config->rx_sessions[i].scale_w = json_int_get(scale, "w", 0);
+ config->rx_sessions[i].scale_h = json_int_get(scale, "h", 0);
+ }
+
+ json_object* crop = NULL;
+ if (json_object_object_get_ex(s, "crop", &crop) &&
+ json_object_is_type(crop, json_type_object)) {
+ config->rx_sessions[i].crop.x = json_int_get(crop, "x", 0);
+ config->rx_sessions[i].crop.y = json_int_get(crop, "y", 0);
+ config->rx_sessions[i].crop.w = json_int_get(crop, "w", 0);
+ config->rx_sessions[i].crop.h = json_int_get(crop, "h", 0);
+ }
+
+ json_object* win = NULL;
+ if (json_object_object_get_ex(s, "window", &win) &&
+ json_object_is_type(win, json_type_object)) {
+ config->rx_sessions[i].has_window = true;
+ config->rx_sessions[i].window.x = json_int_get(win, "x", 0);
+ config->rx_sessions[i].window.y = json_int_get(win, "y", 0);
+ config->rx_sessions[i].window.w = json_int_get(win, "w", 0);
+ config->rx_sessions[i].window.h = json_int_get(win, "h", 0);
+ }
+ }
+ }
+
+ /* ---------- displays: output -> sessions placed on it ---------- */
+ json_object* displays = NULL;
+ if (json_object_object_get_ex(root, "displays", &displays) &&
+ json_object_is_type(displays, json_type_array)) {
+ if (config->display_mode[0] == '\0')
+ snprintf(config->display_mode, sizeof(config->display_mode), "per_output");
+ int nd = json_object_array_length(displays);
+ for (int d = 0; d < nd; d++) {
+ json_object* disp = json_object_array_get_idx(displays, d);
+ char output[32] = "";
+ json_str_copy(disp, "output", output, sizeof(output));
+
+ json_object* list = NULL;
+ if (!json_object_object_get_ex(disp, "sessions", &list) ||
+ !json_object_is_type(list, json_type_array))
+ continue;
+ int ns = json_object_array_length(list);
+ for (int k = 0; k < ns; k++) {
+ json_object* e = json_object_array_get_idx(list, k);
+ int idx = json_int_get(e, "session", -1);
+ if (idx < 0 || idx >= config->num_rx_sessions) {
+ log_error(LOGGER_TAG, "displays[%d] (%s) references unknown session %d",
+ d, output, idx);
+ json_object_put(root);
+ return -1;
+ }
+ struct rx_session_config* s = &config->rx_sessions[idx];
+ if (s->display_assigned) {
+ if (s->num_mirrors >= MAX_MIRRORS) {
+ log_error(LOGGER_TAG, "session %d is placed on more than %d displays",
+ idx, MAX_MIRRORS + 1);
+ json_object_put(root);
+ return -1;
+ }
+ struct display_placement* m = &s->mirrors[s->num_mirrors++];
+ snprintf(m->output, sizeof(m->output), "%s", output);
+ m->window.x = json_int_get(e, "x", 0);
+ m->window.y = json_int_get(e, "y", 0);
+ m->window.w = json_int_get(e, "w", 0);
+ m->window.h = json_int_get(e, "h", 0);
+ m->scale_w = json_int_get(e, "scale_w", 0);
+ m->scale_h = json_int_get(e, "scale_h", 0);
+ continue;
+ }
+ s->display_assigned = true;
+ snprintf(s->output, sizeof(s->output), "%s", output);
+ s->has_window = true;
+ s->window.x = json_int_get(e, "x", 0);
+ s->window.y = json_int_get(e, "y", 0);
+ s->window.w = json_int_get(e, "w", 0);
+ s->window.h = json_int_get(e, "h", 0);
+ s->scale_w = json_int_get(e, "scale_w", 0);
+ s->scale_h = json_int_get(e, "scale_h", 0);
+ }
+ }
+ }
+
+ json_object_put(root); /* free parsed tree */
+
+ /* video width/height are optional: default to the bounding box of all crops. */
+ if (config->video.width <= 0 || config->video.height <= 0) {
+ int max_x = 0, max_y = 0;
+ for (int i = 0; i < config->num_rx_sessions; i++) {
+ const struct crop_config* c = &config->rx_sessions[i].crop;
+ if (c->x + c->w > max_x) max_x = c->x + c->w;
+ if (c->y + c->h > max_y) max_y = c->y + c->h;
+ }
+ if (config->video.width <= 0) config->video.width = max_x > 0 ? max_x : 1920;
+ if (config->video.height <= 0) config->video.height = max_y > 0 ? max_y : 1080;
+ log_info(LOGGER_TAG, "video size not set, using %dx%d from session crops",
+ config->video.width, config->video.height);
+ }
+ if (config->video.fps == 0) config->video.fps = 30;
+ return 0;
+}
+
+/* Supported "fmt" names -> bits per pixel on the wire (ST 2110-20). */
+static int fmt_bits_per_pixel(const char* fmt) {
+ static const struct { const char* name; int bpp; } fmts[] = {
+ {"yuv422p10le", 20}, {"yuv422p12le", 24},
+ {"yuv444p10le", 30}, {"yuv444p12le", 36},
+ {"gbrp10le", 30}, {"gbrp12le", 36},
+ {"yuv420", 12},
+ };
+ if (!fmt || fmt[0] == '\0') return 20; /* default yuv422p10le */
+ for (size_t i = 0; i < sizeof(fmts) / sizeof(fmts[0]); i++)
+ if (strcmp(fmt, fmts[i].name) == 0) return fmts[i].bpp;
+ return -1;
+}
+
+static int check_window(const char* what, int idx, const char* output,
+ const struct crop_config* win, int scale_w, int scale_h) {
+ if (output && output[0] == '\0') {
+ log_error(LOGGER_TAG, "%s for session %d has no \"output\" name", what, idx);
+ return -1;
+ }
+ if (win->x < 0 || win->y < 0 || win->w < 0 || win->h < 0 ||
+ win->x > MAX_VIDEO_DIM || win->y > MAX_VIDEO_DIM ||
+ win->w > MAX_VIDEO_DIM || win->h > MAX_VIDEO_DIM) {
+ log_error(LOGGER_TAG, "%s for session %d has invalid window %d,%d %dx%d (0..%d)",
+ what, idx, win->x, win->y, win->w, win->h, MAX_VIDEO_DIM);
+ return -1;
+ }
+ if (scale_w < 0 || scale_h < 0 || scale_w > MAX_VIDEO_DIM || scale_h > MAX_VIDEO_DIM ||
+ (scale_w > 0) != (scale_h > 0)) {
+ log_error(LOGGER_TAG, "%s for session %d has invalid scale %dx%d "
+ "(set both, each 1..%d)", what, idx, scale_w, scale_h, MAX_VIDEO_DIM);
+ return -1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* validate_rx_config */
+/* ------------------------------------------------------------------ */
+int validate_rx_config(const struct rx_config* config) {
+ if (!config) return -1;
+
+ if (config->num_interfaces < 1) {
+ log_error(LOGGER_TAG, "At least one interface is required");
+ return -1;
+ }
+ for (int i = 0; i < config->num_interfaces; i++) {
+ const struct interface_config* ifc = &config->interfaces[i];
+ struct in_addr addr;
+ if (ifc->name[0] == '\0') {
+ log_error(LOGGER_TAG, "interfaces[%d] has no PCI name", i);
+ return -1;
+ }
+ if (inet_pton(AF_INET, ifc->sip, &addr) != 1) {
+ log_error(LOGGER_TAG, "interfaces[%d] (%s) has invalid source IP '%s'",
+ i, ifc->name, ifc->sip);
+ return -1;
+ }
+ if (ifc->dip[0] == '\0') {
+ log_error(LOGGER_TAG, "interfaces[%d] (%s) has no destination IP (dip)", i, ifc->name);
+ return -1;
+ }
+ if (inet_pton(AF_INET, ifc->dip, &addr) != 1) {
+ log_error(LOGGER_TAG, "interfaces[%d] (%s) has invalid destination IP '%s'",
+ i, ifc->name, ifc->dip);
+ return -1;
+ }
+ for (int j = 0; j < i; j++) {
+ if (config->interfaces[j].nic_index == ifc->nic_index) {
+ log_error(LOGGER_TAG, "interfaces[%d] and [%d] share nic_index %d",
+ j, i, ifc->nic_index);
+ return -1;
+ }
+ if (strcmp(config->interfaces[j].name, ifc->name) == 0) {
+ log_error(LOGGER_TAG, "interfaces[%d] and [%d] use the same NIC %s",
+ j, i, ifc->name);
+ return -1;
+ }
+ }
+ }
+ const struct video_config* v = &config->video;
+ if (v->width <= 0 || v->height <= 0 || v->width > MAX_VIDEO_DIM || v->height > MAX_VIDEO_DIM ||
+ (long)v->width * v->height > MAX_VIDEO_PIXELS) {
+ log_error(LOGGER_TAG, "Invalid video size %dx%d (max 4K: 3840x2160 or 2160x3840)",
+ v->width, v->height);
+ return -1;
+ }
+ static const int fps_ok[] = {24, 25, 29, 30, 50, 59, 60};
+ bool fps_valid = false;
+ for (size_t i = 0; i < sizeof(fps_ok) / sizeof(fps_ok[0]); i++)
+ fps_valid |= (v->fps == fps_ok[i]);
+ if (!fps_valid) {
+ log_error(LOGGER_TAG, "Invalid fps %d (use 24|25|29|30|50|59|60, max %d)", v->fps, MAX_FPS);
+ return -1;
+ }
+ int bpp = fmt_bits_per_pixel(v->fmt);
+ if (bpp < 0) {
+ log_error(LOGGER_TAG, "Unknown fmt '%s' (use yuv422p10le|yuv422p12le|yuv444p10le|"
+ "yuv444p12le|gbrp10le|gbrp12le|yuv420)", v->fmt);
+ return -1;
+ }
+ if (config->num_rx_sessions < 1) {
+ log_error(LOGGER_TAG, "At least one rx_session is required");
+ return -1;
+ }
+ if (config->display_mode[0] != '\0' &&
+ strcmp(config->display_mode, "composite") != 0 &&
+ strcmp(config->display_mode, "per_output") != 0) {
+ log_error(LOGGER_TAG, "Invalid display_mode '%s' (use composite|per_output)",
+ config->display_mode);
+ return -1;
+ }
+ double nic_mbps[MAX_INTERFACES] = {0};
+ for (int i = 0; i < config->num_rx_sessions; i++) {
+ const struct rx_session_config* s = &config->rx_sessions[i];
+ if (s->crop.w <= 0 || s->crop.h <= 0 || s->crop.x < 0 || s->crop.y < 0) {
+ log_error(LOGGER_TAG, "rx_session[%d] has invalid crop %d,%d %dx%d",
+ i, s->crop.x, s->crop.y, s->crop.w, s->crop.h);
+ return -1;
+ }
+ if (s->crop.x + s->crop.w > v->width || s->crop.y + s->crop.h > v->height) {
+ log_error(LOGGER_TAG, "rx_session[%d] crop %d,%d %dx%d is outside the %dx%d video",
+ i, s->crop.x, s->crop.y, s->crop.w, s->crop.h, v->width, v->height);
+ return -1;
+ }
+ if (s->udp_port < 1 || s->udp_port > 65535) {
+ log_error(LOGGER_TAG, "rx_session[%d] has invalid udp_port %d", i, s->udp_port);
+ return -1;
+ }
+ if (s->payload_type < 96 || s->payload_type > 127) {
+ log_error(LOGGER_TAG, "rx_session[%d] payload_type %d not in dynamic range 96..127",
+ i, s->payload_type);
+ return -1;
+ }
+ for (int j = 0; j < i; j++) {
+ if (config->rx_sessions[j].nic_index == s->nic_index &&
+ config->rx_sessions[j].udp_port == s->udp_port) {
+ log_error(LOGGER_TAG, "rx_session[%d] and [%d] both use nic_index %d udp_port %d",
+ j, i, s->nic_index, s->udp_port);
+ return -1;
+ }
+ }
+ int port = rx_config_port_for_nic(config, s->nic_index);
+ if (port < 0) {
+ log_error(LOGGER_TAG, "rx_session[%d] uses nic_index %d, which no interface has",
+ i, s->nic_index);
+ return -1;
+ }
+ /* +5% for RTP/UDP/IP/Ethernet headers (matches measured MTL rx rate). */
+ nic_mbps[port] += (double)s->crop.w * s->crop.h * bpp * v->fps * 1.05 / 1e6;
+
+ const struct crop_config no_window = {0, 0, 0, 0};
+ if (check_window("window", i, s->display_assigned ? s->output : NULL,
+ s->has_window ? &s->window : &no_window, s->scale_w, s->scale_h) < 0)
+ return -1;
+ for (int m = 0; m < s->num_mirrors; m++) {
+ const struct display_placement* pl = &s->mirrors[m];
+ if (check_window("mirror", i, pl->output, &pl->window, pl->scale_w, pl->scale_h) < 0)
+ return -1;
+ }
+ }
+ for (int p = 0; p < config->num_interfaces; p++) {
+ if (nic_mbps[p] > NIC_LINK_MBPS) {
+ log_warn(LOGGER_TAG, "interfaces[%d] (%s) needs ~%.0f Mb/s, above a %d Mb/s link",
+ p, config->interfaces[p].name, nic_mbps[p], NIC_LINK_MBPS);
+ }
+ }
+ if (config->log_file[0] != '\0') {
+ char why[512];
+ if (!logger_path_is_safe(config->log_file, why, sizeof(why))) {
+ log_error(LOGGER_TAG, "log_file '%s' rejected: %s", config->log_file, why);
+ return -1;
+ }
+ }
+
+ log_info(LOGGER_TAG, "Configuration validation passed (%d session%s, %d interface%s)",
+ config->num_rx_sessions,
+ config->num_rx_sessions > 1 ? "s - multi-display" : " - single display",
+ config->num_interfaces, config->num_interfaces > 1 ? "s" : "");
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* load_and_validate_rx_config */
+/* ------------------------------------------------------------------ */
+int load_and_validate_rx_config(const char* config_file, struct rx_config* config) {
+ if (!config_file || !config || config_file[0] == '\0') return -1;
+
+ if (parse_rx_config(config_file, config) != 0 ||
+ validate_rx_config(config) != 0) {
+ log_error(LOGGER_TAG, "Config file %s is invalid", config_file);
+ return -1;
+ }
+
+ log_info(LOGGER_TAG, "Loaded configuration from: %s", config_file);
+ return 0;
+}
+
+int rx_config_port_for_nic(const struct rx_config* config, int nic_index) {
+ for (int i = 0; i < config->num_interfaces; i++) {
+ if (config->interfaces[i].nic_index == nic_index) return i;
+ }
+ return -1;
+}
\ No newline at end of file
diff --git a/dvledrx/src/frame_converter.c b/dvledrx/src/frame_converter.c
new file mode 100644
index 0000000..d7fc2cb
--- /dev/null
+++ b/dvledrx/src/frame_converter.c
@@ -0,0 +1,373 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include "frame_converter.h"
+#include "logger.h"
+#include
+#include
+#include
+#include
+
+// Include MTL headers for st_frame definition
+#include "st_pipeline_api.h"
+
+#define LOGGER_TAG "converter"
+
+/*
+ * YUV/RGB conversion of received ST2110 frames into the BGRA buffers shown by
+ * the X11 display, with proper bit-depth scaling and chroma subsampling.
+ */
+
+static inline uint8_t clamp_u8(int val) {
+ return (val < 0) ? 0 : (val > 255) ? 255 : val;
+}
+
+void yuv_to_rgb(uint8_t y, uint8_t u, uint8_t v, uint8_t* r, uint8_t* g, uint8_t* b) {
+ // ITU-R BT.601 conversion (studio range)
+ int c = y - 16;
+ int d = u - 128;
+ int e = v - 128;
+ *r = clamp_u8((298 * c + 409 * e + 128) >> 8);
+ *g = clamp_u8((298 * c - 100 * d - 208 * e + 128) >> 8);
+ *b = clamp_u8((298 * c + 516 * d + 128) >> 8);
+}
+
+/* --- BT.601 conversion lookup tables -------------------------------------
+ * The per-pixel YUV->RGB math reduces to a few multiplies that depend only
+ * on the 8-bit Y/U/V inputs. Precomputing them as tables turns the inner
+ * loop into pure lookups + adds (no multiplies, no function call), which is
+ * the difference between keeping up with 30fps at 2560x1440 and not.
+ *
+ * r = 298*(Y-16) + 409*(V-128) + 128
+ * g = 298*(Y-16) - 100*(U-128) - 208*(V-128) + 128
+ * b = 298*(Y-16) + 516*(U-128) + 128
+ */
+static int g_yc_tab[256]; /* 298*(Y-16) + 128 (rounding folded in) */
+static int g_rv_tab[256]; /* 409*(V-128) */
+static int g_gu_tab[256]; /* -100*(U-128) */
+static int g_gv_tab[256]; /* -208*(V-128) */
+static int g_bu_tab[256]; /* 516*(U-128) */
+static pthread_once_t g_yuv_tabs_once = PTHREAD_ONCE_INIT;
+
+static void init_yuv_tables_impl(void) {
+ for (int i = 0; i < 256; i++) {
+ g_yc_tab[i] = 298 * (i - 16) + 128;
+ g_rv_tab[i] = 409 * (i - 128);
+ g_gu_tab[i] = -100 * (i - 128);
+ g_gv_tab[i] = -208 * (i - 128);
+ g_bu_tab[i] = 516 * (i - 128);
+ }
+}
+
+static void init_yuv_tables(void) {
+ pthread_once(&g_yuv_tabs_once, init_yuv_tables_impl);
+}
+
+int get_format_bit_shift(int format) {
+ switch (format) {
+ case ST_FRAME_FMT_YUV422PLANAR12LE:
+ case ST_FRAME_FMT_YUV444PLANAR12LE:
+ case ST_FRAME_FMT_GBRPLANAR12LE:
+ return 4; // 12-bit formats
+ case ST_FRAME_FMT_YUV422PLANAR10LE:
+ case ST_FRAME_FMT_YUV444PLANAR10LE:
+ case ST_FRAME_FMT_GBRPLANAR10LE:
+ return 2; // 10-bit formats
+ case ST_FRAME_FMT_YUV420PLANAR8: case ST_FRAME_FMT_YUV420CUSTOM8: default:
+ return 0; // 8-bit formats
+ }
+}
+
+bool is_yuv422_format(int format) {
+ return (format == ST_FRAME_FMT_YUV422PLANAR10LE || format == ST_FRAME_FMT_YUV422PLANAR12LE);
+}
+
+/* Convert frame to RGB format for X11 display */
+int convert_frame_to_rgb(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t width, uint32_t height) {
+ if (!frame || !rgb_buffer) {
+ log_error(LOGGER_TAG, "convert_frame_to_rgb - NULL pointer (frame=%p, buffer=%p)", frame, rgb_buffer);
+ return -1;
+ }
+
+ if (!frame->addr[0]) {
+ log_error(LOGGER_TAG, "convert_frame_to_rgb - No frame data (addr[0]=NULL)");
+ return -1;
+ }
+
+ if (frame->width != width || frame->height != height) {
+ log_error(LOGGER_TAG, "convert_frame_to_rgb - Size mismatch (frame=%dx%d, target=%dx%d)",
+ frame->width, frame->height, width, height);
+ return -1;
+ }
+
+ switch (frame->fmt) {
+ case ST_FRAME_FMT_BGRA: {
+ /* MTL already converted to BGRA — direct memcpy, no pixel conversion needed */
+ memcpy(rgb_buffer, frame->addr[0], (size_t)width * height * 4);
+ break;
+ }
+
+ case ST_FRAME_FMT_YUV422PLANAR10LE:
+ case ST_FRAME_FMT_YUV422PLANAR12LE: {
+ uint16_t* src_y = (uint16_t*)frame->addr[0];
+ uint16_t* src_u = (uint16_t*)frame->addr[1];
+ uint16_t* src_v = (uint16_t*)frame->addr[2];
+ int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) ? 4 : 2;
+
+ // Use linesize from frame (in bytes), convert to stride in pixels for 16-bit data
+ size_t y_stride = frame->linesize[0] / 2; // Y linesize in 16-bit pixels
+ size_t u_stride = frame->linesize[1] / 2; // U linesize in 16-bit pixels
+ size_t v_stride = frame->linesize[2] / 2; // V linesize in 16-bit pixels
+
+ init_yuv_tables();
+
+ for (uint32_t y = 0; y < height; y++) {
+ const uint16_t* yrow = src_y + (size_t)y * y_stride;
+ const uint16_t* urow = src_u + (size_t)y * u_stride;
+ const uint16_t* vrow = src_v + (size_t)y * v_stride;
+ uint8_t* drow = rgb_buffer + (size_t)y * width * 4;
+
+ for (uint32_t x = 0; x < width; x++) {
+ // YUV422: horizontal subsampling for U,V
+ uint8_t y_val = (yrow[x] >> bit_shift) & 0xFF;
+ uint8_t u_val = (urow[x >> 1] >> bit_shift) & 0xFF;
+ uint8_t v_val = (vrow[x >> 1] >> bit_shift) & 0xFF;
+
+ int yc = g_yc_tab[y_val];
+ int r = (yc + g_rv_tab[v_val]) >> 8;
+ int g = (yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8;
+ int b = (yc + g_bu_tab[u_val]) >> 8;
+
+ uint8_t* px = drow + (size_t)x * 4;
+ px[0] = clamp_u8(b); // Blue
+ px[1] = clamp_u8(g); // Green
+ px[2] = clamp_u8(r); // Red
+ px[3] = 255; // Alpha
+ }
+ }
+ break;
+ }
+
+ case ST_FRAME_FMT_YUV444PLANAR10LE:
+ case ST_FRAME_FMT_YUV444PLANAR12LE: {
+ uint16_t* src_y = (uint16_t*)frame->addr[0];
+ uint16_t* src_u = (uint16_t*)frame->addr[1];
+ uint16_t* src_v = (uint16_t*)frame->addr[2];
+ int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV444PLANAR12LE) ? 4 : 2;
+
+ size_t y_stride = frame->linesize[0] / 2;
+ size_t u_stride = frame->linesize[1] / 2;
+ size_t v_stride = frame->linesize[2] / 2;
+
+ init_yuv_tables();
+
+ for (uint32_t y = 0; y < height; y++) {
+ const uint16_t* yrow = src_y + (size_t)y * y_stride;
+ const uint16_t* urow = src_u + (size_t)y * u_stride;
+ const uint16_t* vrow = src_v + (size_t)y * v_stride;
+ uint8_t* drow = rgb_buffer + (size_t)y * width * 4;
+
+ for (uint32_t x = 0; x < width; x++) {
+ // YUV444: no subsampling, each pixel has its own U,V
+ uint8_t y_val = (yrow[x] >> bit_shift) & 0xFF;
+ uint8_t u_val = (urow[x] >> bit_shift) & 0xFF;
+ uint8_t v_val = (vrow[x] >> bit_shift) & 0xFF;
+
+ int yc = g_yc_tab[y_val];
+ int r = (yc + g_rv_tab[v_val]) >> 8;
+ int g = (yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8;
+ int b = (yc + g_bu_tab[u_val]) >> 8;
+
+ uint8_t* px = drow + (size_t)x * 4;
+ px[0] = clamp_u8(b);
+ px[1] = clamp_u8(g);
+ px[2] = clamp_u8(r);
+ px[3] = 255;
+ }
+ }
+ break;
+ }
+
+ case ST_FRAME_FMT_GBRPLANAR10LE:
+ case ST_FRAME_FMT_GBRPLANAR12LE: {
+ /* GBR planar 10-bit: plane 0 = Green, plane 1 = Blue, plane 2 = Red */
+ uint16_t* src_g = (uint16_t*)frame->addr[0];
+ uint16_t* src_b = (uint16_t*)frame->addr[1];
+ uint16_t* src_r = (uint16_t*)frame->addr[2];
+ int bit_shift = (frame->fmt == ST_FRAME_FMT_GBRPLANAR12LE) ? 4 : 2;
+
+ size_t g_stride = frame->linesize[0] / 2;
+ size_t b_stride = frame->linesize[1] / 2;
+ size_t r_stride = frame->linesize[2] / 2;
+
+ for (uint32_t y = 0; y < height; y++) {
+ const uint16_t* grow = src_g + (size_t)y * g_stride;
+ const uint16_t* brow = src_b + (size_t)y * b_stride;
+ const uint16_t* rrow = src_r + (size_t)y * r_stride;
+ uint8_t* drow = rgb_buffer + (size_t)y * width * 4;
+
+ for (uint32_t x = 0; x < width; x++) {
+ uint8_t* px = drow + (size_t)x * 4;
+ px[0] = (brow[x] >> bit_shift) & 0xFF;
+ px[1] = (grow[x] >> bit_shift) & 0xFF;
+ px[2] = (rrow[x] >> bit_shift) & 0xFF;
+ px[3] = 255;
+ }
+ }
+ break;
+ }
+
+ case ST_FRAME_FMT_YUV420PLANAR8: {
+ uint8_t* src_y = (uint8_t*)frame->addr[0];
+ uint8_t* src_u = (uint8_t*)frame->addr[1];
+ uint8_t* src_v = (uint8_t*)frame->addr[2];
+
+ for (uint32_t y = 0; y < height; y++) {
+ for (uint32_t x = 0; x < width; x++) {
+ uint8_t y_val = src_y[y * width + x];
+ uint8_t u_val = src_u[(y/2) * (width/2) + (x/2)];
+ uint8_t v_val = src_v[(y/2) * (width/2) + (x/2)];
+
+ uint8_t r, g, b;
+ yuv_to_rgb(y_val, u_val, v_val, &r, &g, &b);
+
+ uint32_t idx = (y * width + x) * 4;
+ rgb_buffer[idx + 0] = b; // Blue
+ rgb_buffer[idx + 1] = g; // Green
+ rgb_buffer[idx + 2] = r; // Red
+ rgb_buffer[idx + 3] = 255; // Alpha
+ }
+ }
+ break;
+ }
+
+ case ST_FRAME_FMT_YUV420CUSTOM8: {
+ /* I420 planar in single buffer: Y(w*h) + U(w/2*h/2) + V(w/2*h/2)
+ * Optimised: process 2 horizontal pixels at a time sharing U/V,
+ * inline BT.601 conversion with pre-computed chroma terms. */
+ uint8_t* base = (uint8_t*)frame->addr[0];
+ uint32_t y_plane_sz = width * height;
+ uint32_t uv_plane_sz = (width / 2) * (height / 2);
+ const uint8_t* src_y = base;
+ const uint8_t* src_u = base + y_plane_sz;
+ const uint8_t* src_v = base + y_plane_sz + uv_plane_sz;
+ uint32_t half_w = width / 2;
+
+ for (uint32_t row = 0; row < height; row++) {
+ const uint8_t* y_row = src_y + row * width;
+ const uint8_t* u_row = src_u + (row / 2) * half_w;
+ const uint8_t* v_row = src_v + (row / 2) * half_w;
+ uint8_t* dst = rgb_buffer + row * width * 4;
+
+ for (uint32_t cx = 0; cx < half_w; cx++) {
+ int d = (int)u_row[cx] - 128;
+ int e = (int)v_row[cx] - 128;
+ /* Pre-compute chroma contributions (shared by 2 pixels) */
+ int cr_r = 409 * e + 128;
+ int cr_g = -100 * d - 208 * e + 128;
+ int cr_b = 516 * d + 128;
+
+ for (int k = 0; k < 2; k++) {
+ int c = (int)y_row[cx * 2 + k] - 16;
+ int c298 = 298 * c;
+ int rv = (c298 + cr_r) >> 8;
+ int gv = (c298 + cr_g) >> 8;
+ int bv = (c298 + cr_b) >> 8;
+ if (rv < 0) rv = 0; else if (rv > 255) rv = 255;
+ if (gv < 0) gv = 0; else if (gv > 255) gv = 255;
+ if (bv < 0) bv = 0; else if (bv > 255) bv = 255;
+ dst[0] = (uint8_t)bv;
+ dst[1] = (uint8_t)gv;
+ dst[2] = (uint8_t)rv;
+ dst[3] = 255;
+ dst += 4;
+ }
+ }
+ }
+ break;
+ }
+
+ default:
+ log_warn(LOGGER_TAG, "Unsupported format %d for X11 display, filling with test pattern", frame->fmt);
+ // Fill with test pattern
+ for (uint32_t y = 0; y < height; y++) {
+ for (uint32_t x = 0; x < width; x++) {
+ uint32_t idx = (y * width + x) * 4;
+ rgb_buffer[idx + 0] = (x + y) % 256; // Blue gradient
+ rgb_buffer[idx + 1] = x % 256; // Green gradient
+ rgb_buffer[idx + 2] = y % 256; // Red gradient
+ rgb_buffer[idx + 3] = 255; // Alpha
+ }
+ }
+ break;
+ }
+
+ return 0;
+}
+
+/* Same sampling as scale_bgra_nearest() in x11_handler.c, but only the sampled
+ * source pixels are converted, so cost scales with the output size. */
+int convert_frame_to_rgb_scaled(struct st_frame* frame, uint8_t* rgb_buffer, uint32_t dst_w, uint32_t dst_h) {
+ if (!frame || !rgb_buffer || !frame->addr[0] || !frame->addr[1] || !frame->addr[2] ||
+ !dst_w || !dst_h || !frame->width || !frame->height)
+ return -1;
+
+ bool yuv422 = frame->fmt == ST_FRAME_FMT_YUV422PLANAR10LE || frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE;
+ bool yuv444 = frame->fmt == ST_FRAME_FMT_YUV444PLANAR10LE || frame->fmt == ST_FRAME_FMT_YUV444PLANAR12LE;
+ bool gbr = frame->fmt == ST_FRAME_FMT_GBRPLANAR10LE || frame->fmt == ST_FRAME_FMT_GBRPLANAR12LE;
+ if (!yuv422 && !yuv444 && !gbr) return -1;
+
+ uint32_t src_w = frame->width, src_h = frame->height;
+ uint32_t* sx_tab = malloc((size_t)dst_w * sizeof(*sx_tab));
+ if (!sx_tab) return -1;
+ for (uint32_t x = 0; x < dst_w; x++)
+ sx_tab[x] = (uint32_t)(((uint64_t)x * src_w) / dst_w);
+
+ const uint16_t* p0 = (const uint16_t*)frame->addr[0];
+ const uint16_t* p1 = (const uint16_t*)frame->addr[1];
+ const uint16_t* p2 = (const uint16_t*)frame->addr[2];
+ size_t s0 = frame->linesize[0] / 2, s1 = frame->linesize[1] / 2, s2 = frame->linesize[2] / 2;
+ int bit_shift = get_format_bit_shift(frame->fmt);
+ int uv_shift = yuv422 ? 1 : 0;
+
+ init_yuv_tables();
+
+ for (uint32_t y = 0; y < dst_h; y++) {
+ uint32_t sy = (uint32_t)(((uint64_t)y * src_h) / dst_h);
+ const uint16_t* r0 = p0 + (size_t)sy * s0;
+ const uint16_t* r1 = p1 + (size_t)sy * s1;
+ const uint16_t* r2 = p2 + (size_t)sy * s2;
+ uint8_t* drow = rgb_buffer + (size_t)y * dst_w * 4;
+
+ if (gbr) {
+ for (uint32_t x = 0; x < dst_w; x++) {
+ uint32_t sx = sx_tab[x];
+ uint8_t* px = drow + (size_t)x * 4;
+ px[0] = (r1[sx] >> bit_shift) & 0xFF;
+ px[1] = (r0[sx] >> bit_shift) & 0xFF;
+ px[2] = (r2[sx] >> bit_shift) & 0xFF;
+ px[3] = 255;
+ }
+ continue;
+ }
+
+ for (uint32_t x = 0; x < dst_w; x++) {
+ uint32_t sx = sx_tab[x];
+ uint32_t cx = sx >> uv_shift;
+ uint8_t y_val = (r0[sx] >> bit_shift) & 0xFF;
+ uint8_t u_val = (r1[cx] >> bit_shift) & 0xFF;
+ uint8_t v_val = (r2[cx] >> bit_shift) & 0xFF;
+
+ int yc = g_yc_tab[y_val];
+ uint8_t* px = drow + (size_t)x * 4;
+ px[0] = clamp_u8((yc + g_bu_tab[u_val]) >> 8);
+ px[1] = clamp_u8((yc + g_gu_tab[u_val] + g_gv_tab[v_val]) >> 8);
+ px[2] = clamp_u8((yc + g_rv_tab[v_val]) >> 8);
+ px[3] = 255;
+ }
+ }
+
+ free(sx_tab);
+ return 0;
+}
diff --git a/dvledrx/src/logger.c b/dvledrx/src/logger.c
new file mode 100644
index 0000000..af8cd37
--- /dev/null
+++ b/dvledrx/src/logger.c
@@ -0,0 +1,419 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE /* renameat2() */
+#endif
+
+#include "logger.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static _Atomic enum log_level g_log_level = LOG_LEVEL_INFO;
+static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
+static FILE* g_log_file = NULL; /* NULL → use stderr */
+static char g_log_file_path[512] = {0};
+static size_t g_log_max_bytes = 0;
+
+/* Background gzip of a rotated log; g_gz_running is protected by g_log_lock. */
+static pthread_t g_gz_thread;
+static bool g_gz_running = false;
+static atomic_bool g_gz_done = false;
+static time_t g_gz_last_retry = 0; /* protected by g_log_lock */
+
+static const char *level_color[] = {
+ [LOG_LEVEL_ERROR] = "\033[31m", /* red */
+ [LOG_LEVEL_WARN] = "\033[33m", /* yellow */
+ [LOG_LEVEL_INFO] = "\033[32m", /* green */
+ [LOG_LEVEL_DEBUG] = "\033[36m", /* cyan */
+};
+static const char *color_reset = "\033[0m";
+
+static const char* level_to_text(enum log_level level) {
+ switch (level) {
+ case LOG_LEVEL_DEBUG:
+ return "DEBUG";
+ case LOG_LEVEL_INFO:
+ return "INFO";
+ case LOG_LEVEL_WARN:
+ return "WARN";
+ case LOG_LEVEL_ERROR:
+ return "ERROR";
+ default:
+ return "INFO";
+ }
+}
+
+static void to_lower_copy(const char* src, char* dst, size_t dst_size) {
+ if (!src || !dst || dst_size == 0) return;
+
+ size_t i = 0;
+ for (; src[i] != '\0' && i < dst_size - 1; i++) {
+ dst[i] = (char)tolower((unsigned char)src[i]);
+ }
+ dst[i] = '\0';
+}
+
+bool logger_parse_level(const char* level_str, enum log_level* out_level) {
+ if (!level_str || !out_level) return false;
+
+ char norm[16];
+ to_lower_copy(level_str, norm, sizeof(norm));
+
+ if (strcmp(norm, "debug") == 0) {
+ *out_level = LOG_LEVEL_DEBUG;
+ return true;
+ }
+ if (strcmp(norm, "info") == 0) {
+ *out_level = LOG_LEVEL_INFO;
+ return true;
+ }
+ if (strcmp(norm, "warn") == 0 || strcmp(norm, "warning") == 0) {
+ *out_level = LOG_LEVEL_WARN;
+ return true;
+ }
+ if (strcmp(norm, "error") == 0) {
+ *out_level = LOG_LEVEL_ERROR;
+ return true;
+ }
+
+ return false;
+}
+
+void logger_set_level(enum log_level level) { atomic_store(&g_log_level, level); }
+
+enum log_level logger_get_level(void) { return atomic_load(&g_log_level); }
+
+bool logger_path_is_safe(const char* path, char* why, size_t why_len) {
+ char dir[PATH_MAX], real[PATH_MAX];
+
+ if (!path || path[0] == '\0') {
+ snprintf(why, why_len, "empty path");
+ return false;
+ }
+ const char* slash = strrchr(path, '/');
+ if (slash && slash[1] == '\0') {
+ snprintf(why, why_len, "'%s' is a directory", path);
+ return false;
+ }
+ if (!slash)
+ snprintf(dir, sizeof(dir), ".");
+ else if (slash == path)
+ snprintf(dir, sizeof(dir), "/");
+ else
+ snprintf(dir, sizeof(dir), "%.*s", (int)(slash - path), path);
+
+ if (!realpath(dir, real)) {
+ snprintf(why, why_len, "directory '%s' does not exist", dir);
+ return false;
+ }
+
+ const char* sudo_uid_env = getenv("SUDO_UID");
+ uid_t sudo_uid = sudo_uid_env ? (uid_t)strtoul(sudo_uid_env, NULL, 10) : (uid_t)-1;
+ for (;;) {
+ struct stat st;
+ if (stat(real, &st) != 0) {
+ snprintf(why, why_len, "cannot stat '%s'", real);
+ return false;
+ }
+ if (st.st_mode & S_IWOTH) {
+ snprintf(why, why_len, "'%s' is world-writable (e.g. /tmp); use a private directory", real);
+ return false;
+ }
+ if (st.st_uid != 0 && st.st_uid != geteuid() && st.st_uid != sudo_uid) {
+ snprintf(why, why_len, "'%s' is owned by another user (uid %u)", real, (unsigned)st.st_uid);
+ return false;
+ }
+ if (strcmp(real, "/") == 0) break;
+ char* s = strrchr(real, '/');
+ if (s == real) s[1] = '\0';
+ else *s = '\0';
+ }
+
+ struct stat lst;
+ if (lstat(path, &lst) == 0 && !S_ISREG(lst.st_mode)) {
+ snprintf(why, why_len, "'%s' is not a regular file (symlink or device)", path);
+ return false;
+ }
+ return true;
+}
+
+/* Append-open without following symlinks; only regular files, mode 0640. */
+static FILE* open_log_file(const char* path) {
+ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0640);
+ if (fd < 0) return NULL;
+ struct stat st;
+ if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
+ close(fd);
+ return NULL;
+ }
+ FILE* f = fdopen(fd, "a");
+ if (!f) close(fd);
+ return f;
+}
+
+static int gzip_file(const char* src, const char* dst) {
+ char tmp[PATH_MAX + 8];
+ snprintf(tmp, sizeof(tmp), "%s.tmp", dst);
+
+ int in = open(src, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
+ if (in < 0) return -1;
+ unlink(tmp);
+ int out = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0640);
+ if (out < 0) {
+ close(in);
+ return -1;
+ }
+ gzFile gz = gzdopen(out, "wb6");
+ if (!gz) {
+ close(out);
+ close(in);
+ unlink(tmp);
+ return -1;
+ }
+
+ int rc = 0;
+ char buf[64 * 1024];
+ ssize_t n;
+ while ((n = read(in, buf, sizeof(buf))) > 0) {
+ if (gzwrite(gz, buf, (unsigned)n) != (int)n) {
+ rc = -1;
+ break;
+ }
+ }
+ if (n < 0) rc = -1;
+ if (gzclose(gz) != Z_OK) rc = -1; /* also closes out */
+ close(in);
+ if (rc == 0 && rename(tmp, dst) != 0) rc = -1;
+ if (rc != 0) unlink(tmp);
+ return rc;
+}
+
+/* gzip .rotating to a temp archive first; only on success shift .N.gz up by
+ * one (dropping the oldest) and install it as .1.gz. On failure nothing is
+ * shifted or deleted and .rotating stays for a later retry. */
+static void* gz_worker(void* arg) {
+ char* base = arg;
+ char a[PATH_MAX], b[PATH_MAX], pending[PATH_MAX], fresh[PATH_MAX];
+
+ snprintf(pending, sizeof(pending), "%s.rotating", base);
+ snprintf(fresh, sizeof(fresh), "%s.new.gz", base);
+ if (gzip_file(pending, fresh) != 0) {
+ fprintf(stderr, "logger: cannot compress %s, will retry\n", pending);
+ unlink(fresh);
+ free(base);
+ atomic_store(&g_gz_done, true);
+ return NULL;
+ }
+
+ snprintf(a, sizeof(a), "%s.%d.gz", base, LOGGER_MAX_ARCHIVES);
+ unlink(a);
+ for (int i = LOGGER_MAX_ARCHIVES - 1; i >= 1; i--) {
+ snprintf(a, sizeof(a), "%s.%d.gz", base, i);
+ snprintf(b, sizeof(b), "%s.%d.gz", base, i + 1);
+ if (rename(a, b) != 0 && errno != ENOENT)
+ fprintf(stderr, "logger: cannot rename %s: %s\n", a, strerror(errno));
+ }
+ snprintf(a, sizeof(a), "%s.1.gz", base);
+ if (rename(fresh, a) == 0)
+ unlink(pending);
+ else
+ fprintf(stderr, "logger: cannot install %s: %s\n", a, strerror(errno));
+
+ free(base);
+ atomic_store(&g_gz_done, true);
+ return NULL;
+}
+
+/* Atomically rename 'from' to 'to', failing with EEXIST instead of replacing an
+ * existing 'to'. Avoids a separate existence check that could race. */
+static int rename_noreplace(const char* from, const char* to) {
+ if (renameat2(AT_FDCWD, from, AT_FDCWD, to, RENAME_NOREPLACE) == 0) return 0;
+ if (errno != EINVAL && errno != ENOSYS) return -1;
+ /* Filesystem without RENAME_NOREPLACE: link() also refuses an existing target. */
+ if (link(from, to) != 0) return -1;
+ if (unlink(from) != 0) {
+ int err = errno;
+ unlink(to);
+ errno = err;
+ return -1;
+ }
+ return 0;
+}
+
+/* Must be called with g_log_lock held. */
+static void start_archiver(void) {
+ char* base = strdup(g_log_file_path);
+ if (!base) return;
+ atomic_store(&g_gz_done, false);
+ if (pthread_create(&g_gz_thread, NULL, gz_worker, base) == 0) {
+ g_gz_running = true;
+ } else {
+ gz_worker(base); /* no thread: compress inline */
+ }
+}
+
+/* Must be called with g_log_lock held. */
+static void join_archiver(void) {
+ if (g_gz_running) {
+ pthread_join(g_gz_thread, NULL);
+ g_gz_running = false;
+ }
+}
+
+void logger_set_file(const char* file_path, size_t max_bytes) {
+ pthread_mutex_lock(&g_log_lock);
+
+ /* Close any previously opened log file */
+ if (g_log_file) {
+ fclose(g_log_file);
+ g_log_file = NULL;
+ }
+ g_log_file_path[0] = '\0';
+ g_log_max_bytes = 0;
+
+ if (file_path && file_path[0] != '\0') {
+ char why[PATH_MAX + 96];
+ if (!logger_path_is_safe(file_path, why, sizeof(why))) {
+ fprintf(stderr, "logger: refusing log file '%s': %s; using console\n", file_path, why);
+ } else if (strlen(file_path) >= sizeof(g_log_file_path)) {
+ fprintf(stderr, "logger: log file path too long, using console\n");
+ } else if ((g_log_file = open_log_file(file_path)) != NULL) {
+ snprintf(g_log_file_path, sizeof(g_log_file_path), "%s", file_path);
+ g_log_max_bytes = max_bytes;
+ /* Archive a rotation interrupted by a previous crash. */
+ char pending[PATH_MAX];
+ struct stat st;
+ snprintf(pending, sizeof(pending), "%s.rotating", file_path);
+ if (!g_gz_running && lstat(pending, &st) == 0 && S_ISREG(st.st_mode)) start_archiver();
+ } else {
+ /* Cannot open — fall through to stderr; print a notice. */
+ fprintf(stderr, "logger: cannot open log file '%s': %s; using console\n",
+ file_path, strerror(errno));
+ }
+ }
+
+ pthread_mutex_unlock(&g_log_lock);
+}
+
+void logger_close(void) {
+ pthread_mutex_lock(&g_log_lock);
+ if (g_log_file) {
+ fclose(g_log_file);
+ g_log_file = NULL;
+ }
+ g_log_file_path[0] = '\0';
+ join_archiver();
+ pthread_mutex_unlock(&g_log_lock);
+}
+
+void logger_init_from_env(void) {
+ const char* level = getenv("RXAPP_LOG_LEVEL");
+ enum log_level parsed;
+
+ if (!level) return;
+
+ if (logger_parse_level(level, &parsed)) {
+ logger_set_level(parsed);
+ }
+}
+
+/* Rotate when the file exceeds the limit: rename it aside, reopen a fresh file
+ * immediately, and gzip the old one on a background thread so callers (RX and
+ * display threads) never wait for compression. Must be called with g_log_lock held. */
+static void check_and_rotate(void) {
+ if (!g_log_file || g_log_max_bytes == 0) return;
+
+ long pos = ftell(g_log_file);
+ if (pos < 0) {
+ /* ftell failed – try fstat on the open descriptor as fallback */
+ struct stat st;
+ if (fstat(fileno(g_log_file), &st) == 0)
+ pos = (long)st.st_size;
+ else
+ return;
+ }
+ if ((size_t)pos < g_log_max_bytes) return;
+
+ if (g_gz_running) {
+ if (!atomic_load(&g_gz_done)) return; /* previous archive still compressing: keep appending */
+ join_archiver();
+ }
+
+ char pending[PATH_MAX];
+ snprintf(pending, sizeof(pending), "%s.rotating", g_log_file_path);
+ /* Rename while the file is still open so a failed rotation keeps appending. */
+ fflush(g_log_file);
+ if (rename_noreplace(g_log_file_path, pending) != 0) {
+ if (errno == EEXIST) {
+ /* An earlier archive failed to compress: never overwrite it. Keep appending
+ * and retry compressing it at most every 10 s. */
+ time_t now = time(NULL);
+ if (now - g_gz_last_retry >= 10) {
+ g_gz_last_retry = now;
+ start_archiver();
+ }
+ } else {
+ fprintf(g_log_file, "--- log rotation failed: %s ---\n", strerror(errno));
+ }
+ return;
+ }
+ fclose(g_log_file);
+ g_log_file = open_log_file(g_log_file_path);
+ if (g_log_file) {
+ fprintf(g_log_file, "--- log rotated: previous %ld bytes -> %s.1.gz (keeping %d archives) ---\n",
+ pos, g_log_file_path, LOGGER_MAX_ARCHIVES);
+ fflush(g_log_file);
+ }
+ start_archiver();
+}
+
+void logger_vlog(enum log_level level, const char* tag, const char* fmt, va_list args) {
+ if (level < atomic_load(&g_log_level)) return;
+
+ time_t now = time(NULL);
+ struct tm tm_now;
+ char time_buf[20];
+
+ localtime_r(&now, &tm_now);
+ strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", &tm_now);
+
+ pthread_mutex_lock(&g_log_lock);
+
+ FILE* out = g_log_file ? g_log_file : stderr;
+
+ if (g_log_file) check_and_rotate();
+
+ /* Re-read out in case check_and_rotate changed g_log_file */
+ out = g_log_file ? g_log_file : stderr;
+
+ bool use_color = (!g_log_file && level <= LOG_LEVEL_ERROR);
+
+ if (use_color) fputs(level_color[level], out);
+ fprintf(out, "%s [%s] [%s] ", time_buf, level_to_text(level), tag ? tag : "app");
+ vfprintf(out, fmt, args);
+ if (use_color) fputs(color_reset, out);
+ fputc('\n', out);
+ fflush(out);
+ pthread_mutex_unlock(&g_log_lock);
+}
+
+void logger_log(enum log_level level, const char* tag, const char* fmt, ...) {
+ va_list args;
+ va_start(args, fmt);
+ logger_vlog(level, tag, fmt, args);
+ va_end(args);
+}
diff --git a/dvledrx/src/main.c b/dvledrx/src/main.c
new file mode 100644
index 0000000..80dafb4
--- /dev/null
+++ b/dvledrx/src/main.c
@@ -0,0 +1,282 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "mtl_api.h"
+#include "st_pipeline_api.h"
+#include "frame_converter.h"
+#include "x11_handler.h"
+#include "config_reader.h"
+#include "session_manager.h"
+#include "logger.h"
+
+#define LOGGER_TAG "main"
+
+/* Overridden by the build system with the meson project version. */
+#ifndef DVLEDRX_VERSION
+#define DVLEDRX_VERSION "1.0.0"
+#endif
+
+/* Map a plain integer fps value to the correct MTL enum.
+ * MTL uses enum st_fps for RTP timestamp spacing — passing a raw
+ * integer (e.g. 30) instead of ST_FPS_P30 (==8) makes the receiver
+ * expect the wrong timestamp cadence and triggers rv_slot_by_tmstamp
+ * errors on every packet. */
+static enum st_fps int_fps_to_st_fps(int fps) {
+ switch (fps) {
+ case 120: return ST_FPS_P120;
+ case 100: return ST_FPS_P100;
+ case 60: return ST_FPS_P60;
+ case 59: return ST_FPS_P59_94;
+ case 50: return ST_FPS_P50;
+ case 30: return ST_FPS_P30;
+ case 29: return ST_FPS_P29_97;
+ case 25: return ST_FPS_P25;
+ case 24: return ST_FPS_P24;
+ default:
+ log_warn(LOGGER_TAG, "Unknown FPS %d, defaulting to ST_FPS_P25", fps);
+ return ST_FPS_P25;
+ }
+}
+
+/* Map a format name string (as used by FFmpeg -pix_fmt) to the MTL enum.
+ * Accepts both the ffmpeg short name and the MTL enum name so the
+ * value in the config feels natural. */
+static enum st_frame_fmt str_fmt_to_st_fmt(const char* s) {
+ if (!s) return ST_FRAME_FMT_YUV422PLANAR10LE;
+ /* 10-bit formats */
+ if (strcmp(s, "yuv422p10le") == 0) return ST_FRAME_FMT_YUV422PLANAR10LE;
+ if (strcmp(s, "yuv444p10le") == 0) return ST_FRAME_FMT_YUV444PLANAR10LE;
+ if (strcmp(s, "gbrp10le") == 0) return ST_FRAME_FMT_GBRPLANAR10LE;
+ /* 12-bit formats */
+ if (strcmp(s, "yuv422p12le") == 0) return ST_FRAME_FMT_YUV422PLANAR12LE;
+ if (strcmp(s, "yuv444p12le") == 0) return ST_FRAME_FMT_YUV444PLANAR12LE;
+ if (strcmp(s, "gbrp12le") == 0) return ST_FRAME_FMT_GBRPLANAR12LE;
+ /* 8-bit / special formats */
+ if (strcmp(s, "yuv420") == 0) return ST_FRAME_FMT_YUV420CUSTOM8;
+ /* Also accept raw integer for backward compatibility */
+ char* end;
+ long v = strtol(s, &end, 10);
+ if (*end == '\0') return (enum st_frame_fmt)v;
+ log_warn(LOGGER_TAG, "Unknown format '%s', defaulting to yuv422p10le", s);
+ return ST_FRAME_FMT_YUV422PLANAR10LE;
+}
+
+/* Map an output frame format to ST2110-20 transport format. */
+static enum st20_fmt st_fmt_to_transport_fmt(enum st_frame_fmt frame_fmt) {
+ switch (frame_fmt) {
+ case ST_FRAME_FMT_YUV422PLANAR10LE:
+ return ST20_FMT_YUV_422_10BIT;
+ case ST_FRAME_FMT_YUV422PLANAR12LE:
+ return ST20_FMT_YUV_422_12BIT;
+ case ST_FRAME_FMT_YUV444PLANAR10LE:
+ return ST20_FMT_YUV_444_10BIT;
+ case ST_FRAME_FMT_YUV444PLANAR12LE:
+ return ST20_FMT_YUV_444_12BIT;
+ case ST_FRAME_FMT_GBRPLANAR10LE:
+ return ST20_FMT_RGB_10BIT;
+ case ST_FRAME_FMT_GBRPLANAR12LE:
+ return ST20_FMT_RGB_12BIT;
+ case ST_FRAME_FMT_YUV420PLANAR8:
+ case ST_FRAME_FMT_YUV420CUSTOM8:
+ return ST20_FMT_YUV_420_8BIT;
+ default:
+ log_warn(LOGGER_TAG, "Unknown frame format %d, defaulting transport to YUV_422_10BIT", frame_fmt);
+ return ST20_FMT_YUV_422_10BIT;
+ }
+}
+
+/* Set by the signal handler; logged and acted on by the main loop (logging is not async-signal-safe). */
+static volatile sig_atomic_t g_stop_signal = 0;
+
+static void rx_app_sig_handler(int sig) {
+ g_stop_signal = sig;
+}
+
+static const char* signal_name(int sig) {
+ return sig == SIGINT ? "SIGINT(Ctrl+C)" : sig == SIGTERM ? "SIGTERM" :
+ sig == SIGQUIT ? "SIGQUIT(Ctrl+\\)" : "UNKNOWN";
+}
+
+static void print_help(const char* prog_name) {
+ log_info(LOGGER_TAG, "Usage: %s --config [options]", prog_name);
+ log_info(LOGGER_TAG, "Options:");
+ log_info(LOGGER_TAG, " -C, --config JSON config file (required)");
+ log_info(LOGGER_TAG, " -t, --test-time Receive for N seconds then exit (1..86400)");
+ log_info(LOGGER_TAG, " -v, --version Show version");
+ log_info(LOGGER_TAG, " --help Show this help");
+}
+
+/* Same options as dvledtx; everything else comes from the JSON config. */
+static int parse_args(struct rx_app_context* app, int argc, char** argv) {
+ static struct option long_options[] = {
+ {"config", required_argument, 0, 'C'},
+ {"test-time", required_argument, 0, 't'},
+ {"version", no_argument, 0, 'v'},
+ {"help", no_argument, 0, '?'},
+ {0, 0, 0, 0}
+ };
+
+ app->config_file[0] = '\0';
+
+ int c = 0, option_index = 0;
+ while ((c = getopt_long(argc, argv, "C:t:v?", long_options, &option_index)) != -1) {
+ switch (c) {
+ case 'C':
+ snprintf(app->config_file, sizeof(app->config_file), "%s", optarg);
+ break;
+ case 't': {
+ char* endptr = NULL;
+ long val = strtol(optarg, &endptr, 10);
+ if (endptr == optarg || *endptr != '\0' || val <= 0 || val > 86400) {
+ log_error(LOGGER_TAG, "Invalid --test-time value '%s' (expected 1..86400)", optarg);
+ return -1;
+ }
+ app->test_time_s = (int)val;
+ break;
+ }
+ case 'v':
+ printf("dvledrx version %s\n", DVLEDRX_VERSION);
+ exit(EXIT_SUCCESS);
+ case '?':
+ default:
+ print_help(argv[0]);
+ return -1;
+ }
+ }
+
+ if (app->config_file[0] == '\0') {
+ print_help(argv[0]);
+ return -1;
+ }
+ return 0;
+}
+
+/* Main application */
+int main(int argc, char** argv) {
+ struct rx_app_context app;
+ session_manager_t session_manager;
+ int ret = 0;
+
+ memset(&app, 0, sizeof(app));
+ /* Must precede every other Xlib call: display, RX and main threads share Display* handles. */
+ XInitThreads();
+ logger_init_from_env();
+
+ /* Parse arguments */
+ if (parse_args(&app, argc, argv) < 0) {
+ return -1;
+ }
+
+ if (load_and_validate_rx_config(app.config_file, &app.json_config) != 0) {
+ return -1;
+ }
+ const struct rx_config* cfg = &app.json_config;
+
+ /* Video parameters */
+ app.width = (uint32_t)cfg->video.width;
+ app.height = (uint32_t)cfg->video.height;
+ app.fps = int_fps_to_st_fps(cfg->video.fps);
+ app.fmt = cfg->video.fmt[0] != '\0' ? str_fmt_to_st_fmt(cfg->video.fmt)
+ : ST_FRAME_FMT_YUV422PLANAR10LE;
+ app.transport_fmt = st_fmt_to_transport_fmt(app.fmt);
+
+ /* 1 session -> single window; N sessions -> composite, or per_output windows. */
+ app.st20p_sessions = cfg->num_rx_sessions;
+
+ log_info(LOGGER_TAG, "JSON config: %d rx_session(s) -> %s display mode",
+ cfg->num_rx_sessions,
+ strcmp(cfg->display_mode, "per_output") == 0 ? "per_output" :
+ cfg->num_rx_sessions > 1 ? "composite" : "single");
+
+ if (cfg->log_level[0] != '\0') {
+ enum log_level level;
+ if (logger_parse_level(cfg->log_level, &level)) {
+ logger_set_level(level);
+ log_info(LOGGER_TAG, "Log level set to: %s", cfg->log_level);
+ } else {
+ log_warn(LOGGER_TAG, "Invalid log_level '%s' in config (use debug|info|warn|error)", cfg->log_level);
+ }
+ }
+
+ /* Redirect logs to file when configured (empty = console) */
+ if (cfg->log_file[0] != '\0') {
+ logger_set_file(cfg->log_file, LOGGER_MAX_BYTES);
+ log_info(LOGGER_TAG, "Logging to file: %s (rotates at %u MB, keeps %d .gz archives)",
+ cfg->log_file, LOGGER_MAX_BYTES / (1024u * 1024u), LOGGER_MAX_ARCHIVES);
+ }
+
+ /* Install signal handler */
+ signal(SIGINT, rx_app_sig_handler);
+ signal(SIGTERM, rx_app_sig_handler);
+ signal(SIGQUIT, rx_app_sig_handler); /* Ctrl+\\ */
+
+ log_info(LOGGER_TAG, "Signal handlers installed. Press Ctrl+C to stop.");
+
+ /* Initialize session manager */
+ if (session_manager_init(&session_manager, &app) < 0) {
+ log_error(LOGGER_TAG, "Failed to initialize session manager");
+ return -1;
+ }
+
+ /* Start all sessions */
+ if (session_manager_start(&session_manager, &app) < 0) {
+ log_error(LOGGER_TAG, "Failed to start sessions");
+ app.exit = true; /* stop the sessions that did start */
+ ret = -1;
+ goto cleanup;
+ }
+
+ if (app.test_time_s > 0) {
+ log_info(LOGGER_TAG, "Running for %d seconds...", app.test_time_s);
+ } else {
+ log_info(LOGGER_TAG, "Running indefinitely (press Ctrl+C to stop)...");
+ }
+
+ /* Main loop: pump X11 events until Ctrl+C, a window close, or --test-time. */
+ time_t start_time = time(NULL);
+ while (!app.exit) {
+ if (session_manager.composite) {
+ process_composite_x11_events(session_manager.composite);
+ } else {
+ for (int i = 0; i < session_manager.st20p_count; i++) {
+ struct st20p_rx_ctx* ctx = &session_manager.st20p_sessions[i];
+ if (ctx->display.initialized) process_x11_events(&ctx->display);
+ for (int m = 0; m < ctx->num_mirror_displays; m++)
+ process_x11_events(&ctx->mirror_display[m]);
+ }
+ }
+
+ if (x11_close_requested()) app.exit = true;
+ if (g_stop_signal) {
+ log_info(LOGGER_TAG, "Signal received: %d (%s)", (int)g_stop_signal, signal_name(g_stop_signal));
+ app.exit = true;
+ }
+ if (app.test_time_s > 0 && time(NULL) - start_time >= app.test_time_s) {
+ log_info(LOGGER_TAG, "Test duration completed");
+ app.exit = true;
+ }
+
+ usleep(10000); /* 10ms: responsive to signals and window events */
+ }
+
+ log_info(LOGGER_TAG, "Stopping application...");
+
+cleanup:
+ /* Cleanup session manager */
+ session_manager_cleanup(&session_manager);
+
+ log_info(LOGGER_TAG, "Application stopped");
+ logger_close();
+ return ret;
+}
diff --git a/dvledrx/src/session_manager.c b/dvledrx/src/session_manager.c
new file mode 100644
index 0000000..4d874e4
--- /dev/null
+++ b/dvledrx/src/session_manager.c
@@ -0,0 +1,949 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include "session_manager.h"
+#include "x11_handler.h"
+#include "frame_converter.h"
+#include "config_reader.h"
+#include "logger.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOGGER_TAG "session"
+
+static bool is_valid_pci_bdf(const char* pci_bdf);
+
+static bool is_pci_virtual_function(const char* pci_bdf) {
+ char path[256];
+
+ if (!is_valid_pci_bdf(pci_bdf)) {
+ return false;
+ }
+
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/physfn", pci_bdf);
+ return (access(path, F_OK) == 0);
+}
+
+static bool is_pci_bound_to_vfio(const char* pci_bdf) {
+ char path[256];
+ char link_target[256];
+ ssize_t len;
+
+ if (!is_valid_pci_bdf(pci_bdf)) {
+ return false;
+ }
+
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/driver", pci_bdf);
+ len = readlink(path, link_target, sizeof(link_target) - 1);
+ if (len <= 0) {
+ return false;
+ }
+
+ link_target[len] = '\0';
+ return strstr(link_target, "vfio-pci") != NULL;
+}
+
+/* Validate a PCI BDF string (e.g. "0000:03:10.3" or "03:10.3") so it is safe
+ * to embed in sysfs paths and shell commands. Only the canonical forms
+ * "dddd:bb:dd.f" (4-digit domain) and "bb:dd.f" (domain omitted) are
+ * accepted, with function restricted to 0-7. Reformatting the parsed fields
+ * and comparing back to the input rejects anything with the wrong digit
+ * width (e.g. ".." or truncated/extended fields), which would otherwise
+ * allow directory traversal in sysfs paths. */
+static bool is_valid_pci_bdf(const char* pci_bdf) {
+ unsigned domain, bus, dev, func;
+ int consumed;
+ char canonical[32];
+
+ if (!pci_bdf || !pci_bdf[0]) {
+ return false;
+ }
+
+ consumed = -1;
+ if (sscanf(pci_bdf, "%4x:%2x:%2x.%1x%n", &domain, &bus, &dev, &func, &consumed) == 4 &&
+ consumed > 0 && pci_bdf[consumed] == '\0' && func <= 7) {
+ snprintf(canonical, sizeof(canonical), "%04x:%02x:%02x.%01x", domain, bus, dev, func);
+ return strcasecmp(canonical, pci_bdf) == 0;
+ }
+
+ consumed = -1;
+ if (sscanf(pci_bdf, "%2x:%2x.%1x%n", &bus, &dev, &func, &consumed) == 3 &&
+ consumed > 0 && pci_bdf[consumed] == '\0' && func <= 7) {
+ snprintf(canonical, sizeof(canonical), "%02x:%02x.%01x", bus, dev, func);
+ return strcasecmp(canonical, pci_bdf) == 0;
+ }
+
+ return false;
+}
+
+/*
+ * Prepare the parent PF of an SR-IOV VF for multicast RX.
+ *
+ * ixgbe (and similar) NICs reject a VF's DPDK promiscuous request unless the
+ * parent PF is already in promiscuous mode (kernel: "Enabling VF promisc
+ * requires PF in promisc"). MTL requests promiscuous once at init and never
+ * retries, so we must configure the PF *before* mtl_init():
+ * - PF: promiscuous on, link up
+ * - VF: trust on, spoof checking off
+ *
+ * The VF itself is bound to vfio-pci (no kernel netdev), but the PF still has a
+ * kernel netdev we can drive via `ip link`. Requires CAP_NET_ADMIN (run as root).
+ * Best-effort: logs a warning and returns on any failure.
+ */
+static void prepare_vf_parent_pf(const char* vf_bdf) {
+ char path[512];
+ char link_target[512];
+ ssize_t len;
+ char pf_bdf[64];
+ char pf_iface[64] = {0};
+ int vf_index = -1;
+ DIR* dir;
+ struct dirent* ent;
+ char cmd[512];
+
+ if (!is_valid_pci_bdf(vf_bdf)) {
+ log_warn(LOGGER_TAG, "VF setup: invalid PCI BDF '%s', skipping PF promisc setup", vf_bdf);
+ return;
+ }
+
+ /* Resolve parent PF BDF via /sys/bus/pci/devices//physfn */
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/physfn", vf_bdf);
+ len = readlink(path, link_target, sizeof(link_target) - 1);
+ if (len <= 0) {
+ log_warn(LOGGER_TAG, "VF setup: cannot resolve parent PF for %s", vf_bdf);
+ return;
+ }
+ link_target[len] = '\0';
+ {
+ const char* base = strrchr(link_target, '/');
+ snprintf(pf_bdf, sizeof(pf_bdf), "%s", base ? base + 1 : link_target);
+ }
+
+ /* Find the PF kernel netdev name via /sys/bus/pci/devices//net/ */
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/net", pf_bdf);
+ dir = opendir(path);
+ if (dir) {
+ while ((ent = readdir(dir)) != NULL) {
+ if (ent->d_name[0] == '.') continue;
+ snprintf(pf_iface, sizeof(pf_iface), "%s", ent->d_name);
+ break;
+ }
+ closedir(dir);
+ }
+ if (!pf_iface[0]) {
+ log_warn(LOGGER_TAG, "VF setup: cannot find PF netdev for %s (PF %s)", vf_bdf, pf_bdf);
+ return;
+ }
+
+ /* Determine VF index by matching virtfn* symlinks under the PF */
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s", pf_bdf);
+ dir = opendir(path);
+ if (dir) {
+ while ((ent = readdir(dir)) != NULL) {
+ if (strncmp(ent->d_name, "virtfn", 6) != 0) continue;
+ snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/%s", pf_bdf, ent->d_name);
+ len = readlink(path, link_target, sizeof(link_target) - 1);
+ if (len <= 0) continue;
+ link_target[len] = '\0';
+ const char* base = strrchr(link_target, '/');
+ base = base ? base + 1 : link_target;
+ if (strcmp(base, vf_bdf) == 0) {
+ vf_index = atoi(ent->d_name + 6);
+ break;
+ }
+ }
+ closedir(dir);
+ }
+ if (vf_index < 0) {
+ log_warn(LOGGER_TAG, "VF setup: cannot resolve VF index for %s under PF %s", vf_bdf, pf_bdf);
+ return;
+ }
+
+ log_info(LOGGER_TAG,
+ "VF setup: configuring PF %s (%s) promisc + vf %d trust on / spoofchk off",
+ pf_bdf, pf_iface, vf_index);
+
+ /* PF promiscuous mode (required before VF promisc is honored) */
+ for (const char* p = pf_iface; *p; p++) {
+ if (!isalnum((unsigned char)*p) && *p != '_' && *p != '-' && *p != '.') {
+ log_warn(LOGGER_TAG, "VF setup: unsafe PF netdev name '%s', skipping ip link setup", pf_iface);
+ return;
+ }
+ }
+ snprintf(cmd, sizeof(cmd), "ip link set %s promisc on", pf_iface);
+ if (system(cmd) != 0) {
+ log_warn(LOGGER_TAG, "VF setup: '%s' failed (need root/CAP_NET_ADMIN?)", cmd);
+ }
+ /* VF trust on */
+ snprintf(cmd, sizeof(cmd), "ip link set %s vf %d trust on", pf_iface, vf_index);
+ if (system(cmd) != 0) {
+ log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd);
+ }
+ /* VF spoof checking off (allows promiscuous/multicast reception) */
+ snprintf(cmd, sizeof(cmd), "ip link set %s vf %d spoofchk off", pf_iface, vf_index);
+ if (system(cmd) != 0) {
+ log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd);
+ }
+ /* Bring the PF link up; RxTxApp's nicctl.sh does this so the VF admin queue
+ * works for DPDK init and traffic reaches the VF. */
+ snprintf(cmd, sizeof(cmd), "ip link set %s up", pf_iface);
+ if (system(cmd) != 0) {
+ log_warn(LOGGER_TAG, "VF setup: '%s' failed", cmd);
+ }
+}
+
+/* "per_output": one fullscreen window per session, each on its own HDMI/DP output. */
+static bool is_per_output_mode(const struct rx_app_context* app) {
+ return strcmp(app->json_config.display_mode, "per_output") == 0;
+}
+
+/*
+ * st20p_display_thread - dedicated rendering thread (single-session mode).
+ *
+ * Takes the newest MTL frame handed over by the RX thread, renders it, then
+ * returns it to MTL. Runs best-effort: if rendering cannot keep up, the RX
+ * thread returns superseded frames unrendered — the receive pipeline never waits.
+ */
+static void* st20p_display_thread(void* arg) {
+ struct st20p_rx_ctx* ctx = (struct st20p_rx_ctx*)arg;
+
+ log_info(LOGGER_TAG, "ST20P display thread %d started", ctx->idx);
+
+ char dip[INET_ADDRSTRLEN] = "";
+ inet_ntop(AF_INET, ctx->dip_addr, dip, sizeof(dip));
+ char rx_label[96];
+ snprintf(rx_label, sizeof(rx_label), "%s %s:%u", ctx->port_name, dip, ctx->session_udp_port);
+
+ /* Display-rate measurement: count frames actually blitted and report the
+ * achieved render fps once per second. This is the true on-screen rate,
+ * independent of the MTL receive rate. */
+ struct timespec last;
+ clock_gettime(CLOCK_MONOTONIC, &last);
+ int rendered = 0;
+
+ while (!ctx->app->exit) {
+ struct st_frame* frame = NULL;
+
+ pthread_mutex_lock(&ctx->disp_lock);
+ while (!ctx->disp_pending && !ctx->app->exit) {
+ /* Timed wait so the window stays responsive and exit is observed
+ * promptly even when no new frames are arriving. */
+ struct timespec ts;
+ clock_gettime(CLOCK_REALTIME, &ts);
+ ts.tv_nsec += 20 * 1000 * 1000; /* 20ms */
+ if (ts.tv_nsec >= 1000000000L) {
+ ts.tv_sec += 1;
+ ts.tv_nsec -= 1000000000L;
+ }
+ pthread_cond_timedwait(&ctx->disp_cond, &ctx->disp_lock, &ts);
+ }
+ frame = ctx->disp_pending;
+ ctx->disp_pending = NULL;
+ pthread_mutex_unlock(&ctx->disp_lock);
+
+ if (frame) {
+ display_frame_x11(&ctx->display, frame);
+ for (int m = 0; m < ctx->num_mirror_displays; m++)
+ if (!display_copy_x11(&ctx->mirror_display[m], &ctx->display))
+ display_frame_x11(&ctx->mirror_display[m], frame);
+ rendered++;
+ /* All puts go through disp_lock so RX and display threads never race in MTL. */
+ pthread_mutex_lock(&ctx->disp_lock);
+ st20p_rx_put_frame(ctx->handle, frame);
+ pthread_mutex_unlock(&ctx->disp_lock);
+ }
+ /* X11 events are pumped by the main thread to avoid concurrent event handling. */
+
+ /* Report achieved display fps once per second. */
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ double elapsed = (now.tv_sec - last.tv_sec) + (now.tv_nsec - last.tv_nsec) / 1e9;
+ if (elapsed >= 1.0) {
+ int rx = __atomic_exchange_n(&ctx->frames_rx_window, 0, __ATOMIC_RELAXED);
+ pthread_mutex_lock(&ctx->disp_lock);
+ int skipped = ctx->disp_skipped;
+ ctx->disp_skipped = 0;
+ pthread_mutex_unlock(&ctx->disp_lock);
+
+ char outputs[160] = "";
+ int n = 0;
+ const struct x11_display_ctx* wins[1 + MAX_MIRRORS] = {&ctx->display};
+ for (int m = 0; m < ctx->num_mirror_displays; m++) wins[1 + m] = &ctx->mirror_display[m];
+ for (int w = 0; w <= ctx->num_mirror_displays && (size_t)n < sizeof(outputs); w++) {
+ if (!wins[w]->initialized) continue;
+ n += snprintf(outputs + n, sizeof(outputs) - (size_t)n, "%s%s", n ? ", " : "",
+ wins[w]->output_name);
+ }
+
+ log_info(LOGGER_TAG, "Session %d [%s]: rx %.2f fps, display %.2f fps, skipped %d -> %s",
+ ctx->idx, rx_label, rx / elapsed, rendered / elapsed, skipped, outputs);
+ rendered = 0;
+ last = now;
+ }
+ }
+
+ log_info(LOGGER_TAG, "ST20P display thread %d stopped", ctx->idx);
+ return NULL;
+}
+
+/*
+ * publish_frame_to_display - hand a received MTL frame to the display thread
+ * without copying it. The display thread returns it to MTL after rendering;
+ * if a previous frame is still waiting unrendered, it is returned here.
+ * Returns false (caller keeps ownership) when the frame has no data.
+ */
+static bool publish_frame_to_display(struct st20p_rx_ctx* ctx, struct st_frame* frame) {
+ if (!frame->addr[0] || frame->data_size == 0) {
+ return false;
+ }
+
+ pthread_mutex_lock(&ctx->disp_lock);
+ struct st_frame* stale = ctx->disp_pending;
+ ctx->disp_pending = frame;
+ if (stale) {
+ st20p_rx_put_frame(ctx->handle, stale);
+ ctx->disp_skipped++;
+ }
+ pthread_cond_signal(&ctx->disp_cond);
+ pthread_mutex_unlock(&ctx->disp_lock);
+ return true;
+}
+
+/*
+ * st20p_rx_thread - per-session ST20P (video) receive thread.
+ *
+ * Blocks on st20p_rx_get_frame() until MTL delivers a decoded video frame.
+ * For each frame received it:
+ * 1. Logs detailed metadata for the first few frames (format/size sanity check).
+ * 2. Renders the frame to the display:
+ * - Single-session mode : directly into a dedicated X11 window.
+ * - Multi-session mode : writes a strip into the shared composite buffer.
+ * 3. Returns the frame buffer to the MTL library (mandatory — frees the slot).
+ *
+ * Exits cleanly once app->exit is set (e.g. by the SIGINT handler).
+ */
+static void* st20p_rx_thread(void* arg) {
+ struct st20p_rx_ctx* ctx = (struct st20p_rx_ctx*)arg;
+ struct st_frame* frame;
+
+ log_info(LOGGER_TAG, "ST20P RX thread %d started - waiting for incoming frames", ctx->idx);
+
+ /* Single-session display runs on a dedicated thread so the heavy YUV->RGB
+ * conversion and X11 blit never block the receive loop. Composite
+ * (multi-session) mode keeps its existing in-loop barrier-synchronised path.
+ * Mirrors count as displays: the primary output may be disconnected. */
+ bool has_window = ctx->display.initialized;
+ for (int m = 0; m < ctx->num_mirror_displays; m++)
+ has_window |= ctx->mirror_display[m].initialized;
+ bool use_disp_thread = !ctx->composite && has_window;
+ if (use_disp_thread) {
+ pthread_mutex_init(&ctx->disp_lock, NULL);
+ pthread_cond_init(&ctx->disp_cond, NULL);
+ ctx->disp_pending = NULL;
+ if (pthread_create(&ctx->disp_thread, NULL, st20p_display_thread, ctx) == 0) {
+ ctx->disp_thread_started = true;
+ } else {
+ log_error(LOGGER_TAG, "Failed to start display thread %d; rendering inline", ctx->idx);
+ pthread_mutex_destroy(&ctx->disp_lock);
+ pthread_cond_destroy(&ctx->disp_cond);
+ use_disp_thread = false;
+ }
+ }
+
+ while (!ctx->app->exit) {
+ frame = st20p_rx_get_frame(ctx->handle);
+ if (!frame) {
+ usleep(1000); /* 1ms — avoid busy-spinning when no frame is ready */
+ continue;
+ }
+
+ ctx->frames_received++;
+ __atomic_fetch_add(&ctx->frames_rx_window, 1, __ATOMIC_RELAXED);
+ bool handed_off = false;
+
+ /* --- Diagnostic: log frame metadata for the first few frames ----------
+ * Helps verify that sender format/resolution matches the MTL RX output. */
+ /* DEBUG: Print detailed frame information for first few frames */
+ if (ctx->frames_received <= 5) {
+ log_debug(LOGGER_TAG, "ST20P RX session %d: Frame %d received", ctx->idx, ctx->frames_received);
+ log_debug(LOGGER_TAG, " Format: %d, Size: %zu bytes", frame->fmt, frame->data_size);
+ log_debug(LOGGER_TAG, " Width: %d, Height: %d", frame->width, frame->height);
+ log_debug(LOGGER_TAG, " Addresses: Y=%p, U=%p, V=%p", frame->addr[0], frame->addr[1], frame->addr[2]);
+ log_debug(LOGGER_TAG, " Linesizes: Y=%zu, U=%zu, V=%zu", frame->linesize[0], frame->linesize[1], frame->linesize[2]);
+ }
+
+ /* Render the frame.
+ * Two render paths — selected by whether a composite context was wired in:
+ *
+ * ctx->composite != NULL (multi-session / legacy single-machine mode):
+ * Copy this session's strip into the shared composite buffer and wait
+ * at the pthread_barrier so all strips are present before session 0
+ * flushes the full frame to the screen.
+ *
+ * ctx->composite == NULL (single-session / standard wall deployment):
+ * Render the complete 640x1080 frame directly into this receiver's own
+ * X11 window. No synchronisation with other machines is needed — each
+ * receiver is independent. */
+ if (ctx->composite) {
+ /* Multi-session: write strip/crop and synchronise with peer sessions */
+ if (ctx->crop.w > 0 && ctx->crop.h > 0) {
+ /* Crop-aware composite: place frame at specific coordinates */
+ composite_write_crop(ctx->composite, frame, &ctx->crop);
+ } else {
+ /* Legacy equal-width strip composite */
+ composite_write_strip(ctx->composite, frame, ctx->idx);
+ }
+ } else if (has_window) {
+ /* Single-session: hand the frame to the dedicated display thread,
+ * which returns it to MTL after rendering, so receive never stalls. */
+ if (use_disp_thread) {
+ handed_off = publish_frame_to_display(ctx, frame);
+ } else {
+ /* Fallback: display thread unavailable, render inline. */
+ display_frame_x11(&ctx->display, frame);
+ for (int m = 0; m < ctx->num_mirror_displays; m++)
+ if (!display_copy_x11(&ctx->mirror_display[m], &ctx->display))
+ display_frame_x11(&ctx->mirror_display[m], frame);
+ process_x11_events(&ctx->display);
+ for (int m = 0; m < ctx->num_mirror_displays; m++)
+ process_x11_events(&ctx->mirror_display[m]);
+ }
+ }
+
+ /* Return the frame buffer to MTL so it can be reused for the next frame.
+ * Frames handed to the display thread are returned by it instead. */
+ if (!handed_off) st20p_rx_put_frame(ctx->handle, frame);
+
+ /* Periodic progress log — printed every 50 frames to avoid console spam */
+ if (ctx->frames_received % 50 == 0) {
+ log_info(LOGGER_TAG, "ST20P session %d: received %d frames",
+ ctx->idx, ctx->frames_received);
+ }
+ }
+
+ log_info(LOGGER_TAG, "ST20P RX thread %d stopped, received %d frames", ctx->idx, ctx->frames_received);
+
+ /* Stop and clean up the dedicated display thread (single-session mode). */
+ if (ctx->disp_thread_started) {
+ pthread_mutex_lock(&ctx->disp_lock);
+ pthread_cond_signal(&ctx->disp_cond); /* wake it so it observes app->exit */
+ pthread_mutex_unlock(&ctx->disp_lock);
+ pthread_join(ctx->disp_thread, NULL);
+ ctx->disp_thread_started = false;
+ if (ctx->disp_pending) {
+ st20p_rx_put_frame(ctx->handle, ctx->disp_pending);
+ ctx->disp_pending = NULL;
+ }
+ pthread_mutex_destroy(&ctx->disp_lock);
+ pthread_cond_destroy(&ctx->disp_cond);
+ }
+
+ return NULL;
+}
+
+/*
+ * get_transport_format - map a decoded pixel format to the ST2110-20
+ * on-wire transport format.
+ *
+ * MTL requires callers to specify the compressed transport packing separately
+ * from the decoded output format. This helper centralises that mapping so
+ * it doesn't have to be repeated at each session creation call site.
+ */
+static enum st20_fmt get_transport_format(enum st_frame_fmt frame_fmt) {
+ switch (frame_fmt) {
+ case ST_FRAME_FMT_YUV422PLANAR10LE:
+ return ST20_FMT_YUV_422_10BIT;
+ case ST_FRAME_FMT_YUV420PLANAR8:
+ return ST20_FMT_YUV_420_8BIT;
+ case ST_FRAME_FMT_YUV420CUSTOM8:
+ return ST20_FMT_YUV_420_8BIT;
+ case ST_FRAME_FMT_YUV422PLANAR12LE:
+ return ST20_FMT_YUV_422_12BIT;
+ case ST_FRAME_FMT_YUV444PLANAR10LE:
+ return ST20_FMT_YUV_444_10BIT;
+ case ST_FRAME_FMT_YUV444PLANAR12LE:
+ return ST20_FMT_YUV_444_12BIT;
+ case ST_FRAME_FMT_GBRPLANAR10LE:
+ return ST20_FMT_RGB_10BIT;
+ case ST_FRAME_FMT_GBRPLANAR12LE:
+ return ST20_FMT_RGB_12BIT;
+ default:
+ log_warn(LOGGER_TAG, "Unknown frame format %d, using YUV_422_10BIT", frame_fmt);
+ return ST20_FMT_YUV_422_10BIT;
+ }
+}
+
+/* Create ST20P session */
+static int create_st20p_session(struct rx_app_context* app, struct st20p_rx_ctx* ctx, int session_idx) {
+ struct st20p_rx_ops ops;
+ bool vf_mode = is_pci_virtual_function(ctx->port_name);
+
+ memset(&ops, 0, sizeof(ops));
+
+ /* Session identification */
+ snprintf(ctx->session_name, sizeof(ctx->session_name), "st20p_rx_%d", session_idx);
+ ops.name = ctx->session_name;
+ ops.priv = ctx;
+
+ /* Network configuration (from the session's JSON rx_sessions entry). */
+ ops.port.num_port = 1;
+ memcpy(ops.port.ip_addr[MTL_SESSION_PORT_P], ctx->dip_addr, MTL_IP_ADDR_LEN);
+ snprintf(ops.port.port[MTL_SESSION_PORT_P], MTL_PORT_MAX_LEN, "%s", ctx->port_name);
+ ops.port.udp_port[MTL_SESSION_PORT_P] = ctx->session_udp_port;
+ ops.port.payload_type = ctx->session_payload_type;
+
+ /* Video parameters.
+ * When a crop region is defined, the stream carries crop.w × crop.h. */
+ if (ctx->crop.w > 0 && ctx->crop.h > 0) {
+ ops.width = (uint32_t)ctx->crop.w;
+ ops.height = (uint32_t)ctx->crop.h;
+ } else {
+ ops.width = app->width;
+ ops.height = app->height;
+ }
+ ops.fps = app->fps;
+ ops.transport_fmt = (app->transport_fmt != 0)
+ ? (enum st20_fmt)app->transport_fmt
+ : get_transport_format(app->fmt);
+ ops.output_fmt = app->fmt;
+
+ /* Device and flags */
+ ops.device = ST_PLUGIN_DEVICE_AUTO;
+ ops.framebuff_cnt = 8;
+ ops.flags = ST20P_RX_FLAG_BLOCK_GET | ST20P_RX_FLAG_RECEIVE_INCOMPLETE_FRAME;
+
+ /* Create RX handle */
+ ctx->handle = st20p_rx_create(app->mtl, &ops);
+ if (!ctx->handle) {
+ log_error(LOGGER_TAG, "Failed to create ST20P RX session %d", session_idx);
+ log_error(LOGGER_TAG, "Possible causes: insufficient RX queues, network configuration, or DPDK setup");
+ if (vf_mode) {
+ log_error(LOGGER_TAG,
+ "Detected PCI VF on %s. Current MTL build may not support rte_flow for ixgbe VF.",
+ ctx->port_name);
+ log_error(LOGGER_TAG,
+ "Use PF/iavf-capable NIC path, or rebuild/install MTL with explicit net_ixgbe_vf no-flow handling.");
+ }
+ log_error(LOGGER_TAG, "Check MTL logs above for detailed error information");
+ return -1;
+ }
+
+ ctx->frame_size = st20p_rx_frame_size(ctx->handle);
+ log_info(LOGGER_TAG, "ST20P RX session %d created, frame size: %zu bytes", session_idx, ctx->frame_size);
+
+ /* Display initialisation — two mutually exclusive code paths:
+ *
+ * Single-session (st20p_sessions == 1):
+ * Normal deployment on a 3-receiver wall. Each receiver process has
+ * exactly one ST20P session and opens its own dedicated X11 window
+ * sized to the panel resolution (e.g. 640x1080). Frames are rendered
+ * directly via display_frame_x11() in st20p_rx_thread().
+ *
+ * Multi-session (st20p_sessions > 1):
+ * Legacy single-machine composite mode. A shared composite_x11_ctx is
+ * allocated in session_manager_start() and wired into every session's
+ * ctx->composite pointer. Each thread writes its strip via
+ * composite_write_strip() and a pthread_barrier synchronises the flush.
+ * The per-session x11_display_ctx is not used in this path.
+ *
+ * display_mode "per_output" (any session count):
+ * Each session gets its own borderless fullscreen window on the XRandR
+ * output named in rx_sessions[i].output, or the i-th active monitor.
+ * rx_sessions[i].window switches it to a fixed-size window on that output. */
+ bool per_output = is_per_output_mode(app);
+ if (app->st20p_sessions == 1 || per_output) {
+ /* For single-session: use crop dimensions if available, else full video */
+ uint32_t disp_w = (ctx->crop.w > 0) ? (uint32_t)ctx->crop.w : app->width;
+ uint32_t disp_h = (ctx->crop.h > 0) ? (uint32_t)ctx->crop.h : app->height;
+ const char* output_name = NULL;
+ struct crop_config window_rect;
+ const struct crop_config* window_ptr = NULL;
+ if (session_idx < app->json_config.num_rx_sessions) {
+ const struct rx_session_config* sc = &app->json_config.rx_sessions[session_idx];
+ bool has_scale = sc->scale_w > 0 && sc->scale_h > 0;
+ output_name = sc->output;
+ if (has_scale) {
+ disp_w = (uint32_t)sc->scale_w;
+ disp_h = (uint32_t)sc->scale_h;
+ }
+ if (sc->has_window || has_scale) {
+ /* window gives the position; scale (if set) gives the size. */
+ window_rect = sc->has_window ? sc->window : (struct crop_config){0, 0, 0, 0};
+ if (has_scale) {
+ window_rect.w = sc->scale_w;
+ window_rect.h = sc->scale_h;
+ }
+ if (window_rect.w == 0) window_rect.w = (int)disp_w;
+ if (window_rect.h == 0) window_rect.h = (int)disp_h;
+ window_ptr = &window_rect;
+ }
+ }
+ if (init_x11_display(&ctx->display, disp_w, disp_h, session_idx,
+ output_name, per_output, window_ptr) < 0) {
+ log_warn(LOGGER_TAG, "Failed to initialize X11 display for session %d", session_idx);
+ /* Continue without display */
+ }
+
+ if (per_output && session_idx < app->json_config.num_rx_sessions) {
+ const struct rx_session_config* sc = &app->json_config.rx_sessions[session_idx];
+ for (int m = 0; m < sc->num_mirrors; m++) {
+ const struct display_placement* pl = &sc->mirrors[m];
+ struct crop_config rect = pl->window;
+ if (pl->scale_w > 0 && pl->scale_h > 0) {
+ rect.w = pl->scale_w;
+ rect.h = pl->scale_h;
+ }
+ if (rect.w == 0) rect.w = (ctx->crop.w > 0) ? ctx->crop.w : (int)app->width;
+ if (rect.h == 0) rect.h = (ctx->crop.h > 0) ? ctx->crop.h : (int)app->height;
+ struct x11_display_ctx* md = &ctx->mirror_display[ctx->num_mirror_displays];
+ if (init_x11_display(md, (uint32_t)rect.w, (uint32_t)rect.h, session_idx,
+ pl->output, true, &rect) == 0) {
+ ctx->num_mirror_displays++;
+ } else {
+ log_warn(LOGGER_TAG, "Failed to mirror session %d on %s", session_idx, pl->output);
+ }
+ }
+ }
+ }
+
+ return 0;
+}
+
+/*
+ * session_manager_init - initialise the MTL library and prepare the session
+ * manager structure.
+ *
+ * Binds MTL to every NIC in the JSON "interfaces" array, each with its own
+ * source IP, and provisions RX queues per NIC for the sessions on it plus a
+ * small reserve for internal MTL use.
+ *
+ * Must be called before session_manager_start().
+ * Returns 0 on success, -1 on failure.
+ */
+int session_manager_init(session_manager_t* manager, struct rx_app_context* app) {
+ struct mtl_init_params mtl_params;
+ bool any_vf = false;
+ int num_ports;
+ uint16_t sessions_on_port[MTL_PORT_MAX] = {0};
+
+ if (!manager || !app) {
+ return -1;
+ }
+
+ memset(manager, 0, sizeof(*manager));
+
+ /* One MTL port per JSON interface (MTL port index == position in the array). */
+ const struct rx_config* cfg = &app->json_config;
+ num_ports = cfg->num_interfaces;
+
+ for (int i = 0; i < app->st20p_sessions; i++) {
+ int p = rx_config_port_for_nic(cfg, cfg->rx_sessions[i].nic_index);
+ if (p >= 0 && p < num_ports) sessions_on_port[p]++;
+ }
+
+ memset(&mtl_params, 0, sizeof(mtl_params));
+ mtl_params.num_ports = (uint8_t)num_ports;
+ mtl_params.log_level = MTL_LOG_LEVEL_INFO;
+ mtl_params.flags |= MTL_FLAG_DEV_AUTO_START_STOP;
+ mtl_params.flags |= MTL_FLAG_RX_VIDEO_MIGRATE;
+ if (cfg->tasklet_sleep) {
+ /* Scheduler sleeps when idle; RX sessions cap it at ~128 packet times. */
+ mtl_params.flags |= MTL_FLAG_TASKLET_SLEEP;
+ log_info(LOGGER_TAG, "MTL tasklet sleep enabled");
+ }
+ mtl_params.nb_rx_desc = 4096;
+
+ for (int p = 0; p < num_ports; p++) {
+ const char* name = cfg->interfaces[p].name;
+ uint8_t sip[MTL_IP_ADDR_LEN];
+ inet_pton(AF_INET, cfg->interfaces[p].sip, sip); /* validated at load */
+
+ snprintf(mtl_params.port[p], MTL_PORT_MAX_LEN, "%s", name);
+ memcpy(mtl_params.sip_addr[p], sip, MTL_IP_ADDR_LEN);
+ mtl_params.pmd[p] = mtl_pmd_by_port_name(name);
+
+ bool vf = is_pci_virtual_function(name);
+ if (vf) {
+ any_vf = true;
+ log_warn(LOGGER_TAG, "VF NIC detected on %s%s", name,
+ is_pci_bound_to_vfio(name) ? " (vfio-pci)" : "");
+ /* ixgbe rejects the VF's promiscuous request unless the parent PF is
+ * already promiscuous, so prepare it before mtl_init(). */
+ prepare_vf_parent_pf(name);
+ }
+
+ /* One RX queue per session + 2 for MTL housekeeping (ARP, PTP, ...).
+ * VF/CNI mode uses only the system queue; extra queues would let RSS
+ * scatter packets away from it. */
+ uint16_t rx_queues = vf ? 0 : (uint16_t)(sessions_on_port[p] + 2);
+ mtl_para_rx_queues_cnt_set(&mtl_params, (enum mtl_port)p, rx_queues);
+
+ log_info(LOGGER_TAG, "MTL port %d: %s sip %s, %u session(s), %u RX queues%s",
+ p, name, cfg->interfaces[p].sip,
+ sessions_on_port[p], rx_queues, vf ? " (VF)" : "");
+ }
+
+ if (any_vf) {
+ /* Mirror RxTxApp VF-friendly path: force CNI RX instead of per-session rte_flow. */
+ mtl_params.flags |= MTL_FLAG_RX_USE_CNI;
+ mtl_params.flags |= MTL_FLAG_RX_UDP_PORT_ONLY;
+ mtl_params.flags |= MTL_FLAG_NIC_RX_PROMISCUOUS;
+ log_warn(LOGGER_TAG, "VF NIC(s) present: enabling rx_use_cni + rx_udp_port_only + promiscuous");
+ }
+
+ manager->mtl = mtl_init(&mtl_params);
+ if (!manager->mtl) {
+ log_error(LOGGER_TAG, "MTL initialization failed");
+ return -1;
+ }
+
+ app->mtl = manager->mtl; /* Back-fill app context so session creators can reach the handle */
+
+ log_info(LOGGER_TAG, "MTL initialized successfully with %d port(s)", num_ports);
+ log_info(LOGGER_TAG, "Video format: %dx%d, FPS: %d", app->width, app->height,
+ (app->fps == ST_FPS_P25) ? 25 : (app->fps == ST_FPS_P30) ? 30 :
+ (app->fps == ST_FPS_P50) ? 50 : 60);
+ log_info(LOGGER_TAG, "ST20P sessions: %d", app->st20p_sessions);
+
+ manager->st20p_count = app->st20p_sessions;
+
+ return 0;
+}
+
+static const struct x11_display_ctx* session_window(const struct st20p_rx_ctx* s, int w) {
+ return w == 0 ? &s->display : &s->mirror_display[w - 1];
+}
+
+/* One block per display output listing every session window placed on it. */
+static void log_display_layout(const session_manager_t* manager) {
+ const char* seen[MAX_RX_SESSIONS * (MAX_MIRRORS + 1)];
+ int nseen = 0;
+
+ log_info(LOGGER_TAG, "Display layout:");
+ for (int i = 0; i < manager->st20p_count; i++) {
+ const struct st20p_rx_ctx* s = &manager->st20p_sessions[i];
+ for (int w = 0; w <= s->num_mirror_displays; w++) {
+ const struct x11_display_ctx* d = session_window(s, w);
+ if (!d->initialized) continue;
+ bool dup = false;
+ for (int k = 0; k < nseen && !dup; k++) dup = strcmp(seen[k], d->output_name) == 0;
+ if (dup || nseen >= (int)(sizeof(seen) / sizeof(seen[0]))) continue;
+ seen[nseen++] = d->output_name;
+
+ log_info(LOGGER_TAG, " Output %s:", d->output_name);
+ for (int j = 0; j < manager->st20p_count; j++) {
+ const struct st20p_rx_ctx* t = &manager->st20p_sessions[j];
+ for (int v = 0; v <= t->num_mirror_displays; v++) {
+ const struct x11_display_ctx* e = session_window(t, v);
+ if (!e->initialized || strcmp(e->output_name, d->output_name) != 0) continue;
+ log_info(LOGGER_TAG, " session %d: window %ux%u at (%d,%d) <- stream %dx%d (crop %d,%d)%s",
+ j, e->width, e->height, e->out_x, e->out_y, t->crop.w, t->crop.h,
+ t->crop.x, t->crop.y, v ? " [mirror]" : "");
+ }
+ }
+ }
+ }
+ for (int i = 0; i < manager->st20p_count; i++) {
+ if (!manager->st20p_sessions[i].display.initialized)
+ log_warn(LOGGER_TAG, " session %d: no window (display init failed)", i);
+ }
+}
+
+/*
+ * session_manager_start - allocate and launch all ST20P RX sessions.
+ *
+ * For ST20P sessions:
+ * - In single-session mode (st20p_count == 1, normal wall deployment):
+ * Creates one session, opens a dedicated per-receiver X11 window.
+ * - In multi-session mode (st20p_count > 1, legacy composite mode):
+ * Creates N sessions and a shared composite X11 window.
+ *
+ * Each session gets its own receive thread that blocks on st20p_rx_get_frame().
+ * Returns 0 on success, -1 on any failure.
+ */
+int session_manager_start(session_manager_t* manager, struct rx_app_context* app) {
+ if (!manager || !app) {
+ return -1;
+ }
+
+ /* --- ST20P (video) sessions ------------------------------------------- */
+ if (manager->st20p_count > 0) {
+ manager->st20p_sessions = calloc(manager->st20p_count, sizeof(struct st20p_rx_ctx));
+ if (!manager->st20p_sessions) {
+ log_error(LOGGER_TAG, "Failed to allocate ST20P contexts");
+ return -1;
+ }
+ /* Set early so session_manager_stop() also joins sessions started before a failure. */
+ manager->running = true;
+
+ /* Display setup — chosen based on session count:
+ *
+ * st20p_count == 1 (single display mode):
+ * composite stays NULL. Dedicated X11 window initialised inside
+ * create_st20p_session().
+ *
+ * st20p_count > 1 (multi-display / crop mode from JSON config):
+ * Compute the bounding box of all crop regions and create a composite
+ * window of exactly that size. Each session thread writes its crop
+ * region, waits at a barrier, then one thread flushes the display. */
+ if (manager->st20p_count > 1 && !is_per_output_mode(app)) {
+ manager->composite = calloc(1, sizeof(struct composite_x11_ctx));
+ if (!manager->composite) {
+ log_error(LOGGER_TAG, "Failed to allocate composite display context");
+ return -1;
+ }
+
+ /* Compute bounding box from all crop regions */
+ uint32_t comp_width = app->width;
+ uint32_t comp_height = app->height;
+
+ if (app->json_config.num_rx_sessions > 0) {
+ uint32_t max_x = 0, max_y = 0;
+ for (int i = 0; i < app->json_config.num_rx_sessions; i++) {
+ const struct crop_config* c = &app->json_config.rx_sessions[i].crop;
+ uint32_t right = (uint32_t)(c->x + c->w);
+ uint32_t bottom = (uint32_t)(c->y + c->h);
+ if (right > max_x) max_x = right;
+ if (bottom > max_y) max_y = bottom;
+ }
+ if (max_x > 0) comp_width = max_x;
+ if (max_y > 0) comp_height = max_y;
+ log_info(LOGGER_TAG, "Composite bounding box: %ux%u (from %d crop regions)",
+ comp_width, comp_height, app->json_config.num_rx_sessions);
+ }
+
+ if (init_composite_x11_display(manager->composite,
+ comp_width, comp_height,
+ manager->st20p_count) < 0) {
+ log_warn(LOGGER_TAG, "Failed to initialise composite X11 display");
+ free(manager->composite);
+ manager->composite = NULL;
+ }
+ }
+
+ for (int i = 0; i < manager->st20p_count; i++) {
+ manager->st20p_sessions[i].app = app;
+ manager->st20p_sessions[i].idx = i;
+ /* Wire composite pointer (NULL for single-session) */
+ manager->st20p_sessions[i].composite = manager->composite;
+
+ /* NIC, multicast group, port and crop from the JSON rx_sessions entry. */
+ const struct rx_session_config* ts = &app->json_config.rx_sessions[i];
+ int p = rx_config_port_for_nic(&app->json_config, ts->nic_index);
+ const struct interface_config* ifc = &app->json_config.interfaces[p]; /* validated */
+ snprintf(manager->st20p_sessions[i].port_name, MTL_PORT_MAX_LEN, "%s", ifc->name);
+ inet_pton(AF_INET, ifc->dip, manager->st20p_sessions[i].dip_addr);
+ manager->st20p_sessions[i].session_udp_port = (uint16_t)ts->udp_port;
+ manager->st20p_sessions[i].session_payload_type = (uint8_t)ts->payload_type;
+ manager->st20p_sessions[i].crop = ts->crop;
+ log_info(LOGGER_TAG, "Session %d: nic %d (%s) %s:%d, payload %d, crop (%d,%d %dx%d)",
+ i, ts->nic_index, ifc->name, ifc->dip, ts->udp_port, ts->payload_type,
+ ts->crop.x, ts->crop.y, ts->crop.w, ts->crop.h);
+
+ if (create_st20p_session(app, &manager->st20p_sessions[i], i) < 0) {
+ return -1;
+ }
+
+ if (pthread_create(&manager->st20p_sessions[i].thread, NULL, st20p_rx_thread, &manager->st20p_sessions[i]) != 0) {
+ log_error(LOGGER_TAG, "Failed to create ST20P thread %d", i);
+ return -1;
+ }
+ }
+ }
+
+ manager->running = true;
+ if (!manager->composite) log_display_layout(manager);
+ log_info(LOGGER_TAG, "All RX sessions started successfully");
+
+ return 0;
+}
+
+/*
+ * session_manager_stop - gracefully stop all running sessions.
+ *
+ * Wakes any threads blocked in st20p_rx_get_frame(), waits for each
+ * thread to exit, then frees the MTL session handles and display contexts.
+ * Safe to call more than once (guarded by manager->running).
+ */
+void session_manager_stop(session_manager_t* manager) {
+ if (!manager || !manager->running) {
+ return;
+ }
+
+ manager->running = false;
+
+ /* A session may wait at the composite barrier for a peer that stopped or never started. */
+ composite_abort(manager->composite);
+
+ /* --- Stop ST20P (video) sessions -------------------------------------- */
+ if (manager->st20p_sessions) {
+ for (int i = 0; i < manager->st20p_count; i++) {
+ if (manager->st20p_sessions[i].handle) {
+ /* Unblock st20p_rx_get_frame() so the thread observes app->exit */
+ st20p_rx_wake_block(manager->st20p_sessions[i].handle);
+
+ if (manager->st20p_sessions[i].thread) {
+ pthread_join(manager->st20p_sessions[i].thread, NULL);
+ }
+
+ st20p_rx_free(manager->st20p_sessions[i].handle);
+
+ /* Per-session display (single-session mode only) */
+ cleanup_x11_display(&manager->st20p_sessions[i].display);
+ for (int m = 0; m < manager->st20p_sessions[i].num_mirror_displays; m++)
+ cleanup_x11_display(&manager->st20p_sessions[i].mirror_display[m]);
+ }
+ }
+
+ /* Composite display (multi-session mode) */
+ if (manager->composite) {
+ cleanup_composite_x11_display(manager->composite);
+ free(manager->composite);
+ manager->composite = NULL;
+ }
+ }
+}
+
+/*
+ * session_manager_cleanup - full teardown of the session manager.
+ *
+ * Calls session_manager_stop() to join threads and free session handles,
+ * then releases the session context arrays and shuts down the MTL library
+ * (which releases DPDK resources and hugepage mappings).
+ */
+void session_manager_cleanup(session_manager_t* manager) {
+ if (!manager) {
+ return;
+ }
+
+ /* Stop and join all session threads; free handles and display contexts */
+ session_manager_stop(manager);
+
+ /* Release the context arrays that were allocated in session_manager_start() */
+ if (manager->st20p_sessions) {
+ free(manager->st20p_sessions);
+ manager->st20p_sessions = NULL;
+ }
+
+ /* Shut down MTL — releases DPDK port, hugepages, and kernel NIC binding */
+ if (manager->mtl) {
+ mtl_uninit(manager->mtl);
+ manager->mtl = NULL;
+ }
+
+ manager->st20p_count = 0;
+ manager->running = false;
+}
+
+/* Check if sessions are running */
+bool session_manager_is_running(const session_manager_t* manager) {
+ return manager && manager->running;
+}
\ No newline at end of file
diff --git a/dvledrx/src/x11_handler.c b/dvledrx/src/x11_handler.c
new file mode 100644
index 0000000..6d60fa6
--- /dev/null
+++ b/dvledrx/src/x11_handler.c
@@ -0,0 +1,1455 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include "x11_handler.h"
+#include "config_reader.h"
+#include "frame_converter.h"
+#include "logger.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Include MTL headers for st_frame definition
+#include "st_pipeline_api.h"
+
+#define LOGGER_TAG "x11"
+
+/* X11 Error handler */
+static int x11_error_handler(Display *display, XErrorEvent *error) {
+ char error_text[256];
+ XGetErrorText(display, error->error_code, error_text, sizeof(error_text));
+
+ log_error(LOGGER_TAG, "X11 Error: %s", error_text);
+ log_error(LOGGER_TAG, " Request: %d (0x%02x)", error->request_code, error->request_code);
+ log_error(LOGGER_TAG, " Minor code: %d", error->minor_code);
+ log_error(LOGGER_TAG, " Resource ID: 0x%lx", error->resourceid);
+ log_error(LOGGER_TAG, " Serial: %lu", error->serial);
+
+ // Don't exit on error, just log it
+ return 0;
+}
+
+/* X11 IO Error handler */
+static int x11_io_error_handler(Display *display) {
+ log_error(LOGGER_TAG, "X11 IO Error: Connection lost to X server");
+ exit(1);
+}
+
+/* ------------------------------------------------------------------
+ * MIT-SHM image buffers
+ * ------------------------------------------------------------------ */
+static int shm_major_opcode = -1;
+static volatile int shm_attach_failed;
+
+static int shm_attach_error_handler(Display* dpy, XErrorEvent* error) {
+ if (error->request_code == shm_major_opcode) {
+ shm_attach_failed = 1;
+ return 0;
+ }
+ return x11_error_handler(dpy, error);
+}
+
+/* Try to create a w x h XImage backed by a SysV shared memory segment that
+ * the X server attaches to. Only called from the main thread. */
+static XImage* create_shm_image(Display* dpy, Visual* vis, int depth,
+ uint32_t w, uint32_t h, XShmSegmentInfo** out_shm) {
+ int ev_base, err_base;
+ if (!XShmQueryExtension(dpy) ||
+ !XQueryExtension(dpy, "MIT-SHM", &shm_major_opcode, &ev_base, &err_base)) {
+ return NULL;
+ }
+
+ XShmSegmentInfo* shm = calloc(1, sizeof(*shm));
+ if (!shm) return NULL;
+
+ XImage* img = XShmCreateImage(dpy, vis, (unsigned)depth, ZPixmap, NULL, shm, w, h);
+ if (!img) {
+ free(shm);
+ return NULL;
+ }
+ /* Converter and scaler assume a tightly packed 32-bit BGRA row. */
+ if (img->bits_per_pixel != 32 || img->bytes_per_line != (int)(w * 4)) {
+ XDestroyImage(img);
+ free(shm);
+ return NULL;
+ }
+
+ shm->shmid = shmget(IPC_PRIVATE, (size_t)img->bytes_per_line * img->height, IPC_CREAT | 0600);
+ if (shm->shmid < 0) {
+ XDestroyImage(img);
+ free(shm);
+ return NULL;
+ }
+ /* Rootless Xorg runs as the desktop user; under sudo give it the segment. */
+ const char* sudo_uid = getenv("SUDO_UID");
+ if (geteuid() == 0 && sudo_uid) {
+ char* end = NULL;
+ unsigned long uid = strtoul(sudo_uid, &end, 10);
+ struct shmid_ds ds;
+ if (end && *end == '\0' && shmctl(shm->shmid, IPC_STAT, &ds) == 0) {
+ ds.shm_perm.uid = (uid_t)uid;
+ shmctl(shm->shmid, IPC_SET, &ds);
+ }
+ }
+ shm->shmaddr = img->data = shmat(shm->shmid, NULL, 0);
+ if (shm->shmaddr == (char*)-1) {
+ shmctl(shm->shmid, IPC_RMID, NULL);
+ img->data = NULL;
+ XDestroyImage(img);
+ free(shm);
+ return NULL;
+ }
+ shm->readOnly = False;
+
+ XSync(dpy, False);
+ shm_attach_failed = 0;
+ int (*prev_handler)(Display*, XErrorEvent*) = XSetErrorHandler(shm_attach_error_handler);
+ Status ok = XShmAttach(dpy, shm);
+ XSync(dpy, False);
+ XSetErrorHandler(prev_handler);
+ /* Mark for removal now so the segment is freed even if we crash. */
+ shmctl(shm->shmid, IPC_RMID, NULL);
+
+ if (!ok || shm_attach_failed) {
+ shmdt(shm->shmaddr);
+ img->data = NULL;
+ XDestroyImage(img);
+ free(shm);
+ return NULL;
+ }
+
+ *out_shm = shm;
+ return img;
+}
+
+static void destroy_image_buffer(Display* dpy, XImage* img, char* data, XShmSegmentInfo* shm) {
+ if (shm) {
+ XShmDetach(dpy, shm);
+ XSync(dpy, False); /* server must detach before we unmap */
+ if (img) {
+ img->data = NULL;
+ XDestroyImage(img);
+ }
+ shmdt(shm->shmaddr);
+ free(shm);
+ } else {
+ if (img) {
+ img->data = NULL; /* we own data */
+ XDestroyImage(img);
+ }
+ free(data);
+ }
+}
+
+/* Allocate display->ximage/image_data of w x h, preferring MIT-SHM. */
+static int create_image_buffer(struct x11_display_ctx* display, uint32_t w, uint32_t h) {
+ Display* dpy = (Display*)display->display;
+ Visual* vis = (Visual*)display->visual;
+ XShmSegmentInfo* shm = NULL;
+ XImage* img = NULL;
+
+ if (display->use_shm) {
+ img = create_shm_image(dpy, vis, display->depth, w, h, &shm);
+ if (!img) {
+ log_warn(LOGGER_TAG, "MIT-SHM unavailable (remote display?), falling back to XPutImage");
+ display->use_shm = false;
+ }
+ }
+
+ if (!img) {
+ char* data = malloc((size_t)w * h * 4);
+ if (!data) {
+ log_error(LOGGER_TAG, "Cannot allocate image buffer (%ux%u)", w, h);
+ return -1;
+ }
+ img = XCreateImage(dpy, vis, (unsigned)display->depth, ZPixmap, 0, data, w, h, 32, (int)(w * 4));
+ if (!img) {
+ log_error(LOGGER_TAG, "Cannot create XImage (depth=%d, size=%ux%u)", display->depth, w, h);
+ free(data);
+ return -1;
+ }
+ }
+
+ memset(img->data, 0, (size_t)w * h * 4);
+ if (display->depth == 32) { /* ARGB window: start opaque black, not transparent */
+ uint32_t* p = (uint32_t*)img->data;
+ for (size_t i = 0; i < (size_t)w * h; i++) p[i] = 0xFF000000u;
+ }
+ display->ximage = img;
+ display->image_data = img->data;
+ display->shminfo = shm;
+ display->width = w;
+ display->height = h;
+ return 0;
+}
+
+/* Blit the whole image to the window. Caller must hold display->xlock. */
+static void put_image_locked(struct x11_display_ctx* display) {
+ Display* dpy = (Display*)display->display;
+ Window win = (Window)(uintptr_t)display->window;
+ GC gc = (GC)display->gc;
+ XImage* img = (XImage*)display->ximage;
+
+ if (!img) return;
+ if (display->shminfo) {
+ XShmPutImage(dpy, win, gc, img, 0, 0, 0, 0, display->width, display->height, False);
+ /* Server reads the segment asynchronously; wait so the next frame cannot
+ * overwrite it mid-read. Runs on the display thread, not the RX thread. */
+ XSync(dpy, False);
+ } else {
+ XPutImage(dpy, win, gc, img, 0, 0, 0, 0, display->width, display->height);
+ XFlush(dpy);
+ }
+}
+
+/* ------------------------------------------------------------------
+ * XRandR output lookup / fullscreen placement
+ * ------------------------------------------------------------------ */
+
+/* Find an active monitor by XRandR output name, or by index when name is
+ * empty. mon_idx receives the monitor's Xinerama-order index. */
+static int find_output_geometry(Display* dpy, Window root, const char* name, int index,
+ int* x, int* y, int* w, int* h, int* mon_idx,
+ char* matched, size_t matched_len) {
+ int ev_base, err_base;
+ if (!XRRQueryExtension(dpy, &ev_base, &err_base)) {
+ log_error(LOGGER_TAG, "XRandR extension not available on this X server");
+ return -1;
+ }
+
+ int nmon = 0;
+ XRRMonitorInfo* mons = XRRGetMonitors(dpy, root, True, &nmon);
+ XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, root);
+ bool by_name = name && name[0];
+ int found = -1;
+
+ for (int m = 0; m < nmon; m++) {
+ for (int o = 0; o < mons[m].noutput; o++) {
+ XRROutputInfo* info = res ? XRRGetOutputInfo(dpy, res, mons[m].outputs[o]) : NULL;
+ const char* oname = info ? info->name : "?";
+ log_debug(LOGGER_TAG, "Monitor %d: output %s %dx%d+%d+%d", m, oname,
+ mons[m].width, mons[m].height, mons[m].x, mons[m].y);
+ if (found < 0 && ((by_name && strcmp(oname, name) == 0) || (!by_name && m == index))) {
+ found = m;
+ snprintf(matched, matched_len, "%s", oname);
+ }
+ if (info) XRRFreeOutputInfo(info);
+ }
+ }
+
+ if (found >= 0) {
+ *x = mons[found].x;
+ *y = mons[found].y;
+ *w = mons[found].width;
+ *h = mons[found].height;
+ *mon_idx = found;
+ } else {
+ log_warn(LOGGER_TAG, "No active monitor matches %s%s%s (index %d); %d active monitor(s)",
+ by_name ? "output '" : "", by_name ? name : "", by_name ? "'" : "", index, nmon);
+ }
+
+ if (res) XRRFreeScreenResources(res);
+ if (mons) XRRFreeMonitors(mons);
+ return found >= 0 ? 0 : -1;
+}
+
+/* Pre-map hints: fixed position/size, no decorations, EWMH fullscreen. */
+static void set_position_hints(Display* dpy, Window win, int x, int y, uint32_t w, uint32_t h) {
+ XSizeHints* hints = XAllocSizeHints();
+ if (hints) {
+ hints->flags = USPosition | USSize;
+ hints->x = x;
+ hints->y = y;
+ hints->width = (int)w;
+ hints->height = (int)h;
+ XSetWMNormalHints(dpy, win, hints);
+ XFree(hints);
+ }
+}
+
+static void set_decorations(Display* dpy, Window win, bool on) {
+ long motif[5] = {2 /* MWM_HINTS_DECORATIONS */, 0, on ? 1 /* MWM_DECOR_ALL */ : 0, 0, 0};
+ Atom motif_atom = XInternAtom(dpy, "_MOTIF_WM_HINTS", False);
+ XChangeProperty(dpy, win, motif_atom, motif_atom, 32, PropModeReplace,
+ (unsigned char*)motif, 5);
+}
+
+static void set_fullscreen_hints(Display* dpy, Window win, int x, int y, uint32_t w, uint32_t h) {
+ set_position_hints(dpy, win, x, y, w, h);
+ set_decorations(dpy, win, false);
+ Atom fs = XInternAtom(dpy, "_NET_WM_STATE_FULLSCREEN", False);
+ XChangeProperty(dpy, win, XInternAtom(dpy, "_NET_WM_STATE", False), XA_ATOM, 32,
+ PropModeReplace, (unsigned char*)&fs, 1);
+}
+
+/* Ask the WM to keep the fullscreen window on exactly one monitor. */
+static void pin_fullscreen_to_monitor(Display* dpy, int screen, Window win, int mon_idx) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xclient.type = ClientMessage;
+ ev.xclient.window = win;
+ ev.xclient.message_type = XInternAtom(dpy, "_NET_WM_FULLSCREEN_MONITORS", False);
+ ev.xclient.format = 32;
+ ev.xclient.data.l[0] = mon_idx; /* top */
+ ev.xclient.data.l[1] = mon_idx; /* bottom */
+ ev.xclient.data.l[2] = mon_idx; /* left */
+ ev.xclient.data.l[3] = mon_idx; /* right */
+ ev.xclient.data.l[4] = 1; /* source: normal application */
+ XSendEvent(dpy, RootWindow(dpy, screen), False,
+ SubstructureRedirectMask | SubstructureNotifyMask, &ev);
+}
+
+/* ------------------------------------------------------------------
+ * Wall mode <-> normal window (Escape)
+ * ------------------------------------------------------------------ */
+#define MAX_WALL_WINDOWS (MAX_RX_SESSIONS * (1 + MAX_MIRRORS)) /* primary + mirrors */
+
+static pthread_mutex_t g_wall_lock = PTHREAD_MUTEX_INITIALIZER;
+static struct x11_display_ctx* g_wall[MAX_WALL_WINDOWS];
+static int g_nwall;
+static bool g_all_windowed;
+static bool g_esc_grabbed;
+static bool g_close_requested;
+static bool g_esc_down;
+static long long g_esc_last_ms;
+
+static long long now_ms(void) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
+}
+
+bool x11_close_requested(void) {
+ return __atomic_load_n(&g_close_requested, __ATOMIC_RELAXED);
+}
+
+static void handle_close_message(Display* dpy, const XEvent* ev) {
+ if (ev->xclient.message_type == XInternAtom(dpy, "WM_PROTOCOLS", True) &&
+ (Atom)ev->xclient.data.l[0] == XInternAtom(dpy, "WM_DELETE_WINDOW", True)) {
+ if (!__atomic_exchange_n(&g_close_requested, true, __ATOMIC_RELAXED))
+ log_info(LOGGER_TAG, "Window closed by the user, stopping");
+ }
+}
+
+static void send_wm_state(Display* dpy, int screen, Window win, bool add, const char* state) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xclient.type = ClientMessage;
+ ev.xclient.window = win;
+ ev.xclient.message_type = XInternAtom(dpy, "_NET_WM_STATE", False);
+ ev.xclient.format = 32;
+ ev.xclient.data.l[0] = add ? 1 : 0; /* _NET_WM_STATE_ADD / _REMOVE */
+ ev.xclient.data.l[1] = (long)XInternAtom(dpy, state, False);
+ ev.xclient.data.l[3] = 1;
+ XSendEvent(dpy, RootWindow(dpy, screen), False,
+ SubstructureRedirectMask | SubstructureNotifyMask, &ev);
+}
+
+/* Windowed mode keeps the wall position and size; only the title bar is added. */
+static void windowed_geometry(const struct x11_display_ctx* d, int* x, int* y,
+ uint32_t* w, uint32_t* h) {
+ *x = d->mon_x + d->out_x;
+ *y = d->mon_y + d->out_y;
+ *w = d->home_w;
+ *h = d->home_h;
+}
+
+/* Caller holds g_wall_lock. */
+static void enter_windowed(struct x11_display_ctx* d) {
+ Display* dpy = (Display*)d->display;
+ Window win = (Window)(uintptr_t)d->window;
+ int x, y;
+ uint32_t w, h;
+ windowed_geometry(d, &x, &y, &w, &h);
+
+ pthread_mutex_lock(&d->xlock);
+ if (d->wall_kind == X11_WALL_FULLSCREEN) {
+ send_wm_state(dpy, d->screen, win, false, "_NET_WM_STATE_FULLSCREEN");
+ set_decorations(dpy, win, true);
+ set_position_hints(dpy, win, x, y, w, h);
+ XMoveResizeWindow(dpy, win, x, y, w, h);
+ } else {
+ /* An override-redirect window must be unmapped to become WM-managed. */
+ XUnmapWindow(dpy, win);
+ XSetWindowAttributes attrs;
+ attrs.override_redirect = False;
+ XChangeWindowAttributes(dpy, win, CWOverrideRedirect, &attrs);
+ set_decorations(dpy, win, true);
+ set_position_hints(dpy, win, x, y, w, h);
+ XMoveResizeWindow(dpy, win, x, y, w, h);
+ XMapRaised(dpy, win);
+ }
+ d->windowed = true;
+ XFlush(dpy);
+ pthread_mutex_unlock(&d->xlock);
+}
+
+/* Second half of leaving windowed mode for a fixed window, once the WM has
+ * released it (or after a timeout). Caller holds g_wall_lock. */
+static void finish_fixed_wall(struct x11_display_ctx* d) {
+ Display* dpy = (Display*)d->display;
+ Window win = (Window)(uintptr_t)d->window;
+ Window root = RootWindow(dpy, d->screen), parent = root, rret, *kids = NULL;
+ unsigned int nkids = 0;
+ int hx = d->mon_x + d->out_x, hy = d->mon_y + d->out_y;
+
+ pthread_mutex_lock(&d->xlock);
+ if (XQueryTree(dpy, win, &rret, &parent, &kids, &nkids) && kids) XFree(kids);
+ if (parent != root) XReparentWindow(dpy, win, root, hx, hy);
+ XSetWindowAttributes attrs;
+ attrs.override_redirect = True;
+ XChangeWindowAttributes(dpy, win, CWOverrideRedirect, &attrs);
+ XMoveResizeWindow(dpy, win, hx, hy, d->home_w, d->home_h);
+ XMapRaised(dpy, win);
+ XFlush(dpy);
+ pthread_mutex_unlock(&d->xlock);
+ d->wall_pending = false;
+ d->windowed = false;
+}
+
+/* Caller holds g_wall_lock. */
+static void leave_windowed(struct x11_display_ctx* d) {
+ Display* dpy = (Display*)d->display;
+ Window win = (Window)(uintptr_t)d->window;
+
+ pthread_mutex_lock(&d->xlock);
+ if (d->wall_kind == X11_WALL_FULLSCREEN) {
+ set_decorations(dpy, win, false);
+ XMapRaised(dpy, win); /* un-minimize */
+ send_wm_state(dpy, d->screen, win, true, "_NET_WM_STATE_FULLSCREEN");
+ pin_fullscreen_to_monitor(dpy, d->screen, win, d->monitor_idx);
+ d->windowed = false;
+ } else {
+ /* Withdraw (also works when minimized); the WM reparents it back to the
+ * root and finish_fixed_wall() makes it override-redirect again. */
+ XWithdrawWindow(dpy, win, d->screen);
+ d->wall_pending = true;
+ d->pending_ms = now_ms();
+ }
+ XFlush(dpy);
+ pthread_mutex_unlock(&d->xlock);
+}
+
+static void toggle_wall_mode(void) {
+ pthread_mutex_lock(&g_wall_lock);
+ for (int i = 0; i < g_nwall; i++) {
+ if (g_wall[i]->wall_pending) { /* previous toggle still in progress */
+ pthread_mutex_unlock(&g_wall_lock);
+ return;
+ }
+ }
+ g_all_windowed = !g_all_windowed;
+ for (int i = 0; i < g_nwall; i++) {
+ if (g_all_windowed) enter_windowed(g_wall[i]);
+ else leave_windowed(g_wall[i]);
+ }
+ int n = g_nwall;
+ pthread_mutex_unlock(&g_wall_lock);
+ log_info(LOGGER_TAG, "Escape: %d window(s) %s", n,
+ g_all_windowed ? "switched to normal windows (Escape again to go back)"
+ : "back to full-screen wall mode");
+}
+
+/* True only for a fresh Escape press, not keyboard auto-repeat. The 1 s timeout
+ * recovers if a KeyRelease is ever missed. */
+static bool escape_pressed(void) {
+ long long now = now_ms();
+ pthread_mutex_lock(&g_wall_lock);
+ bool fresh = !g_esc_down || now - g_esc_last_ms > 1000;
+ g_esc_down = true;
+ g_esc_last_ms = now;
+ pthread_mutex_unlock(&g_wall_lock);
+ return fresh;
+}
+
+static void escape_released(void) {
+ pthread_mutex_lock(&g_wall_lock);
+ g_esc_down = false;
+ pthread_mutex_unlock(&g_wall_lock);
+}
+
+/* Grab Escape on the root window so it works whichever window has focus. */
+static void grab_escape(struct x11_display_ctx* d) {
+ Display* dpy = (Display*)d->display;
+ Window root = RootWindow(dpy, d->screen);
+ KeyCode kc = XKeysymToKeycode(dpy, XK_Escape);
+ /* Also grab with Caps Lock / Num Lock on, or Escape is missed while they are lit. */
+ unsigned int mods[] = {0, LockMask, Mod2Mask, LockMask | Mod2Mask};
+ for (size_t i = 0; i < sizeof(mods) / sizeof(mods[0]); i++)
+ XGrabKey(dpy, kc, mods[i], root, True, GrabModeAsync, GrabModeAsync);
+ XFlush(dpy);
+ log_info(LOGGER_TAG, "Escape toggles the title bars from any window");
+}
+
+static void wall_register(struct x11_display_ctx* d) {
+ pthread_mutex_lock(&g_wall_lock);
+ if (g_nwall < MAX_WALL_WINDOWS) g_wall[g_nwall++] = d;
+ bool grab = !g_esc_grabbed;
+ g_esc_grabbed = true;
+ pthread_mutex_unlock(&g_wall_lock);
+ if (grab) grab_escape(d);
+}
+
+static void wall_unregister(struct x11_display_ctx* d) {
+ pthread_mutex_lock(&g_wall_lock);
+ for (int i = 0; i < g_nwall; i++) {
+ if (g_wall[i] == d) {
+ g_wall[i] = g_wall[--g_nwall];
+ break;
+ }
+ }
+ if (g_nwall == 0) g_all_windowed = false;
+ pthread_mutex_unlock(&g_wall_lock);
+}
+
+/* Initialize X11 display */
+int init_x11_display(struct x11_display_ctx* display, uint32_t width, uint32_t height,
+ int session_idx, const char* output_name, bool fullscreen_on_output,
+ const struct crop_config* window_rect) {
+ Display* x_display;
+ Window x_window;
+ GC x_gc;
+ Visual* x_visual;
+ char window_title[96];
+ int win_x = 100 + (session_idx * 50);
+ int win_y = 100 + (session_idx * 50);
+ int monitor_idx = -1;
+ char matched_output[32] = "";
+ bool fixed_window = false;
+
+ // Initialize structure
+ memset(display, 0, sizeof(*display));
+ pthread_mutex_init(&display->xlock, NULL);
+
+ // Check DISPLAY environment variable
+ const char* display_env = getenv("DISPLAY");
+ if (!display_env) {
+ log_error(LOGGER_TAG, "DISPLAY environment variable not set. X11 display not available.");
+ log_info(LOGGER_TAG, "Hint: Run 'export DISPLAY=:0' or use SSH with X11 forwarding (-X flag)");
+ return -1;
+ }
+
+ // Set up X11 error handlers
+ XSetErrorHandler(x11_error_handler);
+ XSetIOErrorHandler(x11_io_error_handler);
+
+ // Open X11 display connection
+ x_display = XOpenDisplay(NULL);
+ if (!x_display) {
+ log_error(LOGGER_TAG, "Cannot open X11 display '%s'", display_env);
+ log_info(LOGGER_TAG, "Hint: Check if X11 server is running or use SSH with X11 forwarding");
+ return -1;
+ }
+
+ display->display = x_display;
+ display->screen = DefaultScreen(x_display);
+ display->depth = DefaultDepth(x_display, display->screen);
+ /* Held keys repeat KeyPress only, without a KeyRelease between repeats. */
+ XkbSetDetectableAutoRepeat(x_display, True, NULL);
+ display->frame_width = width;
+ display->frame_height = height;
+ display->frame_scratch = NULL;
+
+ if (fullscreen_on_output) {
+ int mx = 0, my = 0, mw = 0, mh = 0;
+ if (find_output_geometry(x_display, RootWindow(x_display, display->screen),
+ output_name, session_idx, &mx, &my, &mw, &mh,
+ &monitor_idx, matched_output, sizeof(matched_output)) == 0) {
+ display->mon_x = mx;
+ display->mon_y = my;
+ display->mon_w = mw;
+ display->mon_h = mh;
+ if (window_rect) {
+ win_x = mx + window_rect->x;
+ win_y = my + window_rect->y;
+ display->out_x = window_rect->x;
+ display->out_y = window_rect->y;
+ width = (uint32_t)window_rect->w;
+ height = (uint32_t)window_rect->h;
+ fixed_window = true;
+ if (window_rect->x + window_rect->w > mw || window_rect->y + window_rect->h > mh) {
+ log_warn(LOGGER_TAG, "Session %d: window %d,%d %dx%d extends beyond %s (%dx%d)",
+ session_idx, window_rect->x, window_rect->y, window_rect->w,
+ window_rect->h, matched_output, mw, mh);
+ }
+ } else {
+ win_x = mx;
+ win_y = my;
+ width = (uint32_t)mw;
+ height = (uint32_t)mh;
+ }
+ } else {
+ /* A fallback window would land on another session's panel. */
+ log_warn(LOGGER_TAG, "Session %d: output %s not connected, no window will be shown",
+ session_idx, (output_name && output_name[0]) ? output_name : "(by index)");
+ XCloseDisplay(x_display);
+ display->display = NULL;
+ return -1;
+ }
+ }
+ bool on_output = fullscreen_on_output;
+ fullscreen_on_output = on_output && !fixed_window;
+
+ // Get visual info
+ x_visual = DefaultVisual(x_display, display->screen);
+ unsigned long black = BlackPixel(x_display, display->screen);
+ XVisualInfo argb;
+ bool use_argb = fixed_window &&
+ XMatchVisualInfo(x_display, display->screen, 32, TrueColor, &argb);
+ if (use_argb) {
+ /* Mutter draws a drop shadow around fixed windows (a dark band where two sessions
+ * meet) but never around ARGB windows; every pixel we write has alpha 255. */
+ x_visual = argb.visual;
+ display->depth = 32;
+ display->colormap = XCreateColormap(x_display, RootWindow(x_display, display->screen),
+ x_visual, AllocNone);
+ black = 0xFF000000UL;
+ }
+ display->visual = x_visual;
+
+ // Create window
+ if (on_output) {
+ snprintf(window_title, sizeof(window_title), "dvledrx - Session %d on %s",
+ session_idx, matched_output);
+ } else {
+ snprintf(window_title, sizeof(window_title), "dvledrx - Session %d (%ux%u)",
+ session_idx, width, height);
+ }
+
+ if (use_argb) {
+ XSetWindowAttributes wa;
+ wa.colormap = (Colormap)display->colormap;
+ wa.border_pixel = black;
+ wa.background_pixel = black;
+ x_window = XCreateWindow(x_display, RootWindow(x_display, display->screen),
+ win_x, win_y, width, height, 0, 32, InputOutput, x_visual,
+ CWColormap | CWBorderPixel | CWBackPixel, &wa);
+ } else {
+ x_window = XCreateSimpleWindow(x_display,
+ RootWindow(x_display, display->screen),
+ win_x, win_y,
+ width, height,
+ on_output ? 0 : 1, // border width
+ black, // border
+ black // background
+ );
+ }
+
+ if (!x_window) {
+ log_error(LOGGER_TAG, "Cannot create X11 window");
+ if (display->colormap) XFreeColormap(x_display, (Colormap)display->colormap);
+ XCloseDisplay(x_display);
+ return -1;
+ }
+
+ display->window = (void*)(uintptr_t)x_window;
+
+ // Set window properties
+ XStoreName(x_display, x_window, window_title);
+ XSelectInput(x_display, x_window,
+ ExposureMask | KeyPressMask | KeyReleaseMask | StructureNotifyMask);
+ Atom wm_delete = XInternAtom(x_display, "WM_DELETE_WINDOW", False);
+ XSetWMProtocols(x_display, x_window, &wm_delete, 1); /* close button: ClientMessage, not a kill */
+ if (fullscreen_on_output) {
+ set_fullscreen_hints(x_display, x_window, win_x, win_y, width, height);
+ } else if (fixed_window) {
+ /* Bypass the WM so it can't move the window under panels or resize it. */
+ XSetWindowAttributes attrs;
+ attrs.override_redirect = True;
+ XChangeWindowAttributes(x_display, x_window, CWOverrideRedirect, &attrs);
+ }
+
+ // Create graphics context
+ x_gc = XCreateGC(x_display, x_window, 0, NULL);
+ display->gc = x_gc;
+
+ // Image buffer uses the real screen depth (24 or 32) with a 4 bytes/pixel stride.
+ display->use_shm = true;
+ if (create_image_buffer(display, width, height) < 0) {
+ XFreeGC(x_display, x_gc);
+ XDestroyWindow(x_display, x_window);
+ XCloseDisplay(x_display);
+ return -1;
+ }
+
+ // Map window (make it visible)
+ XMapWindow(x_display, x_window);
+ if (fullscreen_on_output) {
+ pin_fullscreen_to_monitor(x_display, display->screen, x_window, monitor_idx);
+ } else if (fixed_window) {
+ XRaiseWindow(x_display, x_window);
+ }
+ XFlush(x_display);
+
+ display->initialized = true;
+ snprintf(display->output_name, sizeof(display->output_name), "%s",
+ on_output ? matched_output : "(window)");
+ display->monitor_idx = monitor_idx;
+ display->home_w = width;
+ display->home_h = height;
+ display->wall_kind = fullscreen_on_output ? X11_WALL_FULLSCREEN
+ : fixed_window ? X11_WALL_FIXED
+ : X11_WALL_NONE;
+ if (display->wall_kind != X11_WALL_NONE) wall_register(display);
+
+ log_info(LOGGER_TAG, "X11 display initialized: %ux%u+%d+%d, depth=%d, session=%d, output=%s (%s), shm=%s",
+ width, height, win_x, win_y, display->depth, session_idx,
+ on_output ? matched_output : "(window)",
+ fixed_window ? "fixed" : fullscreen_on_output ? "fullscreen" : "normal",
+ display->shminfo ? "yes" : "no");
+
+ return 0;
+}
+
+/* Cleanup X11 display */
+void cleanup_x11_display(struct x11_display_ctx* display) {
+ if (!display || !display->initialized) {
+ return;
+ }
+
+ Display* x_display = (Display*)display->display;
+ Window x_window = (Window)(uintptr_t)display->window;
+ GC x_gc = (GC)display->gc;
+
+ wall_unregister(display);
+ destroy_image_buffer(x_display, (XImage*)display->ximage, display->image_data,
+ (XShmSegmentInfo*)display->shminfo);
+
+ if (display->frame_scratch) {
+ free(display->frame_scratch);
+ }
+
+ if (x_gc) {
+ XFreeGC(x_display, x_gc);
+ }
+
+ if (x_window) {
+ XDestroyWindow(x_display, x_window);
+ }
+
+ if (display->colormap) {
+ XFreeColormap(x_display, (Colormap)display->colormap);
+ }
+
+ if (x_display) {
+ XCloseDisplay(x_display);
+ }
+
+ memset(display, 0, sizeof(*display));
+
+ log_info(LOGGER_TAG, "X11 display cleaned up");
+}
+
+/* Resize only XImage buffer to match the current X11 window size. */
+static int resize_x11_buffers(struct x11_display_ctx* display,
+ uint32_t new_width, uint32_t new_height) {
+ if (!display || !display->initialized || !new_width || !new_height) return -1;
+ if (new_width == display->width && new_height == display->height) return 0;
+
+ Display* x_display = (Display*)display->display;
+ XImage* old_image = (XImage*)display->ximage;
+ char* old_image_data = display->image_data;
+ XShmSegmentInfo* old_shm = (XShmSegmentInfo*)display->shminfo;
+ uint32_t old_w = display->width;
+ uint32_t old_h = display->height;
+
+ if (create_image_buffer(display, new_width, new_height) < 0) {
+ display->ximage = old_image;
+ display->image_data = old_image_data;
+ display->shminfo = old_shm;
+ display->width = old_w;
+ display->height = old_h;
+ return -1;
+ }
+
+ destroy_image_buffer(x_display, old_image, old_image_data, old_shm);
+ return 0;
+}
+
+/* Nearest-neighbour scaler for BGRA buffers. */
+static void scale_bgra_nearest(const uint8_t* src, uint32_t src_w, uint32_t src_h,
+ uint8_t* dst, uint32_t dst_w, uint32_t dst_h) {
+ for (uint32_t y = 0; y < dst_h; y++) {
+ uint32_t sy = (uint32_t)(((uint64_t)y * src_h) / dst_h);
+ for (uint32_t x = 0; x < dst_w; x++) {
+ uint32_t sx = (uint32_t)(((uint64_t)x * src_w) / dst_w);
+ const uint8_t* sp = src + ((size_t)sy * src_w + sx) * 4;
+ uint8_t* dp = dst + ((size_t)y * dst_w + x) * 4;
+ dp[0] = sp[0];
+ dp[1] = sp[1];
+ dp[2] = sp[2];
+ dp[3] = sp[3];
+ }
+ }
+}
+
+/* Display frame using X11 - uses unified convert_frame_to_rgb with proper linesize */
+void display_frame_x11(struct x11_display_ctx* display, struct st_frame* frame) {
+ if (!display || !display->initialized) {
+ return;
+ }
+
+ Display* x_display = (Display*)display->display;
+ Window x_window = (Window)(uintptr_t)display->window;
+ GC x_gc = (GC)display->gc;
+
+ int frame_counter = (int)++display->frame_counter;
+
+ bool video_displayed = false;
+
+ if (frame && frame->addr[0]) {
+ if (frame->width > 0 && frame->height > 0) {
+ display->frame_width = frame->width;
+ display->frame_height = frame->height;
+ }
+
+ uint32_t frame_w = display->frame_width;
+ uint32_t frame_h = display->frame_height;
+ size_t scratch_size = (size_t)frame_w * frame_h * 4;
+
+ if (display->frame_scratch_cap < scratch_size) {
+ free(display->frame_scratch);
+ display->frame_scratch = (char*)malloc(scratch_size);
+ display->frame_scratch_cap = display->frame_scratch ? scratch_size : 0;
+ if (!display->frame_scratch) {
+ log_error(LOGGER_TAG, "Cannot allocate frame scratch buffer (%ux%u)", frame_w, frame_h);
+ return;
+ }
+ }
+ /* -------------------------------------------------------
+ * DIAGNOSTIC: log raw YUV plane info for first 5 frames
+ * to help identify green-screen root cause.
+ * ------------------------------------------------------- */
+ if (frame_counter <= 5) {
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: fmt=%d %ux%u data_size=%zu",
+ frame_counter, frame->fmt, frame->width, frame->height,
+ frame->data_size);
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: addr Y=%p U=%p V=%p",
+ frame_counter, frame->addr[0], frame->addr[1], frame->addr[2]);
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: linesize Y=%zu U=%zu V=%zu",
+ frame_counter, frame->linesize[0], frame->linesize[1],
+ frame->linesize[2]);
+
+ if ((frame->fmt == ST_FRAME_FMT_YUV422PLANAR10LE ||
+ frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) &&
+ frame->addr[0] && frame->addr[1] && frame->addr[2]) {
+ int bit_shift = (frame->fmt == ST_FRAME_FMT_YUV422PLANAR12LE) ? 4 : 2;
+ uint16_t* yp = (uint16_t*)frame->addr[0];
+ uint16_t* up = (uint16_t*)frame->addr[1];
+ uint16_t* vp = (uint16_t*)frame->addr[2];
+ size_t y_stride = frame->linesize[0] / 2;
+ size_t u_stride = frame->linesize[1] / 2;
+
+ /* top-left pixel */
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: TL raw16 Y=0x%04x U=0x%04x V=0x%04x "
+ "8-bit Y=%u U=%u V=%u",
+ frame_counter,
+ yp[0], up[0], vp[0],
+ (yp[0] >> bit_shift) & 0xFF,
+ (up[0] >> bit_shift) & 0xFF,
+ (vp[0] >> bit_shift) & 0xFF);
+
+ /* centre pixel */
+ uint32_t cx = frame_w / 2, cy = frame_h / 2;
+ uint16_t cy16 = yp[cy * y_stride + cx];
+ uint16_t cu16 = up[cy * u_stride + cx / 2];
+ uint16_t cv16 = vp[cy * u_stride + cx / 2];
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: CTR raw16 Y=0x%04x U=0x%04x V=0x%04x "
+ "8-bit Y=%u U=%u V=%u",
+ frame_counter,
+ cy16, cu16, cv16,
+ (cy16 >> bit_shift) & 0xFF,
+ (cu16 >> bit_shift) & 0xFF,
+ (cv16 >> bit_shift) & 0xFF);
+
+ /* Print first 4 raw U values to spot if U-plane is zero/garbage */
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: U-plane first 4 words: 0x%04x 0x%04x 0x%04x 0x%04x",
+ frame_counter, up[0], up[1], up[2], up[3]);
+ log_debug(LOGGER_TAG, "DIAG[rx frame %d]: V-plane first 4 words: 0x%04x 0x%04x 0x%04x 0x%04x",
+ frame_counter, vp[0], vp[1], vp[2], vp[3]);
+ }
+ }
+
+ /* -------------------------------------------------------
+ * Convert using the unified converter that respects
+ * frame->linesize[], supporting all planar formats.
+ * The old convert_yuv422_to_rgb_safe used hardcoded stride
+ * (width/2) and would read wrong UV data if the frame had
+ * any row padding – producing all-green output.
+ * ------------------------------------------------------- */
+ bool converted = false;
+ bool direct;
+
+ /* Window size may change on the main thread (ConfigureNotify), so compare
+ * and write into image_data under the same lock. */
+ pthread_mutex_lock(&display->xlock);
+ direct = (frame_w == display->width && frame_h == display->height);
+ if (direct) {
+ /* No scaling needed: convert straight into the X image buffer. */
+ converted = (convert_frame_to_rgb(frame, (uint8_t*)display->image_data, frame_w, frame_h) == 0);
+ if (converted) put_image_locked(display);
+ }
+ pthread_mutex_unlock(&display->xlock);
+
+ if (!direct) {
+ /* Convert only the sampled pixels, straight into the X image (no scratch pass). */
+ pthread_mutex_lock(&display->xlock);
+ if (convert_frame_to_rgb_scaled(frame, (uint8_t*)display->image_data,
+ display->width, display->height) == 0) {
+ converted = true;
+ put_image_locked(display);
+ }
+ pthread_mutex_unlock(&display->xlock);
+ }
+
+ if (!direct && !converted) {
+ converted = (convert_frame_to_rgb(frame, (uint8_t*)display->frame_scratch, frame_w, frame_h) == 0);
+ if (converted) {
+ pthread_mutex_lock(&display->xlock);
+ scale_bgra_nearest((uint8_t*)display->frame_scratch, frame_w, frame_h,
+ (uint8_t*)display->image_data, display->width, display->height);
+ put_image_locked(display);
+ pthread_mutex_unlock(&display->xlock);
+ }
+ }
+
+ if (converted) {
+ video_displayed = true;
+ } else {
+ log_warn(LOGGER_TAG, "convert_frame_to_rgb failed "
+ "(fmt=%d, frame=%ux%u, win=%ux%u). Falling back.",
+ frame->fmt, frame_w, frame_h,
+ display->width, display->height);
+ }
+ }
+
+ /* Fallback animated display when conversion is unavailable */
+ if (!video_displayed) {
+ pthread_mutex_lock(&display->xlock);
+ uint32_t width = display->width;
+ uint32_t height = display->height;
+ unsigned long alpha = display->depth == 32 ? 0xFF000000UL : 0;
+ XSetForeground(x_display, x_gc, alpha | 0x404040);
+ XFillRectangle(x_display, x_window, x_gc, 0, 0, width, height);
+ XSetForeground(x_display, x_gc, alpha | (uint32_t)((frame_counter * 0x1234) & 0xFFFFFF));
+ int rect_x = (frame_counter * 2) % (int)(width > 100 ? width - 100 : 1);
+ int rect_y = (frame_counter) % (int)(height > 100 ? height - 100 : 1);
+ XFillRectangle(x_display, x_window, x_gc, rect_x, rect_y, 100, 100);
+ XSetForeground(x_display, x_gc, alpha | 0xFFFFFF);
+ char status_text[96];
+ if (frame && frame->addr[0])
+ snprintf(status_text, sizeof(status_text),
+ "Converting frame %d (fmt=%d)...", frame_counter,
+ frame ? frame->fmt : -1);
+ else
+ snprintf(status_text, sizeof(status_text), "Waiting for video data...");
+ XDrawString(x_display, x_window, x_gc, 10, 30, status_text, strlen(status_text));
+ pthread_mutex_unlock(&display->xlock);
+ }
+
+ pthread_mutex_lock(&display->xlock);
+ XFlush(x_display);
+ pthread_mutex_unlock(&display->xlock);
+}
+
+/* Mirror src's already-converted image into dst (lock order: src, then dst). */
+bool display_copy_x11(struct x11_display_ctx* dst, struct x11_display_ctx* src) {
+ if (!dst || !src || !dst->initialized || !src->initialized) return false;
+
+ bool copied = false;
+ pthread_mutex_lock(&src->xlock);
+ pthread_mutex_lock(&dst->xlock);
+ if (dst->width == src->width && dst->height == src->height &&
+ dst->image_data && src->image_data) {
+ memcpy(dst->image_data, src->image_data, (size_t)dst->width * dst->height * 4);
+ put_image_locked(dst);
+ XFlush((Display*)dst->display);
+ copied = true;
+ }
+ pthread_mutex_unlock(&dst->xlock);
+ pthread_mutex_unlock(&src->xlock);
+ return copied;
+}
+
+/* Process X11 events (non-blocking) */
+void process_x11_events(struct x11_display_ctx* display) {
+ if (!display || !display->initialized) {
+ return;
+ }
+
+ // Safe, minimal event processing - only handle essential events
+ static int event_counter = 0;
+
+ Display* x_display = (Display*)display->display;
+
+ // Check if X11 connection is still valid
+ if (!x_display || XConnectionNumber(x_display) < 0) {
+ log_error(LOGGER_TAG, "X11 connection lost in process_x11_events");
+ display->initialized = false;
+ return;
+ }
+ XEvent event;
+
+ // Process events very carefully - avoid any operations that might corrupt protocol
+ int events_processed = 0;
+
+ while (XPending(x_display) && events_processed < 10) { // Limit events per call
+ XNextEvent(x_display, &event);
+ events_processed++;
+
+ switch (event.type) {
+ case Expose:
+ /* Do NOT XClearWindow here — it erases the last XPutImage frame.
+ * Re-blit the existing XImage instead so the window repaints
+ * correctly after being minimised / obscured. */
+ pthread_mutex_lock(&display->xlock);
+ put_image_locked(display);
+ pthread_mutex_unlock(&display->xlock);
+ break;
+
+ case KeyPress:
+ if (display->wall_kind != X11_WALL_NONE &&
+ XLookupKeysym(&event.xkey, 0) == XK_Escape && escape_pressed()) {
+ toggle_wall_mode();
+ }
+ break;
+
+ case KeyRelease:
+ if (XLookupKeysym(&event.xkey, 0) == XK_Escape) escape_released();
+ break;
+
+ case ReparentNotify:
+ if (display->wall_pending &&
+ event.xreparent.parent == RootWindow(x_display, display->screen)) {
+ pthread_mutex_lock(&g_wall_lock);
+ if (display->wall_pending) finish_fixed_wall(display);
+ pthread_mutex_unlock(&g_wall_lock);
+ }
+ break;
+
+ case ClientMessage:
+ handle_close_message(x_display, &event);
+ break;
+
+ case ConfigureNotify:
+ if (event.xconfigure.width > 0 && event.xconfigure.height > 0) {
+ pthread_mutex_lock(&display->xlock);
+ if (resize_x11_buffers(display,
+ (uint32_t)event.xconfigure.width,
+ (uint32_t)event.xconfigure.height) == 0 &&
+ ++event_counter % 5 == 0) {
+ log_debug(LOGGER_TAG, "X11 window resized to %dx%d (event %d)",
+ event.xconfigure.width, event.xconfigure.height, event_counter);
+ }
+ pthread_mutex_unlock(&display->xlock);
+ }
+ break;
+
+ default:
+ // Ignore all other events to avoid potential issues
+ break;
+ }
+ }
+
+ // Simple flush without sync
+ if (events_processed > 0) {
+ XFlush(x_display);
+ }
+
+ if (display->wall_pending && now_ms() - display->pending_ms > 1000) {
+ pthread_mutex_lock(&g_wall_lock); /* WM never released the window */
+ if (display->wall_pending) finish_fixed_wall(display);
+ pthread_mutex_unlock(&g_wall_lock);
+ }
+}
+
+/* ============================================================
+ * Composite display implementation
+ * N session threads each write their strip then synchronise
+ * via a barrier; session 0 calls XPutImage once per frame.
+ * ============================================================ */
+
+int init_composite_x11_display(struct composite_x11_ctx* comp,
+ uint32_t strip_width, uint32_t height,
+ int num_sessions) {
+ if (!comp || num_sessions < 1) return -1;
+
+ memset(comp, 0, sizeof(*comp));
+
+ const char* display_env = getenv("DISPLAY");
+ if (!display_env) {
+ log_error(LOGGER_TAG, "DISPLAY not set for composite display");
+ return -1;
+ }
+
+ XSetErrorHandler(x11_error_handler);
+ XSetIOErrorHandler(x11_io_error_handler);
+
+ Display* x_display = XOpenDisplay(NULL);
+ if (!x_display) {
+ log_error(LOGGER_TAG, "Cannot open X11 display for composite");
+ return -1;
+ }
+
+ comp->display = x_display;
+ comp->screen = DefaultScreen(x_display);
+ comp->depth = DefaultDepth(x_display, comp->screen);
+ comp->strip_width = strip_width;
+ comp->full_width = strip_width; /* caller passes the total display width */
+ comp->height = height;
+ comp->num_sessions = num_sessions;
+ comp->win_width = strip_width; /* on-screen size starts equal to native size */
+ comp->win_height = height;
+ comp->scaled_ximage = NULL;
+ comp->scaled_image_data = NULL;
+
+ Visual* x_visual = DefaultVisual(x_display, comp->screen);
+ comp->visual = x_visual;
+
+ /* Full-width composite window */
+ char title[128];
+ snprintf(title, sizeof(title),
+ "dvledrx Composite (%dx%d, %d sessions)",
+ comp->full_width, height, num_sessions);
+
+ Window x_window = XCreateSimpleWindow(
+ x_display, RootWindow(x_display, comp->screen),
+ 50, 50, comp->full_width, height,
+ 1,
+ BlackPixel(x_display, comp->screen),
+ BlackPixel(x_display, comp->screen));
+
+ if (!x_window) {
+ log_error(LOGGER_TAG, "Cannot create composite X11 window");
+ XCloseDisplay(x_display);
+ return -1;
+ }
+
+ comp->window = (void*)(uintptr_t)x_window;
+
+ XStoreName(x_display, x_window, title);
+ XSelectInput(x_display, x_window, ExposureMask | KeyPressMask | StructureNotifyMask);
+ Atom wm_delete = XInternAtom(x_display, "WM_DELETE_WINDOW", False);
+ XSetWMProtocols(x_display, x_window, &wm_delete, 1);
+
+ GC x_gc = XCreateGC(x_display, x_window, 0, NULL);
+ comp->gc = x_gc;
+
+ /* Allocate composite pixel buffer */
+ size_t buf_size = (size_t)comp->full_width * height * 4;
+ char* image_data = malloc(buf_size);
+ if (!image_data) {
+ log_error(LOGGER_TAG, "Cannot allocate composite image buffer (%zu bytes)", buf_size);
+ XFreeGC(x_display, x_gc);
+ XDestroyWindow(x_display, x_window);
+ XCloseDisplay(x_display);
+ return -1;
+ }
+ memset(image_data, 0, buf_size);
+ comp->image_data = image_data;
+
+ XImage* x_image = XCreateImage(x_display, x_visual,
+ comp->depth, ZPixmap, 0,
+ image_data,
+ comp->full_width, height,
+ 32, (int)(comp->full_width * 4));
+ if (!x_image) {
+ log_error(LOGGER_TAG, "Cannot create composite XImage");
+ free(image_data);
+ XFreeGC(x_display, x_gc);
+ XDestroyWindow(x_display, x_window);
+ XCloseDisplay(x_display);
+ return -1;
+ }
+ comp->ximage = x_image;
+
+ XMapWindow(x_display, x_window);
+ XFlush(x_display);
+
+ /* Default-attribute mutex/cond init cannot fail on Linux. */
+ pthread_mutex_init(&comp->bar_lock, NULL);
+ pthread_cond_init(&comp->bar_cond, NULL);
+ pthread_mutex_init(&comp->xlock, NULL);
+
+ comp->initialized = true;
+
+ log_info(LOGGER_TAG, "Composite X11 display initialised: %dx%d (%d sessions)",
+ comp->full_width, height, num_sessions);
+ return 0;
+}
+
+void cleanup_composite_x11_display(struct composite_x11_ctx* comp) {
+ if (!comp || !comp->initialized) return;
+
+ pthread_cond_destroy(&comp->bar_cond);
+ pthread_mutex_destroy(&comp->bar_lock);
+ pthread_mutex_destroy(&comp->xlock);
+
+ Display* x_display = (Display*)comp->display;
+ GC x_gc = (GC)comp->gc;
+ Window x_window = (Window)(uintptr_t)comp->window;
+ XImage* x_image = (XImage*)comp->ximage;
+ XImage* scaled_image = (XImage*)comp->scaled_ximage;
+
+ if (scaled_image) {
+ scaled_image->data = NULL; /* we own scaled_image_data; don't let XDestroyImage free it */
+ XDestroyImage(scaled_image);
+ }
+ free(comp->scaled_image_data);
+
+ if (x_image) {
+ x_image->data = NULL; /* We own image_data; don't let XDestroyImage free it */
+ XDestroyImage(x_image);
+ }
+ free(comp->image_data);
+ if (x_gc) XFreeGC(x_display, x_gc);
+ if (x_window) XDestroyWindow(x_display, x_window);
+ if (x_display) XCloseDisplay(x_display);
+
+ memset(comp, 0, sizeof(*comp));
+ log_info(LOGGER_TAG, "Composite X11 display cleaned up");
+}
+
+/* Reallocate the window-sized scaled buffer to match new_w × new_h.
+ * Must be called with comp->xlock held. When new_w/new_h equal the native
+ * full_width/height, the scaled buffer is freed instead (XPutImage will use
+ * the native buffer directly). */
+static void resize_composite_scaled_buffer(struct composite_x11_ctx* comp,
+ uint32_t new_w, uint32_t new_h) {
+ if (!comp || !new_w || !new_h) return;
+ if (new_w == comp->win_width && new_h == comp->win_height) return;
+
+ Display* x_display = (Display*)comp->display;
+ XImage* old_scaled = (XImage*)comp->scaled_ximage;
+ char* old_scaled_data = comp->scaled_image_data;
+
+ comp->win_width = new_w;
+ comp->win_height = new_h;
+
+ if (new_w == comp->full_width && new_h == comp->height) {
+ /* Back to native size: drop the scaled buffer, use comp->ximage directly */
+ comp->scaled_ximage = NULL;
+ comp->scaled_image_data = NULL;
+ if (old_scaled) {
+ old_scaled->data = NULL;
+ XDestroyImage(old_scaled);
+ }
+ free(old_scaled_data);
+ return;
+ }
+
+ size_t buf_size = (size_t)new_w * new_h * 4;
+ char* new_data = (char*)malloc(buf_size);
+ if (!new_data) {
+ log_error(LOGGER_TAG, "Cannot allocate scaled composite buffer (%ux%u)", new_w, new_h);
+ return;
+ }
+ memset(new_data, 0, buf_size);
+
+ XImage* new_image = XCreateImage(x_display, (Visual*)comp->visual,
+ comp->depth, ZPixmap, 0,
+ new_data, new_w, new_h,
+ 32, (int)(new_w * 4));
+ if (!new_image) {
+ log_error(LOGGER_TAG, "Cannot create scaled composite XImage (%ux%u)", new_w, new_h);
+ free(new_data);
+ return;
+ }
+
+ comp->scaled_image_data = new_data;
+ comp->scaled_ximage = new_image;
+
+ if (old_scaled) {
+ old_scaled->data = NULL;
+ XDestroyImage(old_scaled);
+ }
+ free(old_scaled_data);
+}
+
+/* Blit the composite buffer to the window, scaling to the current window
+ * size if it differs from the native composite resolution (e.g. after the
+ * window manager fullscreens/resizes the window). Must be called with
+ * comp->xlock held. */
+static void composite_flush_locked(struct composite_x11_ctx* comp) {
+ Display* x_display = (Display*)comp->display;
+ Window x_window = (Window)(uintptr_t)comp->window;
+ GC x_gc = (GC)comp->gc;
+
+ if (comp->scaled_ximage &&
+ (comp->win_width != comp->full_width || comp->win_height != comp->height)) {
+ scale_bgra_nearest((uint8_t*)comp->image_data, comp->full_width, comp->height,
+ (uint8_t*)comp->scaled_image_data, comp->win_width, comp->win_height);
+ XPutImage(x_display, x_window, x_gc, (XImage*)comp->scaled_ximage,
+ 0, 0, 0, 0, comp->win_width, comp->win_height);
+ } else {
+ XPutImage(x_display, x_window, x_gc, (XImage*)comp->ximage,
+ 0, 0, 0, 0, comp->full_width, comp->height);
+ }
+ XFlush(x_display);
+}
+
+/* Wait until every session has written its part of this frame. Returns true for
+ * exactly one caller per round (it flushes); false for the others or once aborted. */
+static bool composite_barrier_wait(struct composite_x11_ctx* comp) {
+ bool last = false;
+ pthread_mutex_lock(&comp->bar_lock);
+ if (!comp->bar_aborted) {
+ unsigned round = comp->bar_round;
+ if (++comp->bar_count == comp->num_sessions) {
+ comp->bar_count = 0;
+ comp->bar_round++;
+ pthread_cond_broadcast(&comp->bar_cond);
+ last = true;
+ } else {
+ while (round == comp->bar_round && !comp->bar_aborted)
+ pthread_cond_wait(&comp->bar_cond, &comp->bar_lock);
+ }
+ }
+ pthread_mutex_unlock(&comp->bar_lock);
+ return last;
+}
+
+void composite_abort(struct composite_x11_ctx* comp) {
+ if (!comp || !comp->initialized) return;
+ pthread_mutex_lock(&comp->bar_lock);
+ comp->bar_aborted = true;
+ pthread_cond_broadcast(&comp->bar_cond);
+ pthread_mutex_unlock(&comp->bar_lock);
+}
+
+void composite_write_strip(struct composite_x11_ctx* comp,
+ struct st_frame* frame, int session_idx) {
+ if (!comp || !comp->initialized || !frame) return;
+
+ uint32_t strip_w = comp->strip_width;
+ uint32_t h = comp->height;
+
+ /* Convert this session's strip to BGRA (4 bytes/pixel) into a temporary buffer */
+ size_t strip_bytes = (size_t)strip_w * h * 4;
+ uint8_t* strip_buf = malloc(strip_bytes);
+ if (!strip_buf) {
+ log_error(LOGGER_TAG, "composite_write_strip: OOM for session %d", session_idx);
+ /* Still need to hit the barrier to avoid deadlock */
+ composite_barrier_wait(comp);
+ return;
+ }
+
+ convert_frame_to_rgb(frame, strip_buf, strip_w, h);
+
+ /* Blit strip into composite buffer at the correct X offset.
+ * Each row in the composite buffer is full_width * 4 bytes wide. */
+ uint32_t row_stride_comp = comp->full_width * 4; /* bytes per composite row */
+ uint32_t row_stride_strip = strip_w * 4; /* bytes per strip row */
+ uint32_t x_byte_off = (uint32_t)session_idx * strip_w * 4; /* byte offset in row */
+ uint8_t* dst_base = (uint8_t*)comp->image_data;
+
+ for (uint32_t row = 0; row < h; row++) {
+ uint8_t* dst = dst_base + row * row_stride_comp + x_byte_off;
+ uint8_t* src = strip_buf + row * row_stride_strip;
+ memcpy(dst, src, row_stride_strip);
+ }
+
+ free(strip_buf);
+
+ /* Synchronise: wait for all strips; the last one to arrive flushes. */
+ if (composite_barrier_wait(comp)) {
+ pthread_mutex_lock(&comp->xlock);
+ composite_flush_locked(comp);
+ pthread_mutex_unlock(&comp->xlock);
+ }
+}
+
+#include "config_reader.h" /* struct crop_config */
+
+void composite_write_crop(struct composite_x11_ctx* comp,
+ struct st_frame* frame,
+ const struct crop_config* crop) {
+ if (!comp || !comp->initialized || !frame || !crop) return;
+
+ uint32_t cw = (uint32_t)crop->w;
+ uint32_t ch = (uint32_t)crop->h;
+
+ /* Convert this session's crop-sized frame to BGRA */
+ size_t strip_bytes = (size_t)cw * ch * 4;
+ uint8_t* strip_buf = malloc(strip_bytes);
+ if (!strip_buf) {
+ log_error(LOGGER_TAG, "composite_write_crop: OOM (%ux%u)", cw, ch);
+ composite_barrier_wait(comp);
+ return;
+ }
+
+ convert_frame_to_rgb(frame, strip_buf, cw, ch);
+
+ /* Blit into the composite buffer at (crop->x, crop->y).
+ * Clamp to the composite dimensions to prevent out-of-bounds writes. */
+ uint32_t dst_x = (uint32_t)crop->x;
+ uint32_t dst_y = (uint32_t)crop->y;
+ uint32_t row_stride_comp = comp->full_width * 4;
+ uint8_t* dst_base = (uint8_t*)comp->image_data;
+
+ for (uint32_t row = 0; row < ch; row++) {
+ uint32_t dy = dst_y + row;
+ if (dy >= comp->height) break;
+
+ uint32_t copy_w = cw;
+ if (dst_x + copy_w > comp->full_width)
+ copy_w = comp->full_width - dst_x;
+
+ uint8_t* dst = dst_base + dy * row_stride_comp + dst_x * 4;
+ uint8_t* src = strip_buf + row * cw * 4;
+ memcpy(dst, src, copy_w * 4);
+ }
+
+ free(strip_buf);
+
+ /* Synchronise with other sessions */
+ if (composite_barrier_wait(comp)) {
+ pthread_mutex_lock(&comp->xlock);
+ composite_flush_locked(comp);
+ pthread_mutex_unlock(&comp->xlock);
+ }
+}
+
+void process_composite_x11_events(struct composite_x11_ctx* comp) {
+ if (!comp || !comp->initialized) return;
+
+ Display* x_display = (Display*)comp->display;
+
+ pthread_mutex_lock(&comp->xlock);
+ while (XPending(x_display) > 0) {
+ XEvent event;
+ XNextEvent(x_display, &event);
+
+ if (event.type == Expose && event.xexpose.count == 0) {
+ /* Repaint composite image on expose */
+ composite_flush_locked(comp);
+ } else if (event.type == ConfigureNotify) {
+ /* Window manager resized/fullscreened the window: rescale the
+ * composite buffer to fill the new window size, matching the
+ * behaviour of single-session windows. */
+ if (event.xconfigure.width > 0 && event.xconfigure.height > 0) {
+ resize_composite_scaled_buffer(comp,
+ (uint32_t)event.xconfigure.width,
+ (uint32_t)event.xconfigure.height);
+ composite_flush_locked(comp);
+ }
+ } else if (event.type == ClientMessage) {
+ handle_close_message(x_display, &event);
+ }
+ }
+ pthread_mutex_unlock(&comp->xlock);
+}
diff --git a/dvledrx/tests/data/run_composite.json b/dvledrx/tests/data/run_composite.json
new file mode 100644
index 0000000..c7bcac9
--- /dev/null
+++ b/dvledrx/tests/data/run_composite.json
@@ -0,0 +1,9 @@
+{
+ "log_level": "loud",
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 64, "height": 16, "fps": 25, "fmt": "yuv444p10le" },
+ "rx_sessions": [
+ { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 16 } },
+ { "udp_port": 20002, "crop": { "x": 32, "y": 0, "w": 32, "h": 16 } }
+ ]
+}
diff --git a/dvledrx/tests/data/run_fps24.json b/dvledrx/tests/data/run_fps24.json
new file mode 100644
index 0000000..939b05d
--- /dev/null
+++ b/dvledrx/tests/data/run_fps24.json
@@ -0,0 +1,5 @@
+{
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 32, "height": 16, "fps": 24, "fmt": "yuv444p12le" },
+ "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ]
+}
diff --git a/dvledrx/tests/data/run_fps29.json b/dvledrx/tests/data/run_fps29.json
new file mode 100644
index 0000000..58a07a8
--- /dev/null
+++ b/dvledrx/tests/data/run_fps29.json
@@ -0,0 +1,5 @@
+{
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 32, "height": 16, "fps": 29, "fmt": "gbrp10le" },
+ "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ]
+}
diff --git a/dvledrx/tests/data/run_fps30.json b/dvledrx/tests/data/run_fps30.json
new file mode 100644
index 0000000..b17afba
--- /dev/null
+++ b/dvledrx/tests/data/run_fps30.json
@@ -0,0 +1,5 @@
+{
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 32, "height": 16, "fps": 30 },
+ "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ]
+}
diff --git a/dvledrx/tests/data/run_fps59.json b/dvledrx/tests/data/run_fps59.json
new file mode 100644
index 0000000..eeef1cb
--- /dev/null
+++ b/dvledrx/tests/data/run_fps59.json
@@ -0,0 +1,5 @@
+{
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 32, "height": 16, "fps": 59, "fmt": "yuv420" },
+ "rx_sessions": [ { "crop": { "w": 32, "h": 16 } } ]
+}
diff --git a/dvledrx/tests/data/run_per_output.json b/dvledrx/tests/data/run_per_output.json
new file mode 100644
index 0000000..b5078f8
--- /dev/null
+++ b/dvledrx/tests/data/run_per_output.json
@@ -0,0 +1,18 @@
+{
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 64, "height": 32, "fps": 50, "fmt": "yuv422p12le" },
+ "rx_sessions": [
+ { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 32 } },
+ { "udp_port": 20002, "crop": { "x": 32, "y": 0, "w": 32, "h": 32 } }
+ ],
+ "displays": [
+ {
+ "output": "screen",
+ "sessions": [
+ { "session": 0, "x": 0, "y": 0, "scale_w": 16, "scale_h": 16 },
+ { "session": 1, "x": 16, "y": 0, "scale_w": 16, "scale_h": 16 },
+ { "session": 0, "x": 0, "y": 40, "scale_w": 16, "scale_h": 16 }
+ ]
+ }
+ ]
+}
diff --git a/dvledrx/tests/data/run_single.json b/dvledrx/tests/data/run_single.json
new file mode 100644
index 0000000..b277bc4
--- /dev/null
+++ b/dvledrx/tests/data/run_single.json
@@ -0,0 +1,7 @@
+{
+ "log_file": "dvledrx_fake_test.log",
+ "log_level": "debug",
+ "interfaces": [ { "name": "0000:03:00.0", "sip": "192.168.50.65", "dip": "239.168.85.20" } ],
+ "video": { "width": 32, "height": 16, "fps": 60, "fmt": "gbrp12le" },
+ "rx_sessions": [ { "udp_port": 20000, "crop": { "x": 0, "y": 0, "w": 32, "h": 16 } } ]
+}
diff --git a/dvledrx/tests/fake_mtl.c b/dvledrx/tests/fake_mtl.c
new file mode 100644
index 0000000..e634692
--- /dev/null
+++ b/dvledrx/tests/fake_mtl.c
@@ -0,0 +1,183 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ */
+
+#include "fake_mtl.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define POOL 3
+
+struct fake_mtl_state fake_mtl;
+
+struct fake_rx {
+ struct st20p_rx_ops ops;
+ struct st_frame frames[POOL];
+ bool in_use[POOL];
+ int served;
+ bool woken;
+};
+
+static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
+static int g_main_token;
+
+static int env_int(const char* name, int def) {
+ const char* v = getenv(name);
+ return v && v[0] ? atoi(v) : def;
+}
+
+void fake_mtl_reset(void) {
+ memset(&fake_mtl, 0, sizeof(fake_mtl));
+ fake_mtl.fail_init = env_int("FAKE_MTL_FAIL_INIT", 0) != 0;
+ fake_mtl.fail_create_at = env_int("FAKE_MTL_FAIL_CREATE", -1);
+ fake_mtl.frames_per_session = env_int("FAKE_MTL_FRAMES", -1);
+ fake_mtl.empty_every = env_int("FAKE_MTL_EMPTY_EVERY", 0);
+ fake_mtl.raise_after = env_int("FAKE_MTL_RAISE_AFTER", 0);
+}
+
+__attribute__((constructor)) static void fake_mtl_ctor(void) { fake_mtl_reset(); }
+
+static bool is_planar16(enum st_frame_fmt f) {
+ return f == ST_FRAME_FMT_YUV422PLANAR10LE || f == ST_FRAME_FMT_YUV422PLANAR12LE ||
+ f == ST_FRAME_FMT_YUV444PLANAR10LE || f == ST_FRAME_FMT_YUV444PLANAR12LE ||
+ f == ST_FRAME_FMT_GBRPLANAR10LE || f == ST_FRAME_FMT_GBRPLANAR12LE;
+}
+
+/* Allocate one frame of the session's output format, filled with a mid-level ramp. */
+static void frame_init(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h) {
+ memset(f, 0, sizeof(*f));
+ f->fmt = fmt;
+ f->width = w;
+ f->height = h;
+ if (is_planar16(fmt)) {
+ bool sub = fmt == ST_FRAME_FMT_YUV422PLANAR10LE || fmt == ST_FRAME_FMT_YUV422PLANAR12LE;
+ for (int p = 0; p < 3; p++) {
+ uint32_t pw = (p > 0 && sub) ? w / 2 : w;
+ uint16_t* plane = malloc((size_t)pw * h * 2);
+ for (size_t i = 0; i < (size_t)pw * h; i++) plane[i] = (uint16_t)(256 + i % 256);
+ f->addr[p] = plane;
+ f->linesize[p] = (size_t)pw * 2;
+ f->data_size += (size_t)pw * h * 2;
+ }
+ } else if (fmt == ST_FRAME_FMT_YUV420PLANAR8) {
+ size_t sz[3] = {(size_t)w * h, (size_t)w * h / 4, (size_t)w * h / 4};
+ for (int p = 0; p < 3; p++) {
+ f->addr[p] = calloc(1, sz[p] ? sz[p] : 1);
+ f->linesize[p] = p ? w / 2 : w;
+ f->data_size += sz[p];
+ }
+ } else {
+ size_t sz = (size_t)w * h * 4; /* covers YUV420CUSTOM8 and packed formats */
+ f->addr[0] = calloc(1, sz ? sz : 1);
+ f->linesize[0] = (size_t)w * 4;
+ f->data_size = sz;
+ }
+ f->buffer_size = f->data_size;
+}
+
+mtl_handle mtl_init(struct mtl_init_params* p) {
+ fake_mtl.init_calls++;
+ if (p) fake_mtl.last_init = *p;
+ if (fake_mtl.fail_init) return NULL;
+ return (mtl_handle)&g_main_token;
+}
+
+int mtl_uninit(mtl_handle mt) {
+ (void)mt;
+ fake_mtl.uninit_calls++;
+ return 0;
+}
+
+enum mtl_pmd_type mtl_pmd_by_port_name(const char* port) {
+ (void)port;
+ return MTL_PMD_DPDK_USER;
+}
+
+st20p_rx_handle st20p_rx_create(mtl_handle mt, struct st20p_rx_ops* ops) {
+ (void)mt;
+ int idx = fake_mtl.create_calls++;
+ if (idx < FAKE_MAX_RX) {
+ fake_mtl.ops[idx] = *ops;
+ snprintf(fake_mtl.names[idx], sizeof(fake_mtl.names[idx]), "%s", ops->name ? ops->name : "");
+ fake_mtl.ops[idx].name = fake_mtl.names[idx];
+ }
+ if (idx == fake_mtl.fail_create_at) return NULL;
+
+ struct fake_rx* rx = calloc(1, sizeof(*rx));
+ rx->ops = *ops;
+ for (int i = 0; i < POOL; i++) frame_init(&rx->frames[i], ops->output_fmt, ops->width, ops->height);
+ return (st20p_rx_handle)rx;
+}
+
+size_t st20p_rx_frame_size(st20p_rx_handle handle) {
+ return ((struct fake_rx*)handle)->frames[0].data_size;
+}
+
+struct st_frame* st20p_rx_get_frame(st20p_rx_handle handle) {
+ struct fake_rx* rx = (struct fake_rx*)handle;
+ struct st_frame* out = NULL;
+ bool do_raise = false;
+
+ pthread_mutex_lock(&g_lock);
+ if (!rx->woken &&
+ (fake_mtl.frames_per_session < 0 || rx->served < fake_mtl.frames_per_session)) {
+ for (int i = 0; i < POOL && !out; i++) {
+ if (rx->in_use[i]) continue;
+ rx->in_use[i] = true;
+ out = &rx->frames[i];
+ }
+ }
+ if (out) {
+ rx->served++;
+ out->data_size =
+ (fake_mtl.empty_every > 0 && rx->served % fake_mtl.empty_every == 0) ? 0 : out->buffer_size;
+ fake_mtl.frames_got++;
+ do_raise = fake_mtl.raise_after > 0 && fake_mtl.frames_got == fake_mtl.raise_after;
+ }
+ pthread_mutex_unlock(&g_lock);
+
+ if (do_raise) raise(SIGTERM);
+ return out;
+}
+
+int st20p_rx_put_frame(st20p_rx_handle handle, struct st_frame* frame) {
+ struct fake_rx* rx = (struct fake_rx*)handle;
+ int ret = -1;
+ pthread_mutex_lock(&g_lock);
+ for (int i = 0; i < POOL; i++) {
+ if (frame == &rx->frames[i] && rx->in_use[i]) {
+ rx->in_use[i] = false;
+ fake_mtl.frames_put++;
+ ret = 0;
+ }
+ }
+ if (ret) fake_mtl.bad_puts++;
+ pthread_mutex_unlock(&g_lock);
+ return ret;
+}
+
+int st20p_rx_wake_block(st20p_rx_handle handle) {
+ pthread_mutex_lock(&g_lock);
+ ((struct fake_rx*)handle)->woken = true;
+ fake_mtl.wake_calls++;
+ pthread_mutex_unlock(&g_lock);
+ return 0;
+}
+
+int st20p_rx_free(st20p_rx_handle handle) {
+ struct fake_rx* rx = (struct fake_rx*)handle;
+ pthread_mutex_lock(&g_lock);
+ fake_mtl.free_calls++;
+ for (int i = 0; i < POOL; i++) {
+ if (rx->in_use[i]) fake_mtl.leaked++;
+ for (int p = 0; p < ST_MAX_PLANES; p++) free(rx->frames[i].addr[p]);
+ }
+ pthread_mutex_unlock(&g_lock);
+ free(rx);
+ return 0;
+}
diff --git a/dvledrx/tests/fake_mtl.h b/dvledrx/tests/fake_mtl.h
new file mode 100644
index 0000000..6e8c493
--- /dev/null
+++ b/dvledrx/tests/fake_mtl.h
@@ -0,0 +1,37 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * In-process stand-in for the MTL calls dvledrx makes, so the session manager
+ * and main can run without a NIC, DPDK, hugepages or root. Sessions serve
+ * synthetic frames from a small pool and the fake records what it was asked.
+ */
+
+#pragma once
+
+#include
+
+#include "mtl_api.h"
+#include "st_pipeline_api.h"
+
+#define FAKE_MAX_RX 16
+
+struct fake_mtl_state {
+ /* knobs (also settable via FAKE_MTL_* environment variables) */
+ bool fail_init; /* FAKE_MTL_FAIL_INIT=1 */
+ int fail_create_at; /* FAKE_MTL_FAIL_CREATE=: n-th create fails, -1 none */
+ int frames_per_session; /* FAKE_MTL_FRAMES=: frames per session, -1 unlimited */
+ int empty_every; /* FAKE_MTL_EMPTY_EVERY=: every n-th frame has no data */
+ int raise_after; /* FAKE_MTL_RAISE_AFTER=: raise(SIGTERM) after n frames */
+
+ /* observations */
+ int init_calls, uninit_calls, create_calls, free_calls, wake_calls;
+ int frames_got, frames_put, bad_puts, leaked;
+ struct mtl_init_params last_init;
+ struct st20p_rx_ops ops[FAKE_MAX_RX];
+ char names[FAKE_MAX_RX][32]; /* ops[i].name points here */
+};
+
+extern struct fake_mtl_state fake_mtl;
+
+/* Clear observations and reload the knobs from the environment. */
+void fake_mtl_reset(void);
diff --git a/dvledrx/tests/meson.build b/dvledrx/tests/meson.build
new file mode 100644
index 0000000..b6ad294
--- /dev/null
+++ b/dvledrx/tests/meson.build
@@ -0,0 +1,127 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Intel Corporation
+
+rx_test_cc = meson.get_compiler('c')
+rx_test_m_dep = rx_test_cc.find_library('m', required: true)
+rx_test_pthread_dep = rx_test_cc.find_library('pthread', required: true)
+rx_test_mtl_dep = dependency('mtl', required: true)
+rx_test_x11_dep = dependency('x11', required: true)
+rx_test_xext_dep = dependency('xext', required: true)
+rx_test_xrandr_dep = dependency('xrandr', required: true)
+rx_test_json_c_dep = dependency('json-c', required: true)
+rx_test_zlib_dep = dependency('zlib', required: true)
+
+rx_test_c_args = [
+ '-DALLOW_EXPERIMENTAL_API',
+ '-DDVLEDRX_VERSION="' + meson.project_version() + '"',
+ '-O3',
+ '-I/usr/local/include/mtl',
+]
+foreach arg : ['-march=native', '-funroll-loops']
+ if rx_test_cc.has_argument(arg)
+ rx_test_c_args += [arg]
+ endif
+endforeach
+
+rx_test_source_dir = meson.current_source_dir() / '..'
+rx_test_config_dir = rx_test_source_dir / 'config'
+rx_test_data_dir = meson.current_source_dir() / 'data'
+rx_common_test_deps = [rx_test_m_dep, rx_test_pthread_dep, rx_test_x11_dep,
+ rx_test_xext_dep, rx_test_xrandr_dep, rx_test_json_c_dep,
+ rx_test_zlib_dep]
+
+rx_unit_tests = {
+ 'config_reader': [
+ ['test_config_reader.c', '../src/config_reader.c', '../src/logger.c'],
+ [rx_test_json_c_dep, rx_test_zlib_dep, rx_test_pthread_dep], [rx_test_config_dir],
+ ],
+ 'logger': [
+ ['test_logger.c', '../src/logger.c'],
+ [rx_test_zlib_dep, rx_test_pthread_dep], [],
+ ],
+ 'frame_converter': [
+ ['test_frame_converter.c', '../src/frame_converter.c', '../src/logger.c'],
+ [rx_test_mtl_dep, rx_test_zlib_dep, rx_test_pthread_dep], [],
+ ],
+}
+foreach name, t : rx_unit_tests
+ test('dvledrx_' + name,
+ executable('test_dvledrx_' + name, t[0],
+ include_directories: include_directories('../include'),
+ dependencies: t[1],
+ c_args: rx_test_c_args,
+ build_by_default: false),
+ args: t[2],
+ suite: 'dvledrx-unit')
+endforeach
+
+# These tests link fake_mtl.c instead of libmtl, so they need no NIC/DPDK hardware.
+rx_mtl_headers = rx_test_mtl_dep.partial_dependency(compile_args: true, includes: true)
+rx_app_test_deps = [rx_mtl_headers] + rx_common_test_deps
+rx_app_test_srcs = [
+ '../src/session_manager.c', '../src/x11_handler.c', '../src/frame_converter.c',
+ '../src/config_reader.c', '../src/logger.c', 'fake_mtl.c',
+]
+rx_app_test_inc = include_directories('../include', '.')
+
+rx_fake_app = executable('dvledrx_fake', ['../src/main.c'] + rx_app_test_srcs,
+ include_directories: rx_app_test_inc,
+ dependencies: rx_app_test_deps,
+ c_args: rx_test_c_args,
+ build_by_default: false)
+
+rx_cli_cases = {
+ 'cli_help': [['--help'], true],
+ 'cli_version': [['--version'], false],
+ 'cli_no_config': [[], true],
+ 'cli_config_missing': [['--config', 'does-not-exist.json'], true],
+ 'cli_config_invalid': [['-C', rx_test_source_dir / 'meson.build'], true],
+ 'cli_test_time_zero': [['-C', rx_test_data_dir / 'run_single.json', '-t', '0'], true],
+ 'cli_test_time_text': [['-C', rx_test_data_dir / 'run_single.json', '--test-time', '5s'], true],
+ 'cli_test_time_long': [['-C', rx_test_data_dir / 'run_single.json', '-t', '86401'], true],
+ 'cli_unknown_option': [['-C', rx_test_data_dir / 'run_single.json', '--bogus'], true],
+ 'cli_missing_value': [['-C'], true],
+}
+foreach name, c : rx_cli_cases
+ test('dvledrx_' + name, rx_fake_app,
+ args: c[0], should_fail: c[1], suite: 'dvledrx-unit')
+endforeach
+
+# Window tests use a virtual X server; CI installs xvfb and xauth for these.
+rx_xvfb_run = find_program('xvfb-run', required: false)
+if rx_xvfb_run.found()
+ rx_xvfb_args = ['-a', '-s', '-screen 0 1280x720x24 -nolisten tcp']
+
+ foreach name, srcs : {
+ 'session_manager': ['test_session_manager.c'] + rx_app_test_srcs,
+ 'x11': ['test_x11.c', '../src/x11_handler.c', '../src/frame_converter.c',
+ '../src/config_reader.c', '../src/logger.c'],
+ }
+ test('dvledrx_' + name, rx_xvfb_run,
+ args: rx_xvfb_args + [executable('test_dvledrx_' + name, srcs,
+ include_directories: rx_app_test_inc,
+ dependencies: rx_app_test_deps,
+ c_args: rx_test_c_args,
+ build_by_default: false)],
+ suite: 'dvledrx-unit', timeout: 120)
+ endforeach
+
+ rx_run_cases = {
+ 'run_single': ['run_single.json', ['-t', '1'], {}, false],
+ 'run_composite': ['run_composite.json', ['-t', '1'], {}, false],
+ 'run_per_output': ['run_per_output.json', ['-t', '1'], {}, false],
+ 'run_fps24': ['run_fps24.json', ['-t', '1'], {}, false],
+ 'run_fps29': ['run_fps29.json', ['-t', '1'], {}, false],
+ 'run_fps59': ['run_fps59.json', ['-t', '1'], {}, false],
+ 'run_sigterm': ['run_fps30.json', ['-t', '20'], {'FAKE_MTL_RAISE_AFTER': '20'}, false],
+ 'run_mtl_init_fail': ['run_single.json', [], {'FAKE_MTL_FAIL_INIT': '1'}, true],
+ 'run_create_fail': ['run_per_output.json', ['-t', '20'], {'FAKE_MTL_FAIL_CREATE': '1'}, true],
+ }
+ foreach name, r : rx_run_cases
+ test('dvledrx_' + name, rx_xvfb_run,
+ args: rx_xvfb_args + [rx_fake_app, '--config', rx_test_data_dir / r[0]] + r[1],
+ env: r[2], should_fail: r[3], suite: 'dvledrx-unit', timeout: 60)
+ endforeach
+else
+ message('xvfb-run not found: receiver X11, session manager and app-run tests are skipped')
+endif
diff --git a/dvledrx/tests/test_common.h b/dvledrx/tests/test_common.h
new file mode 100644
index 0000000..b831f06
--- /dev/null
+++ b/dvledrx/tests/test_common.h
@@ -0,0 +1,119 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Minimal unit-test helpers (no external framework). Each test is a void
+ * function; CHECK* record a failure and continue, REQUIRE* stop the test.
+ */
+
+#pragma once
+
+#define _XOPEN_SOURCE 700
+#define _DEFAULT_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static int g_checks, g_failures, g_test_failed;
+static const char* g_test_name;
+
+#define CHECK(cond) \
+ do { \
+ g_checks++; \
+ if (!(cond)) { \
+ g_failures++; \
+ g_test_failed = 1; \
+ fprintf(stderr, " FAIL %s:%d [%s]: %s\n", __FILE__, __LINE__, g_test_name, \
+ #cond); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_INT(a, b) \
+ do { \
+ long long a_ = (long long)(a), b_ = (long long)(b); \
+ g_checks++; \
+ if (a_ != b_) { \
+ g_failures++; \
+ g_test_failed = 1; \
+ fprintf(stderr, " FAIL %s:%d [%s]: %s == %s (%lld != %lld)\n", __FILE__, \
+ __LINE__, g_test_name, #a, #b, a_, b_); \
+ } \
+ } while (0)
+
+#define CHECK_EQ_STR(a, b) \
+ do { \
+ const char *a_ = (a), *b_ = (b); \
+ g_checks++; \
+ if (!a_ || !b_ || strcmp(a_, b_) != 0) { \
+ g_failures++; \
+ g_test_failed = 1; \
+ fprintf(stderr, " FAIL %s:%d [%s]: %s == %s (\"%s\" != \"%s\")\n", __FILE__, \
+ __LINE__, g_test_name, #a, #b, a_ ? a_ : "(null)", b_ ? b_ : "(null)"); \
+ } \
+ } while (0)
+
+#define REQUIRE(cond) \
+ do { \
+ int before_ = g_failures; \
+ CHECK(cond); \
+ if (g_failures != before_) return; \
+ } while (0)
+
+#define RUN_TEST(fn) \
+ do { \
+ g_test_name = #fn; \
+ g_test_failed = 0; \
+ fn(); \
+ fprintf(stderr, "%s %s\n", g_test_failed ? "FAIL" : "ok ", #fn); \
+ } while (0)
+
+static int test_summary(void) {
+ fprintf(stderr, "%d checks, %d failure(s)\n", g_checks, g_failures);
+ return g_failures ? 1 : 0;
+}
+
+/* Private (0700) scratch directory under the current directory; the process
+ * chdir()s into it so relative paths in tests stay inside it. The current
+ * directory must not be world-writable (the logger refuses e.g. /tmp). */
+static char g_scratch[PATH_MAX];
+
+static void scratch_enter(const char* tag) {
+ char tmpl[64];
+ snprintf(tmpl, sizeof(tmpl), "ut_%s_XXXXXX", tag);
+ if (!mkdtemp(tmpl) || !realpath(tmpl, g_scratch) || chdir(g_scratch) != 0) {
+ fprintf(stderr, "cannot create scratch dir: %s\n", strerror(errno));
+ exit(2);
+ }
+}
+
+static int rm_entry(const char* path, const struct stat* st, int flag, struct FTW* ftw) {
+ (void)st; (void)flag; (void)ftw;
+ return remove(path);
+}
+
+static void scratch_leave(void) {
+ if (chdir("..") != 0) return;
+ if (nftw(g_scratch, rm_entry, 16, FTW_DEPTH | FTW_PHYS) != 0)
+ fprintf(stderr, "warning: could not remove %s\n", g_scratch);
+}
+
+static inline int write_file(const char* path, const char* text) {
+ FILE* f = fopen(path, "w");
+ if (!f) return -1;
+ fputs(text, f);
+ return fclose(f);
+}
+
+static inline long file_size(const char* path) {
+ struct stat st;
+ return stat(path, &st) == 0 ? (long)st.st_size : -1;
+}
+
+static inline int file_exists(const char* path) {
+ struct stat st;
+ return lstat(path, &st) == 0;
+}
diff --git a/dvledrx/tests/test_config_reader.c b/dvledrx/tests/test_config_reader.c
new file mode 100644
index 0000000..21da028
--- /dev/null
+++ b/dvledrx/tests/test_config_reader.c
@@ -0,0 +1,499 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Unit tests for config_reader: JSON parsing, defaults, "displays" placement
+ * and every validate_rx_config() rule.
+ * Usage: test_config_reader []
+ */
+
+#include "test_common.h"
+
+#include
+#include
+
+#include "config_reader.h"
+#include "logger.h"
+
+static const char* g_config_dir;
+
+/* Append formatted text at *len; fails the test instead of overflowing 'buf'. */
+__attribute__((format(printf, 4, 5)))
+static bool appendf(char* buf, size_t size, size_t* len, const char* fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ int n = vsnprintf(buf + *len, size - *len, fmt, ap);
+ va_end(ap);
+ CHECK(n >= 0 && (size_t)n < size - *len);
+ if (n < 0 || (size_t)n >= size - *len) return false;
+ *len += (size_t)n;
+ return true;
+}
+
+/* Parse JSON text via a scratch file. */
+static int parse_text(const char* json, struct rx_config* cfg) {
+ if (write_file("cfg.json", json) != 0) return -99;
+ return parse_rx_config("cfg.json", cfg);
+}
+
+/* A valid 2-NIC, 2-session config built in C; tests break one field each. */
+static void make_valid(struct rx_config* c) {
+ memset(c, 0, sizeof(*c));
+ c->num_interfaces = 2;
+ for (int i = 0; i < 2; i++) {
+ c->interfaces[i].nic_index = i;
+ snprintf(c->interfaces[i].name, sizeof(c->interfaces[i].name), "0000:03:00.%d", i);
+ snprintf(c->interfaces[i].sip, sizeof(c->interfaces[i].sip), "192.168.50.%d", 65 + i);
+ snprintf(c->interfaces[i].dip, sizeof(c->interfaces[i].dip), "239.168.85.%d", 20 + i);
+ }
+ c->video.width = 1920;
+ c->video.height = 1080;
+ c->video.fps = 30;
+ snprintf(c->video.fmt, sizeof(c->video.fmt), "yuv422p10le");
+ c->num_rx_sessions = 2;
+ for (int i = 0; i < 2; i++) {
+ struct rx_session_config* s = &c->rx_sessions[i];
+ s->nic_index = i;
+ s->udp_port = 20000 + 2 * i;
+ s->payload_type = 96;
+ s->crop = (struct crop_config){i * 960, 0, 960, 1080};
+ }
+}
+
+#define EXPECT_INVALID(mutation) \
+ do { \
+ struct rx_config c_; \
+ make_valid(&c_); \
+ { struct rx_config* c = &c_; mutation; } \
+ CHECK_EQ_INT(validate_rx_config(&c_), -1); \
+ } while (0)
+
+#define EXPECT_VALID(mutation) \
+ do { \
+ struct rx_config c_; \
+ make_valid(&c_); \
+ { struct rx_config* c = &c_; mutation; } \
+ CHECK_EQ_INT(validate_rx_config(&c_), 0); \
+ } while (0)
+
+/* ------------------------------------------------------------------ parsing */
+
+static void test_parse_full_config(void) {
+ struct rx_config c;
+ REQUIRE(parse_text(
+ "{ \"log_file\": \"rx.log\", \"log_level\": \"debug\", \"tasklet_sleep\": true,\n"
+ " \"interfaces\": [\n"
+ " { \"nic_index\": 3, \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.1.1.1\" },\n"
+ " { \"nic_index\": 7, \"name\": \"0000:03:00.1\", \"SIP\": \"192.168.50.66\", \"DIP\": \"239.1.1.2\" } ],\n"
+ " \"video\": { \"width\": 3840, \"height\": 2160, \"fps\": 60, \"fmt\": \"gbrp12le\" },\n"
+ " \"rx_sessions\": [\n"
+ " { \"nic_index\": 3, \"udp_port\": 30000, \"payload_type\": 112,\n"
+ " \"crop\": { \"x\": 0, \"y\": 0, \"w\": 1920, \"h\": 2160 } } ] }", &c) == 0);
+ CHECK_EQ_INT(c.num_interfaces, 2);
+ CHECK_EQ_INT(c.interfaces[0].nic_index, 3);
+ CHECK_EQ_INT(c.interfaces[1].nic_index, 7);
+ CHECK_EQ_STR(c.interfaces[0].name, "0000:03:00.0");
+ CHECK_EQ_STR(c.interfaces[0].sip, "192.168.50.65");
+ CHECK_EQ_STR(c.interfaces[1].sip, "192.168.50.66"); /* upper-case SIP/DIP accepted */
+ CHECK_EQ_STR(c.interfaces[1].dip, "239.1.1.2");
+ CHECK_EQ_INT(c.video.width, 3840);
+ CHECK_EQ_INT(c.video.height, 2160);
+ CHECK_EQ_INT(c.video.fps, 60);
+ CHECK_EQ_STR(c.video.fmt, "gbrp12le");
+ CHECK_EQ_STR(c.log_file, "rx.log");
+ CHECK_EQ_STR(c.log_level, "debug");
+ CHECK(c.tasklet_sleep);
+ CHECK_EQ_INT(c.num_rx_sessions, 1);
+ CHECK_EQ_INT(c.rx_sessions[0].nic_index, 3);
+ CHECK_EQ_INT(c.rx_sessions[0].udp_port, 30000);
+ CHECK_EQ_INT(c.rx_sessions[0].payload_type, 112);
+ CHECK_EQ_INT(c.rx_sessions[0].crop.w, 1920);
+ CHECK_EQ_INT(c.rx_sessions[0].crop.h, 2160);
+ CHECK(!c.rx_sessions[0].has_window);
+ CHECK_EQ_STR(c.display_mode, "");
+ CHECK_EQ_INT(validate_rx_config(&c), 0);
+}
+
+static void test_parse_defaults(void) {
+ struct rx_config c;
+ REQUIRE(parse_text(
+ "{ \"interfaces\": [ { \"name\": \"a\" }, { \"name\": \"b\" } ],\n"
+ " \"video\": { \"width\": 1920, \"height\": 1080 },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 16 } } ] }", &c) == 0);
+ CHECK_EQ_INT(c.interfaces[0].nic_index, 0); /* nic_index defaults to position */
+ CHECK_EQ_INT(c.interfaces[1].nic_index, 1);
+ CHECK_EQ_INT(c.video.fps, 30);
+ CHECK_EQ_STR(c.video.fmt, "");
+ CHECK(!c.tasklet_sleep);
+ CHECK_EQ_INT(c.rx_sessions[0].nic_index, 0);
+ CHECK_EQ_INT(c.rx_sessions[0].udp_port, 20000);
+ CHECK_EQ_INT(c.rx_sessions[0].payload_type, 96);
+ CHECK_EQ_INT(c.rx_sessions[0].crop.x, 0);
+}
+
+static void test_parse_video_size_from_crops(void) {
+ struct rx_config c;
+ REQUIRE(parse_text(
+ "{ \"video\": { \"fps\": 30 },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"x\": 0, \"y\": 0, \"w\": 1920, \"h\": 2160 } },\n"
+ " { \"crop\": { \"x\": 1920, \"y\": 0, \"w\": 1920, \"h\": 2160 } } ] }",
+ &c) == 0);
+ CHECK_EQ_INT(c.video.width, 3840);
+ CHECK_EQ_INT(c.video.height, 2160);
+
+ REQUIRE(parse_text("{ \"video\": { \"fps\": 30 } }", &c) == 0);
+ CHECK_EQ_INT(c.video.width, 1920); /* no sessions: full HD fallback */
+ CHECK_EQ_INT(c.video.height, 1080);
+}
+
+static void test_parse_displays_and_mirrors(void) {
+ struct rx_config c;
+ REQUIRE(parse_text(
+ "{ \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 960, \"h\": 1080 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 960, \"w\": 960, \"h\": 1080 } } ],\n"
+ " \"displays\": [\n"
+ " { \"output\": \"DP-1\", \"sessions\": [\n"
+ " { \"session\": 0, \"x\": 0, \"y\": 0, \"scale_w\": 540, \"scale_h\": 1920 },\n"
+ " { \"session\": 1, \"x\": 540, \"y\": 0, \"scale_w\": 540, \"scale_h\": 1920 } ] },\n"
+ " { \"output\": \"HDMI-2\", \"sessions\": [\n"
+ " { \"session\": 1, \"x\": 10, \"y\": 20, \"w\": 300, \"h\": 400, \"scale_w\": 1080, \"scale_h\": 1920 } ] } ] }",
+ &c) == 0);
+ CHECK_EQ_STR(c.display_mode, "per_output"); /* implied by "displays" */
+ const struct rx_session_config* s0 = &c.rx_sessions[0];
+ const struct rx_session_config* s1 = &c.rx_sessions[1];
+ CHECK(s0->display_assigned && s0->has_window);
+ CHECK_EQ_STR(s0->output, "DP-1");
+ CHECK_EQ_INT(s0->scale_w, 540);
+ CHECK_EQ_INT(s0->num_mirrors, 0);
+ CHECK_EQ_STR(s1->output, "DP-1");
+ CHECK_EQ_INT(s1->window.x, 540);
+ CHECK_EQ_INT(s1->num_mirrors, 1); /* second placement becomes a mirror */
+ CHECK_EQ_STR(s1->mirrors[0].output, "HDMI-2");
+ CHECK_EQ_INT(s1->mirrors[0].window.x, 10);
+ CHECK_EQ_INT(s1->mirrors[0].window.y, 20);
+ CHECK_EQ_INT(s1->mirrors[0].window.w, 300);
+ CHECK_EQ_INT(s1->mirrors[0].window.h, 400);
+ CHECK_EQ_INT(s1->mirrors[0].scale_w, 1080);
+ CHECK_EQ_INT(s1->mirrors[0].scale_h, 1920);
+}
+
+static void test_parse_display_mode_not_overridden(void) {
+ struct rx_config c;
+ REQUIRE(parse_text("{ \"display_mode\": \"composite\", \"rx_sessions\": [ {} ],\n"
+ " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"session\": 0 } ] } ] }",
+ &c) == 0);
+ CHECK_EQ_STR(c.display_mode, "composite");
+}
+
+static void test_parse_display_unknown_session(void) {
+ struct rx_config c;
+ CHECK_EQ_INT(parse_text("{ \"rx_sessions\": [ {} ],\n"
+ " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"session\": 1 } ] } ] }",
+ &c), -1);
+ CHECK_EQ_INT(parse_text("{ \"rx_sessions\": [ {} ],\n"
+ " \"displays\": [ { \"output\": \"DP-1\", \"sessions\": [ { \"x\": 0 } ] } ] }",
+ &c), -1);
+}
+
+static void test_parse_too_many_mirrors(void) {
+ char json[2048];
+ struct rx_config c;
+ size_t n = 0;
+ REQUIRE(appendf(json, sizeof(json), &n, "{ \"rx_sessions\": [ {} ], \"displays\": ["));
+ for (int d = 0; d < MAX_MIRRORS + 2; d++)
+ REQUIRE(appendf(json, sizeof(json), &n,
+ "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }",
+ d ? "," : "", d));
+ REQUIRE(appendf(json, sizeof(json), &n, " ] }"));
+ CHECK_EQ_INT(parse_text(json, &c), -1);
+
+ /* exactly 1 primary + MAX_MIRRORS mirrors is fine */
+ n = 0;
+ REQUIRE(appendf(json, sizeof(json), &n, "{ \"rx_sessions\": [ {} ], \"displays\": ["));
+ for (int d = 0; d < MAX_MIRRORS + 1; d++)
+ REQUIRE(appendf(json, sizeof(json), &n,
+ "%s { \"output\": \"OUT-%d\", \"sessions\": [ { \"session\": 0 } ] }",
+ d ? "," : "", d));
+ REQUIRE(appendf(json, sizeof(json), &n, " ] }"));
+ REQUIRE(parse_text(json, &c) == 0);
+ CHECK_EQ_INT(c.rx_sessions[0].num_mirrors, MAX_MIRRORS);
+}
+
+static void test_parse_session_window_and_scale(void) {
+ struct rx_config c;
+ REQUIRE(parse_text(
+ "{ \"rx_sessions\": [ { \"output\": \"HDMI-1\", \"crop\": { \"w\": 960, \"h\": 540 },\n"
+ " \"window\": { \"x\": 5, \"y\": 6, \"w\": 7, \"h\": 8 }, \"scale\": { \"w\": 1920, \"h\": 1080 } },\n"
+ " { \"window\": 1, \"scale\": \"2x\" } ] }", &c) == 0);
+ const struct rx_session_config* s = &c.rx_sessions[0];
+ CHECK_EQ_STR(s->output, "HDMI-1");
+ CHECK(s->has_window && !s->display_assigned);
+ CHECK(s->window.x == 5 && s->window.y == 6 && s->window.w == 7 && s->window.h == 8);
+ CHECK_EQ_INT(s->scale_w, 1920);
+ CHECK_EQ_INT(s->scale_h, 1080);
+ CHECK(!c.rx_sessions[1].has_window); /* non-object window/scale ignored */
+ CHECK_EQ_INT(c.rx_sessions[1].scale_w, 0);
+}
+
+static void test_parse_display_without_sessions_ignored(void) {
+ struct rx_config c;
+ REQUIRE(parse_text("{ \"rx_sessions\": [ {} ],\n"
+ " \"displays\": [ { \"output\": \"DP-1\" },\n"
+ " { \"output\": \"DP-2\", \"sessions\": {} },\n"
+ " { \"output\": \"HDMI-2\", \"sessions\": [ { \"session\": 0 } ] } ] }",
+ &c) == 0);
+ CHECK_EQ_STR(c.rx_sessions[0].output, "HDMI-2");
+ CHECK_EQ_INT(c.rx_sessions[0].num_mirrors, 0);
+}
+
+static void test_parse_truncates_arrays(void) {
+ char json[8192];
+ struct rx_config c;
+ size_t n = 0;
+ REQUIRE(appendf(json, sizeof(json), &n, "{ \"interfaces\": ["));
+ for (int i = 0; i < MAX_INTERFACES + 2; i++)
+ REQUIRE(appendf(json, sizeof(json), &n, "%s{ \"name\": \"n%d\" }", i ? "," : "", i));
+ REQUIRE(appendf(json, sizeof(json), &n, "], \"rx_sessions\": ["));
+ for (int i = 0; i < MAX_RX_SESSIONS + 3; i++)
+ REQUIRE(appendf(json, sizeof(json), &n, "%s{ \"udp_port\": %d }", i ? "," : "", 20000 + i));
+ REQUIRE(appendf(json, sizeof(json), &n, "] }"));
+ REQUIRE(parse_text(json, &c) == 0);
+ CHECK_EQ_INT(c.num_interfaces, MAX_INTERFACES);
+ CHECK_EQ_INT(c.num_rx_sessions, MAX_RX_SESSIONS);
+}
+
+static void test_parse_long_strings_truncated(void) {
+ char json[512], name[200];
+ struct rx_config c;
+ memset(name, 'x', sizeof(name) - 1);
+ name[sizeof(name) - 1] = '\0';
+ snprintf(json, sizeof(json), "{ \"interfaces\": [ { \"name\": \"%s\" } ] }", name);
+ REQUIRE(parse_text(json, &c) == 0);
+ CHECK_EQ_INT(strlen(c.interfaces[0].name), sizeof(c.interfaces[0].name) - 1);
+}
+
+static void test_parse_wrong_types_ignored(void) {
+ struct rx_config c;
+ REQUIRE(parse_text("{ \"video\": { \"width\": \"1920\", \"fps\": \"60\", \"fmt\": 5 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": \"x\", \"crop\": [] } ] }", &c) == 0);
+ CHECK_EQ_INT(c.video.fps, 30);
+ CHECK_EQ_STR(c.video.fmt, "");
+ CHECK_EQ_INT(c.rx_sessions[0].udp_port, 20000);
+ CHECK_EQ_INT(c.rx_sessions[0].crop.w, 0);
+}
+
+static void test_parse_errors(void) {
+ struct rx_config c;
+ CHECK_EQ_INT(parse_text("{ \"interfaces\": [ ", &c), -1);
+ CHECK_EQ_INT(parse_rx_config("does-not-exist.json", &c), -1);
+ CHECK_EQ_INT(parse_rx_config(NULL, &c), -1);
+ CHECK_EQ_INT(parse_rx_config("cfg.json", NULL), -1);
+ CHECK_EQ_INT(load_and_validate_rx_config("", &c), -1);
+ CHECK_EQ_INT(load_and_validate_rx_config(NULL, &c), -1);
+ CHECK_EQ_INT(validate_rx_config(NULL), -1);
+}
+
+static void test_load_and_validate(void) {
+ struct rx_config c;
+ REQUIRE(write_file("ok.json",
+ "{ \"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.168.85.20\" } ],\n"
+ " \"video\": { \"fps\": 30, \"fmt\": \"yuv422p10le\" },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 1920, \"h\": 1080 } } ] }") == 0);
+ CHECK_EQ_INT(load_and_validate_rx_config("ok.json", &c), 0);
+ REQUIRE(write_file("bad.json",
+ "{ \"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\" } ],\n"
+ " \"video\": { \"fps\": 61 }, \"rx_sessions\": [ { \"crop\": { \"w\": 8, \"h\": 8 } } ] }") == 0);
+ CHECK_EQ_INT(load_and_validate_rx_config("bad.json", &c), -1);
+}
+
+/* --------------------------------------------------------------- validation */
+
+static void test_validate_baseline(void) {
+ EXPECT_VALID((void)c);
+ EXPECT_VALID(c->video.fmt[0] = '\0'); /* empty fmt = yuv422p10le */
+ EXPECT_VALID(c->rx_sessions[1].udp_port = 20000); /* same port on another NIC */
+}
+
+static void test_validate_interfaces(void) {
+ EXPECT_INVALID(c->num_interfaces = 0);
+ EXPECT_INVALID(c->interfaces[1].name[0] = '\0');
+ EXPECT_INVALID(snprintf(c->interfaces[0].sip, sizeof(c->interfaces[0].sip), "192.168.50"));
+ EXPECT_INVALID(c->interfaces[0].sip[0] = '\0');
+ EXPECT_INVALID(c->interfaces[0].dip[0] = '\0');
+ EXPECT_INVALID(snprintf(c->interfaces[0].dip, sizeof(c->interfaces[0].dip), "239.1.1.256"));
+ EXPECT_INVALID(c->interfaces[1].nic_index = 0);
+ EXPECT_INVALID(snprintf(c->interfaces[1].name, sizeof(c->interfaces[1].name), "0000:03:00.0"));
+}
+
+static void test_validate_video_size(void) {
+ EXPECT_VALID((c->video.width = 3840, c->video.height = 2160,
+ c->rx_sessions[1].crop.x = 2880));
+ EXPECT_VALID((c->video.width = 2160, c->video.height = 3840)); /* portrait 4K */
+ EXPECT_INVALID(c->video.width = 0);
+ EXPECT_INVALID(c->video.height = -1);
+ EXPECT_INVALID(c->video.width = 3841);
+ EXPECT_INVALID((c->video.width = 3840, c->video.height = 3840)); /* > 4K pixels */
+ EXPECT_INVALID((c->video.width = 4096, c->video.height = 2160));
+}
+
+static void test_validate_fps(void) {
+ static const int ok[] = {24, 25, 29, 30, 50, 59, 60};
+ static const int bad[] = {0, -30, 1, 23, 31, 45, 61, 120};
+ for (size_t i = 0; i < sizeof(ok) / sizeof(ok[0]); i++) EXPECT_VALID(c->video.fps = ok[i]);
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) EXPECT_INVALID(c->video.fps = bad[i]);
+}
+
+static void test_validate_fmt(void) {
+ static const char* ok[] = {"yuv422p10le", "yuv422p12le", "yuv444p10le", "yuv444p12le",
+ "gbrp10le", "gbrp12le", "yuv420"};
+ static const char* bad[] = {"nv12", "YUV422P10LE", "yuv422p10", "rgb24", " "};
+ for (size_t i = 0; i < sizeof(ok) / sizeof(ok[0]); i++)
+ EXPECT_VALID(snprintf(c->video.fmt, sizeof(c->video.fmt), "%s", ok[i]));
+ for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++)
+ EXPECT_INVALID(snprintf(c->video.fmt, sizeof(c->video.fmt), "%s", bad[i]));
+}
+
+static void test_validate_display_mode(void) {
+ EXPECT_VALID(snprintf(c->display_mode, sizeof(c->display_mode), "composite"));
+ EXPECT_VALID(snprintf(c->display_mode, sizeof(c->display_mode), "per_output"));
+ EXPECT_INVALID(snprintf(c->display_mode, sizeof(c->display_mode), "tiled"));
+}
+
+static void test_validate_sessions(void) {
+ EXPECT_INVALID(c->num_rx_sessions = 0);
+ EXPECT_INVALID(c->rx_sessions[0].crop.w = 0);
+ EXPECT_INVALID(c->rx_sessions[0].crop.h = -4);
+ EXPECT_INVALID(c->rx_sessions[0].crop.x = -1);
+ EXPECT_INVALID(c->rx_sessions[0].crop.y = -1);
+ EXPECT_INVALID(c->rx_sessions[1].crop.x = 961); /* x + w > width */
+ EXPECT_INVALID(c->rx_sessions[1].crop.y = 1); /* y + h > height */
+ EXPECT_VALID(c->rx_sessions[1].udp_port = 65535);
+ EXPECT_VALID(c->rx_sessions[1].udp_port = 1);
+ EXPECT_INVALID(c->rx_sessions[1].udp_port = 0);
+ EXPECT_INVALID(c->rx_sessions[1].udp_port = 65536);
+ EXPECT_VALID(c->rx_sessions[1].payload_type = 127);
+ EXPECT_INVALID(c->rx_sessions[1].payload_type = 95);
+ EXPECT_INVALID(c->rx_sessions[1].payload_type = 128);
+ EXPECT_INVALID((c->rx_sessions[1].nic_index = 0, c->rx_sessions[1].udp_port = 20000));
+ EXPECT_INVALID(c->rx_sessions[1].nic_index = 5); /* no interface with nic_index 5 */
+}
+
+static void test_validate_windows(void) {
+ EXPECT_VALID((c->rx_sessions[0].has_window = true,
+ c->rx_sessions[0].window = (struct crop_config){0, 0, 540, 1920}));
+ EXPECT_INVALID((c->rx_sessions[0].has_window = true, c->rx_sessions[0].window.x = -1));
+ EXPECT_INVALID((c->rx_sessions[0].has_window = true, c->rx_sessions[0].window.w = 3841));
+ EXPECT_VALID((c->rx_sessions[0].scale_w = 3840, c->rx_sessions[0].scale_h = 2160));
+ EXPECT_INVALID(c->rx_sessions[0].scale_w = 540); /* scale_h missing */
+ EXPECT_INVALID(c->rx_sessions[0].scale_h = 540);
+ EXPECT_INVALID((c->rx_sessions[0].scale_w = 3841, c->rx_sessions[0].scale_h = 100));
+ EXPECT_INVALID((c->rx_sessions[0].scale_w = -1, c->rx_sessions[0].scale_h = -1));
+ /* placed by "displays" but with no output name */
+ EXPECT_INVALID(c->rx_sessions[0].display_assigned = true);
+ EXPECT_VALID((c->rx_sessions[0].display_assigned = true,
+ snprintf(c->rx_sessions[0].output, sizeof(c->rx_sessions[0].output), "DP-1")));
+}
+
+static void test_validate_mirrors(void) {
+#define ADD_MIRROR(out, sw, sh) \
+ do { \
+ struct display_placement* m_ = &c->rx_sessions[0].mirrors[c->rx_sessions[0].num_mirrors++]; \
+ snprintf(m_->output, sizeof(m_->output), "%s", out); \
+ m_->scale_w = sw; \
+ m_->scale_h = sh; \
+ } while (0)
+ EXPECT_VALID(ADD_MIRROR("HDMI-2", 1080, 1920));
+ EXPECT_INVALID(ADD_MIRROR("", 1080, 1920));
+ EXPECT_INVALID(ADD_MIRROR("HDMI-2", 1080, 0));
+ EXPECT_INVALID(ADD_MIRROR("HDMI-2", 4000, 1920));
+ EXPECT_INVALID(ADD_MIRROR("HDMI-2", 0, 0); c->rx_sessions[0].mirrors[0].window.y = -5);
+#undef ADD_MIRROR
+}
+
+static void test_validate_bandwidth_is_warning_only(void) {
+ /* 4K60 12-bit 4:4:4 on one NIC is ~18.8 Gb/s: warned about, not rejected */
+ EXPECT_VALID((c->video.width = 3840, c->video.height = 2160, c->video.fps = 60,
+ snprintf(c->video.fmt, sizeof(c->video.fmt), "gbrp12le"),
+ c->num_rx_sessions = 1,
+ c->rx_sessions[0].crop = (struct crop_config){0, 0, 3840, 2160}));
+}
+
+static void test_validate_log_file(void) {
+ EXPECT_VALID(snprintf(c->log_file, sizeof(c->log_file), "rx.log"));
+ EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "/tmp/rx.log"));
+ EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "missing-dir/rx.log"));
+ EXPECT_INVALID(snprintf(c->log_file, sizeof(c->log_file), "logs/"));
+}
+
+static void test_port_for_nic(void) {
+ struct rx_config c;
+ make_valid(&c);
+ c.interfaces[0].nic_index = 4;
+ c.interfaces[1].nic_index = 2;
+ CHECK_EQ_INT(rx_config_port_for_nic(&c, 4), 0);
+ CHECK_EQ_INT(rx_config_port_for_nic(&c, 2), 1);
+ CHECK_EQ_INT(rx_config_port_for_nic(&c, 0), -1);
+ CHECK_EQ_INT(rx_config_port_for_nic(&c, -1), -1);
+}
+
+/* Every shipped config under config/ must load and validate. */
+static void test_repo_configs_valid(void) {
+ if (!g_config_dir) {
+ fprintf(stderr, " (skipped: no config dir argument)\n");
+ return;
+ }
+ DIR* d = opendir(g_config_dir);
+ REQUIRE(d != NULL);
+ int n = 0;
+ struct dirent* e;
+ while ((e = readdir(d)) != NULL) {
+ size_t len = strlen(e->d_name);
+ if (len < 5 || strcmp(e->d_name + len - 5, ".json") != 0) continue;
+ char path[PATH_MAX];
+ struct rx_config c;
+ snprintf(path, sizeof(path), "%s/%s", g_config_dir, e->d_name);
+ int rc = load_and_validate_rx_config(path, &c);
+ if (rc != 0) fprintf(stderr, " %s is invalid\n", e->d_name);
+ CHECK_EQ_INT(rc, 0);
+ n++;
+ }
+ closedir(d);
+ CHECK(n > 0);
+}
+
+int main(int argc, char** argv) {
+ char dir[PATH_MAX];
+ if (argc > 1 && realpath(argv[1], dir)) g_config_dir = dir;
+ logger_set_level(LOG_LEVEL_ERROR + 1); /* keep expected-error noise out of the output */
+ scratch_enter("config");
+
+ RUN_TEST(test_parse_full_config);
+ RUN_TEST(test_parse_defaults);
+ RUN_TEST(test_parse_video_size_from_crops);
+ RUN_TEST(test_parse_displays_and_mirrors);
+ RUN_TEST(test_parse_display_mode_not_overridden);
+ RUN_TEST(test_parse_display_unknown_session);
+ RUN_TEST(test_parse_too_many_mirrors);
+ RUN_TEST(test_parse_session_window_and_scale);
+ RUN_TEST(test_parse_display_without_sessions_ignored);
+ RUN_TEST(test_parse_truncates_arrays);
+ RUN_TEST(test_parse_long_strings_truncated);
+ RUN_TEST(test_parse_wrong_types_ignored);
+ RUN_TEST(test_parse_errors);
+ RUN_TEST(test_load_and_validate);
+ RUN_TEST(test_validate_baseline);
+ RUN_TEST(test_validate_interfaces);
+ RUN_TEST(test_validate_video_size);
+ RUN_TEST(test_validate_fps);
+ RUN_TEST(test_validate_fmt);
+ RUN_TEST(test_validate_display_mode);
+ RUN_TEST(test_validate_sessions);
+ RUN_TEST(test_validate_windows);
+ RUN_TEST(test_validate_mirrors);
+ RUN_TEST(test_validate_bandwidth_is_warning_only);
+ RUN_TEST(test_validate_log_file);
+ RUN_TEST(test_port_for_nic);
+ RUN_TEST(test_repo_configs_valid);
+
+ scratch_leave();
+ return test_summary();
+}
diff --git a/dvledrx/tests/test_frame_converter.c b/dvledrx/tests/test_frame_converter.c
new file mode 100644
index 0000000..19914d4
--- /dev/null
+++ b/dvledrx/tests/test_frame_converter.c
@@ -0,0 +1,376 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Unit tests for frame_converter: BT.601 math, every supported ST 2110 pixel
+ * format to BGRA (incl. padded line strides) and nearest-neighbour scaling.
+ */
+
+#include "test_common.h"
+
+#include
+
+#include "frame_converter.h"
+#include "logger.h"
+#include "st_pipeline_api.h"
+
+/* ------------------------------------------------------------ test frames */
+
+static uint32_t g_rand = 12345;
+static uint32_t rnd(void) {
+ g_rand = g_rand * 1103515245u + 12345u;
+ return g_rand >> 8;
+}
+
+static bool is_12bit(enum st_frame_fmt fmt) {
+ return fmt == ST_FRAME_FMT_YUV422PLANAR12LE || fmt == ST_FRAME_FMT_YUV444PLANAR12LE ||
+ fmt == ST_FRAME_FMT_GBRPLANAR12LE;
+}
+
+/* 16-bit planar frame with pad_px extra pixels per line; random samples at the
+ * format's bit depth, or every sample = value when value >= 0. */
+static void frame_alloc(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h,
+ uint32_t pad_px, int value) {
+ memset(f, 0, sizeof(*f));
+ f->fmt = fmt;
+ f->width = w;
+ f->height = h;
+ uint16_t mask = is_12bit(fmt) ? 0xFFF : 0x3FF;
+ for (int p = 0; p < 3; p++) {
+ uint32_t pw = (p > 0 && is_yuv422_format(fmt)) ? w / 2 : w;
+ size_t stride = pw + pad_px;
+ uint16_t* plane = malloc(stride * h * sizeof(uint16_t));
+ for (size_t i = 0; i < stride * h; i++)
+ plane[i] = value >= 0 ? (uint16_t)value : (uint16_t)(rnd() & mask);
+ f->addr[p] = plane;
+ f->linesize[p] = stride * sizeof(uint16_t);
+ }
+}
+
+static void frame_free(struct st_frame* f) {
+ for (int p = 0; p < ST_MAX_PLANES; p++) {
+ free(f->addr[p]);
+ f->addr[p] = NULL;
+ }
+}
+
+/* Expected BGRA of source pixel (x, y), written from the format definitions. */
+static void ref_bgra(const struct st_frame* f, uint32_t x, uint32_t y, uint8_t out[4]) {
+ int sh = is_12bit(f->fmt) ? 4 : 2;
+ const uint16_t* p0 = (const uint16_t*)f->addr[0] + y * (f->linesize[0] / 2);
+ const uint16_t* p1 = (const uint16_t*)f->addr[1] + y * (f->linesize[1] / 2);
+ const uint16_t* p2 = (const uint16_t*)f->addr[2] + y * (f->linesize[2] / 2);
+ out[3] = 255;
+ if (f->fmt == ST_FRAME_FMT_GBRPLANAR10LE || f->fmt == ST_FRAME_FMT_GBRPLANAR12LE) {
+ out[0] = (p1[x] >> sh) & 0xFF; /* plane order is G, B, R */
+ out[1] = (p0[x] >> sh) & 0xFF;
+ out[2] = (p2[x] >> sh) & 0xFF;
+ return;
+ }
+ uint32_t cx = is_yuv422_format(f->fmt) ? x / 2 : x;
+ yuv_to_rgb((p0[x] >> sh) & 0xFF, (p1[cx] >> sh) & 0xFF, (p2[cx] >> sh) & 0xFF,
+ &out[2], &out[1], &out[0]);
+}
+
+static const enum st_frame_fmt k_planar_fmts[] = {
+ ST_FRAME_FMT_YUV422PLANAR10LE, ST_FRAME_FMT_YUV422PLANAR12LE,
+ ST_FRAME_FMT_YUV444PLANAR10LE, ST_FRAME_FMT_YUV444PLANAR12LE,
+ ST_FRAME_FMT_GBRPLANAR10LE, ST_FRAME_FMT_GBRPLANAR12LE,
+};
+#define N_PLANAR_FMTS (sizeof(k_planar_fmts) / sizeof(k_planar_fmts[0]))
+
+/* ------------------------------------------------------------------ tests */
+
+static void test_yuv_to_rgb_reference_colours(void) {
+ uint8_t r, g, b;
+ yuv_to_rgb(16, 128, 128, &r, &g, &b); /* studio black */
+ CHECK(r == 0 && g == 0 && b == 0);
+ yuv_to_rgb(235, 128, 128, &r, &g, &b); /* studio white */
+ CHECK(r == 255 && g == 255 && b == 255);
+ yuv_to_rgb(81, 90, 240, &r, &g, &b); /* BT.601 red */
+ CHECK(r == 255 && g == 0 && b == 0);
+ yuv_to_rgb(126, 128, 128, &r, &g, &b); /* mid grey: neutral chroma keeps r == g == b */
+ CHECK(r == g && g == b && r > 100 && r < 150);
+ yuv_to_rgb(0, 128, 128, &r, &g, &b); /* below black clamps to 0 */
+ CHECK(r == 0 && g == 0 && b == 0);
+ yuv_to_rgb(255, 255, 255, &r, &g, &b); /* above white clamps to 255 */
+ CHECK(r == 255 && b == 255);
+}
+
+static void test_format_helpers(void) {
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV422PLANAR10LE), 2);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV444PLANAR10LE), 2);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_GBRPLANAR10LE), 2);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV422PLANAR12LE), 4);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV444PLANAR12LE), 4);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_GBRPLANAR12LE), 4);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_YUV420PLANAR8), 0);
+ CHECK_EQ_INT(get_format_bit_shift(ST_FRAME_FMT_BGRA), 0);
+ CHECK(is_yuv422_format(ST_FRAME_FMT_YUV422PLANAR10LE));
+ CHECK(is_yuv422_format(ST_FRAME_FMT_YUV422PLANAR12LE));
+ CHECK(!is_yuv422_format(ST_FRAME_FMT_YUV444PLANAR10LE));
+ CHECK(!is_yuv422_format(ST_FRAME_FMT_GBRPLANAR12LE));
+}
+
+static void test_convert_rejects_bad_input(void) {
+ struct st_frame f;
+ uint8_t buf[16 * 4 * 4];
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 4, 0, 0);
+ CHECK_EQ_INT(convert_frame_to_rgb(NULL, buf, 4, 4), -1);
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, NULL, 4, 4), -1);
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 4, 2), -1); /* size mismatch */
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 2, 4), -1);
+ void* y = f.addr[0];
+ f.addr[0] = NULL;
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 4, 4), -1);
+ f.addr[0] = y;
+ frame_free(&f);
+}
+
+static void test_convert_uniform_white_all_formats(void) {
+ for (size_t i = 0; i < N_PLANAR_FMTS; i++) {
+ struct st_frame f;
+ enum st_frame_fmt fmt = k_planar_fmts[i];
+ bool gbr = fmt == ST_FRAME_FMT_GBRPLANAR10LE || fmt == ST_FRAME_FMT_GBRPLANAR12LE;
+ int sh = is_12bit(fmt) ? 4 : 2;
+ frame_alloc(&f, fmt, 8, 2, 0, (gbr ? 255 : 235) << sh);
+ if (!gbr) { /* neutral chroma */
+ for (int p = 1; p < 3; p++)
+ for (size_t k = 0; k < f.linesize[p] / 2 * f.height; k++)
+ ((uint16_t*)f.addr[p])[k] = 128 << sh;
+ }
+ uint8_t buf[8 * 2 * 4];
+ memset(buf, 0, sizeof(buf));
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, 8, 2), 0);
+ for (size_t k = 0; k < sizeof(buf); k++) CHECK_EQ_INT(buf[k], 255);
+ frame_free(&f);
+ }
+}
+
+/* Random content with padded lines: every pixel must match the reference. */
+static void test_convert_matches_reference_all_formats(void) {
+ const uint32_t w = 38, h = 7, pad = 5;
+ for (size_t i = 0; i < N_PLANAR_FMTS; i++) {
+ struct st_frame f;
+ frame_alloc(&f, k_planar_fmts[i], w, h, pad, -1);
+ uint8_t* buf = malloc(w * h * 4);
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, buf, w, h), 0);
+ int bad = 0;
+ for (uint32_t y = 0; y < h; y++)
+ for (uint32_t x = 0; x < w; x++) {
+ uint8_t exp[4];
+ ref_bgra(&f, x, y, exp);
+ if (memcmp(exp, buf + (y * w + x) * 4, 4) != 0) bad++;
+ }
+ if (bad) fprintf(stderr, " fmt %d: %d pixel(s) differ\n", k_planar_fmts[i], bad);
+ CHECK_EQ_INT(bad, 0);
+ free(buf);
+ frame_free(&f);
+ }
+}
+
+static void test_convert_bit_depth_uses_top_bits(void) {
+ /* 12-bit 0xABC keeps 0xAB, 10-bit 0x2F3 keeps 0xBC (drops the 2 low bits) */
+ struct st_frame f;
+ uint8_t px[8];
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR12LE, 2, 1, 0, 0xABC);
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 2, 1), 0);
+ CHECK(px[0] == 0xAB && px[1] == 0xAB && px[2] == 0xAB && px[3] == 255);
+ frame_free(&f);
+
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 2, 1, 0, 0x2F3);
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 2, 1), 0);
+ CHECK(px[0] == 0xBC && px[1] == 0xBC && px[2] == 0xBC);
+ frame_free(&f);
+}
+
+static void test_convert_gbr_plane_order(void) {
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 1, 1, 0, 0);
+ ((uint16_t*)f.addr[0])[0] = 20 << 2; /* G */
+ ((uint16_t*)f.addr[1])[0] = 30 << 2; /* B */
+ ((uint16_t*)f.addr[2])[0] = 10 << 2; /* R */
+ uint8_t px[4];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 1, 1), 0);
+ CHECK(px[0] == 30 && px[1] == 20 && px[2] == 10 && px[3] == 255); /* B, G, R, A */
+ frame_free(&f);
+}
+
+static void test_convert_yuv422_shares_chroma(void) {
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 1, 0, 126 << 2);
+ ((uint16_t*)f.addr[1])[0] = 128 << 2; /* pixels 0,1: neutral */
+ ((uint16_t*)f.addr[2])[0] = 128 << 2;
+ ((uint16_t*)f.addr[1])[1] = 90 << 2; /* pixels 2,3: reddish */
+ ((uint16_t*)f.addr[2])[1] = 240 << 2;
+ uint8_t px[16];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, px, 4, 1), 0);
+ CHECK(memcmp(px, px + 4, 4) == 0);
+ CHECK(memcmp(px + 8, px + 12, 4) == 0);
+ CHECK(memcmp(px, px + 8, 4) != 0);
+ CHECK(px[0] == px[1] && px[1] == px[2]); /* grey */
+ CHECK(px[10] > px[8]); /* red > blue */
+ frame_free(&f);
+}
+
+static void test_convert_bgra_passthrough(void) {
+ uint8_t src[3 * 2 * 4], dst[3 * 2 * 4];
+ for (size_t i = 0; i < sizeof(src); i++) src[i] = (uint8_t)(i * 7);
+ struct st_frame f = {0};
+ f.fmt = ST_FRAME_FMT_BGRA;
+ f.width = 3;
+ f.height = 2;
+ f.addr[0] = src;
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, 3, 2), 0);
+ CHECK(memcmp(src, dst, sizeof(src)) == 0);
+}
+
+static void test_convert_i420_custom8(void) {
+ const uint32_t w = 6, h = 4;
+ uint8_t src[6 * 4 + 2 * 3 * 2];
+ for (size_t i = 0; i < sizeof(src); i++) src[i] = (uint8_t)(rnd() & 0xFF);
+ struct st_frame f = {0};
+ f.fmt = ST_FRAME_FMT_YUV420CUSTOM8;
+ f.width = w;
+ f.height = h;
+ f.addr[0] = src;
+ uint8_t dst[6 * 4 * 4];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, w, h), 0);
+ int bad = 0;
+ for (uint32_t y = 0; y < h; y++)
+ for (uint32_t x = 0; x < w; x++) {
+ uint8_t r, g, b;
+ uint8_t U = src[w * h + (y / 2) * (w / 2) + x / 2];
+ uint8_t V = src[w * h + (w / 2) * (h / 2) + (y / 2) * (w / 2) + x / 2];
+ yuv_to_rgb(src[y * w + x], U, V, &r, &g, &b);
+ const uint8_t* px = dst + (y * w + x) * 4;
+ if (px[0] != b || px[1] != g || px[2] != r || px[3] != 255) bad++;
+ }
+ CHECK_EQ_INT(bad, 0);
+}
+
+static void test_convert_unsupported_fills_pattern(void) {
+ uint8_t src[4 * 3 * 2];
+ struct st_frame f = {0};
+ f.fmt = ST_FRAME_FMT_UYVY;
+ f.width = 4;
+ f.height = 3;
+ f.addr[0] = src;
+ uint8_t dst[4 * 3 * 4];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, 4, 3), 0);
+ const uint8_t* px = dst + (2 * 4 + 3) * 4; /* x = 3, y = 2 */
+ CHECK(px[0] == 5 && px[1] == 3 && px[2] == 2 && px[3] == 255);
+}
+
+static void test_convert_yuv420_planar8(void) {
+ const uint32_t w = 6, h = 4;
+ uint8_t py[6 * 4], pu[3 * 2], pv[3 * 2];
+ for (size_t i = 0; i < sizeof(py); i++) py[i] = (uint8_t)rnd();
+ for (size_t i = 0; i < sizeof(pu); i++) pu[i] = (uint8_t)rnd(), pv[i] = (uint8_t)rnd();
+ struct st_frame f = {0};
+ f.fmt = ST_FRAME_FMT_YUV420PLANAR8;
+ f.width = w;
+ f.height = h;
+ f.addr[0] = py;
+ f.addr[1] = pu;
+ f.addr[2] = pv;
+ uint8_t dst[6 * 4 * 4];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, dst, w, h), 0);
+ int bad = 0;
+ for (uint32_t y = 0; y < h; y++)
+ for (uint32_t x = 0; x < w; x++) {
+ uint8_t r, g, b;
+ uint32_t c = (y / 2) * (w / 2) + x / 2;
+ yuv_to_rgb(py[y * w + x], pu[c], pv[c], &r, &g, &b);
+ const uint8_t* px = dst + (y * w + x) * 4;
+ if (px[0] != b || px[1] != g || px[2] != r || px[3] != 255) bad++;
+ }
+ CHECK_EQ_INT(bad, 0);
+}
+
+static void test_scaled_rejects_bad_input(void) {
+ struct st_frame f;
+ uint8_t buf[64];
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 4, 2, 0, 0);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(NULL, buf, 2, 2), -1);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, NULL, 2, 2), -1);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 0, 2), -1);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 0), -1);
+ void* v = f.addr[2];
+ f.addr[2] = NULL;
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1);
+ f.addr[2] = v;
+ f.fmt = ST_FRAME_FMT_BGRA; /* only 16-bit planar formats are scaled */
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1);
+ f.fmt = ST_FRAME_FMT_YUV422PLANAR10LE;
+ f.width = 0;
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, 2, 2), -1);
+ f.width = 4;
+ frame_free(&f);
+}
+
+static void test_scaled_identity_equals_full_conversion(void) {
+ const uint32_t w = 24, h = 6;
+ for (size_t i = 0; i < N_PLANAR_FMTS; i++) {
+ struct st_frame f;
+ frame_alloc(&f, k_planar_fmts[i], w, h, 3, -1);
+ uint8_t a[24 * 6 * 4], b[24 * 6 * 4];
+ CHECK_EQ_INT(convert_frame_to_rgb(&f, a, w, h), 0);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, b, w, h), 0);
+ CHECK(memcmp(a, b, sizeof(a)) == 0);
+ frame_free(&f);
+ }
+}
+
+/* Nearest neighbour: dst(x, y) = src(x * sw / dw, y * sh / dh). */
+static void check_scaled(uint32_t sw, uint32_t sh, uint32_t dw, uint32_t dh) {
+ for (size_t i = 0; i < N_PLANAR_FMTS; i++) {
+ struct st_frame f;
+ frame_alloc(&f, k_planar_fmts[i], sw, sh, 2, -1);
+ uint8_t* buf = malloc((size_t)dw * dh * 4);
+ CHECK_EQ_INT(convert_frame_to_rgb_scaled(&f, buf, dw, dh), 0);
+ int bad = 0;
+ for (uint32_t y = 0; y < dh; y++)
+ for (uint32_t x = 0; x < dw; x++) {
+ uint8_t exp[4];
+ ref_bgra(&f, (uint32_t)((uint64_t)x * sw / dw), (uint32_t)((uint64_t)y * sh / dh), exp);
+ if (memcmp(exp, buf + ((size_t)y * dw + x) * 4, 4) != 0) bad++;
+ }
+ if (bad) fprintf(stderr, " fmt %d %ux%u -> %ux%u: %d pixel(s) differ\n",
+ k_planar_fmts[i], sw, sh, dw, dh, bad);
+ CHECK_EQ_INT(bad, 0);
+ free(buf);
+ frame_free(&f);
+ }
+}
+
+static void test_scaled_up_and_down(void) {
+ check_scaled(16, 8, 32, 16); /* 2x up */
+ check_scaled(16, 8, 8, 4); /* 2x down */
+ check_scaled(16, 8, 5, 3); /* odd down */
+ check_scaled(12, 6, 30, 23); /* non-integer up */
+ check_scaled(20, 10, 20, 40); /* stretch one axis */
+}
+
+int main(void) {
+ logger_set_level(LOG_LEVEL_ERROR + 1); /* keep expected warnings out of the output */
+ scratch_enter("converter");
+
+ RUN_TEST(test_yuv_to_rgb_reference_colours);
+ RUN_TEST(test_format_helpers);
+ RUN_TEST(test_convert_rejects_bad_input);
+ RUN_TEST(test_convert_uniform_white_all_formats);
+ RUN_TEST(test_convert_matches_reference_all_formats);
+ RUN_TEST(test_convert_bit_depth_uses_top_bits);
+ RUN_TEST(test_convert_gbr_plane_order);
+ RUN_TEST(test_convert_yuv422_shares_chroma);
+ RUN_TEST(test_convert_bgra_passthrough);
+ RUN_TEST(test_convert_i420_custom8);
+ RUN_TEST(test_convert_unsupported_fills_pattern);
+ RUN_TEST(test_convert_yuv420_planar8);
+ RUN_TEST(test_scaled_rejects_bad_input);
+ RUN_TEST(test_scaled_identity_equals_full_conversion);
+ RUN_TEST(test_scaled_up_and_down);
+
+ scratch_leave();
+ return test_summary();
+}
diff --git a/dvledrx/tests/test_logger.c b/dvledrx/tests/test_logger.c
new file mode 100644
index 0000000..d913590
--- /dev/null
+++ b/dvledrx/tests/test_logger.c
@@ -0,0 +1,343 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Unit tests for logger: level parsing/filtering, safe log paths, file output
+ * and size-based rotation into gzip archives.
+ */
+
+#include "test_common.h"
+
+#include
+#include
+
+#include "logger.h"
+
+/* Read a whole (plain or gzip) file into buf; returns bytes read or -1. */
+static long read_gz(const char* path, char* buf, size_t len) {
+ gzFile gz = gzopen(path, "rb");
+ if (!gz) return -1;
+ int n = gzread(gz, buf, (unsigned)len - 1);
+ gzclose(gz);
+ if (n < 0) return -1;
+ buf[n] = '\0';
+ return n;
+}
+
+static void test_parse_level(void) {
+ enum log_level l = LOG_LEVEL_ERROR;
+ CHECK(logger_parse_level("debug", &l) && l == LOG_LEVEL_DEBUG);
+ CHECK(logger_parse_level("INFO", &l) && l == LOG_LEVEL_INFO);
+ CHECK(logger_parse_level("Warn", &l) && l == LOG_LEVEL_WARN);
+ CHECK(logger_parse_level("warning", &l) && l == LOG_LEVEL_WARN);
+ CHECK(logger_parse_level("error", &l) && l == LOG_LEVEL_ERROR);
+ l = LOG_LEVEL_INFO;
+ CHECK(!logger_parse_level("verbose", &l));
+ CHECK(!logger_parse_level("", &l));
+ CHECK(!logger_parse_level("debugdebugdebugdebug", &l)); /* longer than the buffer */
+ CHECK(l == LOG_LEVEL_INFO); /* untouched on failure */
+ CHECK(!logger_parse_level(NULL, &l));
+ CHECK(!logger_parse_level("info", NULL));
+}
+
+static void test_set_get_level_and_env(void) {
+ logger_set_level(LOG_LEVEL_WARN);
+ CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_WARN);
+
+ setenv("RXAPP_LOG_LEVEL", "debug", 1);
+ logger_init_from_env();
+ CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_DEBUG);
+
+ setenv("RXAPP_LOG_LEVEL", "bogus", 1);
+ logger_init_from_env();
+ CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_DEBUG); /* invalid value ignored */
+
+ unsetenv("RXAPP_LOG_LEVEL");
+ logger_set_level(LOG_LEVEL_INFO);
+ logger_init_from_env();
+ CHECK_EQ_INT(logger_get_level(), LOG_LEVEL_INFO);
+}
+
+static void test_path_is_safe(void) {
+ char why[512];
+ CHECK(logger_path_is_safe("app.log", why, sizeof(why)));
+ CHECK(logger_path_is_safe("./app.log", why, sizeof(why)));
+
+ REQUIRE(mkdir("logs", 0750) == 0);
+ CHECK(logger_path_is_safe("logs/app.log", why, sizeof(why)));
+
+ why[0] = '\0';
+ CHECK(!logger_path_is_safe("", why, sizeof(why)));
+ CHECK(why[0] != '\0');
+ CHECK(!logger_path_is_safe(NULL, why, sizeof(why)));
+ CHECK(!logger_path_is_safe("logs/", why, sizeof(why)));
+ CHECK(!logger_path_is_safe("nope/app.log", why, sizeof(why)));
+ CHECK(!logger_path_is_safe("/tmp/app.log", why, sizeof(why)));
+ CHECK(strstr(why, "world-writable") != NULL);
+ CHECK(!logger_path_is_safe("logs", why, sizeof(why))); /* existing directory */
+}
+
+static void test_path_is_safe_world_writable_parent(void) {
+ char why[512];
+ REQUIRE(mkdir("open", 0750) == 0);
+ REQUIRE(chmod("open", 0777) == 0);
+ REQUIRE(mkdir("open/inner", 0750) == 0);
+ CHECK(!logger_path_is_safe("open/app.log", why, sizeof(why)));
+ CHECK(!logger_path_is_safe("open/inner/app.log", why, sizeof(why))); /* any ancestor */
+ chmod("open", 0750);
+ CHECK(logger_path_is_safe("open/inner/app.log", why, sizeof(why)));
+}
+
+static void test_path_is_safe_rejects_symlink(void) {
+ char why[512];
+ REQUIRE(write_file("target.log", "x\n") == 0);
+ REQUIRE(symlink("target.log", "link.log") == 0);
+ CHECK(!logger_path_is_safe("link.log", why, sizeof(why)));
+ CHECK(strstr(why, "not a regular file") != NULL);
+ REQUIRE(symlink("/tmp", "tmplink") == 0);
+ CHECK(!logger_path_is_safe("tmplink/app.log", why, sizeof(why))); /* resolved dir checked */
+}
+
+static void test_file_output_and_level_filter(void) {
+ char buf[4096];
+ logger_set_level(LOG_LEVEL_INFO);
+ logger_set_file("out.log", 0);
+ log_debug("t", "hidden %d", 1);
+ log_info("t", "visible %d", 2);
+ log_warn(NULL, "warned");
+ log_error("t", "failed %s", "here");
+ logger_close();
+
+ REQUIRE(read_gz("out.log", buf, sizeof(buf)) > 0); /* gzread also reads plain files */
+ CHECK(strstr(buf, "hidden") == NULL);
+ CHECK(strstr(buf, "[INFO] [t] visible 2\n") != NULL);
+ CHECK(strstr(buf, "[WARN] [app] warned\n") != NULL);
+ CHECK(strstr(buf, "[ERROR] [t] failed here\n") != NULL);
+ CHECK(strchr(buf, '\033') == NULL); /* no colour codes in files */
+
+ struct stat st;
+ REQUIRE(stat("out.log", &st) == 0);
+ CHECK_EQ_INT(st.st_mode & 0777, 0640);
+}
+
+static void test_file_appends(void) {
+ char buf[4096];
+ REQUIRE(write_file("append.log", "previous run\n") == 0);
+ logger_set_file("append.log", 0);
+ log_info("t", "new run");
+ logger_close();
+ REQUIRE(read_gz("append.log", buf, sizeof(buf)) > 0);
+ CHECK(strncmp(buf, "previous run\n", 13) == 0);
+ CHECK(strstr(buf, "new run") != NULL);
+}
+
+static void test_unsafe_file_refused(void) {
+ REQUIRE(write_file("real.log", "") == 0);
+ REQUIRE(symlink("real.log", "evil.log") == 0);
+ logger_set_file("evil.log", 0);
+ log_info("t", "must not reach the symlink target");
+ logger_close();
+ CHECK_EQ_INT(file_size("real.log"), 0);
+
+ logger_set_file("no-such-dir/x.log", 0);
+ log_info("t", "console");
+ logger_close();
+ CHECK(!file_exists("no-such-dir"));
+}
+
+static void test_rotation_creates_gzip_archive(void) {
+ char buf[8192];
+ logger_set_file("rot.log", 1024);
+ /* ~63-byte lines: the file passes 1024 bytes after 17 lines, so exactly one rotation */
+ for (int i = 0; i < 20; i++) log_info("t", "line %02d padding padding padding", i);
+ logger_close(); /* joins the archiver thread */
+
+ CHECK(!file_exists("rot.log.rotating"));
+ CHECK(!file_exists("rot.log.new.gz"));
+ CHECK(!file_exists("rot.log.2.gz"));
+ REQUIRE(file_exists("rot.log.1.gz"));
+ REQUIRE(read_gz("rot.log.1.gz", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "line 00") != NULL);
+ CHECK(strstr(buf, "line 15") != NULL);
+ CHECK(strstr(buf, "line 19") == NULL);
+ REQUIRE(read_gz("rot.log", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "--- log rotated") != NULL);
+ CHECK(strstr(buf, "line 00") == NULL);
+ CHECK(strstr(buf, "line 19") != NULL);
+}
+
+static void test_rotation_keeps_max_archives(void) {
+ char name[64], buf[8192];
+ for (int r = 0; r < LOGGER_MAX_ARCHIVES + 3; r++) {
+ logger_set_file("keep.log", 256);
+ for (int i = 0; i < 6; i++) log_info("t", "round %02d line %d ........................", r, i);
+ logger_close();
+ }
+ for (int i = 1; i <= LOGGER_MAX_ARCHIVES; i++) {
+ snprintf(name, sizeof(name), "keep.log.%d.gz", i);
+ CHECK(file_exists(name));
+ }
+ snprintf(name, sizeof(name), "keep.log.%d.gz", LOGGER_MAX_ARCHIVES + 1);
+ CHECK(!file_exists(name));
+ CHECK(!file_exists("keep.log.rotating"));
+
+ /* newest archive is .1.gz: it holds a later round than .10.gz */
+ REQUIRE(read_gz("keep.log.1.gz", buf, sizeof(buf)) > 0);
+ char* p1 = strstr(buf, "round ");
+ REQUIRE(p1 != NULL);
+ int newest = atoi(p1 + 6);
+ snprintf(name, sizeof(name), "keep.log.%d.gz", LOGGER_MAX_ARCHIVES);
+ REQUIRE(read_gz(name, buf, sizeof(buf)) > 0);
+ char* p10 = strstr(buf, "round ");
+ REQUIRE(p10 != NULL);
+ CHECK(atoi(p10 + 6) < newest);
+}
+
+static void test_debug_level_and_reopen(void) {
+ char buf[4096];
+ logger_set_level(LOG_LEVEL_DEBUG);
+ logger_set_file("first.log", 0);
+ log_debug("t", "to first");
+ logger_set_file("second.log", 0); /* switching files closes the first one */
+ log_debug("t", "to second");
+ logger_close();
+ logger_set_level(LOG_LEVEL_INFO);
+ REQUIRE(read_gz("first.log", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "[DEBUG] [t] to first") != NULL);
+ CHECK(strstr(buf, "to second") == NULL);
+ REQUIRE(read_gz("second.log", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "[DEBUG] [t] to second") != NULL);
+}
+
+static void test_path_in_root_dir_and_too_long(void) {
+ char why[512], path[600] = "";
+ CHECK(logger_path_is_safe("/rx-unit-test-not-created.log", why, sizeof(why)));
+
+ while (strlen(path) < 520) strcat(path, "./");
+ strcat(path, "long.log");
+ CHECK(logger_path_is_safe(path, why, sizeof(why)));
+ logger_set_file(path, 0); /* safe but longer than the logger keeps: console */
+ log_info("t", "console");
+ logger_close();
+ CHECK(!file_exists("long.log"));
+}
+
+static void test_unwritable_dir_uses_console(void) {
+ if (geteuid() == 0) {
+ fprintf(stderr, " (skipped as root)\n");
+ return;
+ }
+ REQUIRE(mkdir("ro", 0550) == 0);
+ logger_set_file("ro/app.log", 0);
+ log_info("t", "console");
+ logger_close();
+ CHECK(!file_exists("ro/app.log"));
+ chmod("ro", 0750);
+}
+
+/* A leftover that cannot be compressed is kept (never overwritten) and the live
+ * log keeps growing past the limit instead of rotating. */
+static void test_failed_compression_keeps_pending(void) {
+ char buf[256];
+ if (geteuid() == 0) {
+ fprintf(stderr, " (skipped as root)\n");
+ return;
+ }
+ REQUIRE(write_file("stuck.log.rotating", "precious\n") == 0);
+ REQUIRE(chmod("stuck.log.rotating", 0) == 0);
+ logger_set_file("stuck.log", 256);
+ for (int i = 0; i < 20; i++) log_info("t", "line %02d ..............................", i);
+ logger_close();
+ CHECK(!file_exists("stuck.log.1.gz"));
+ CHECK(!file_exists("stuck.log.new.gz"));
+ CHECK(file_size("stuck.log") > 256 * 3);
+ REQUIRE(chmod("stuck.log.rotating", 0640) == 0);
+ REQUIRE(read_gz("stuck.log.rotating", buf, sizeof(buf)) > 0);
+ CHECK_EQ_STR(buf, "precious\n");
+
+ logger_set_file("stuck.log", 0); /* readable again: archived on the next start */
+ logger_close();
+ CHECK(!file_exists("stuck.log.rotating"));
+ REQUIRE(read_gz("stuck.log.1.gz", buf, sizeof(buf)) > 0);
+ CHECK_EQ_STR(buf, "precious\n");
+}
+
+/* Directory made read-only while logging: rotation (rename) and archiving (temp
+ * file) fail, the live log keeps working and nothing is lost. */
+static void test_readonly_dir_rotation_fails_safely(void) {
+ char buf[8192];
+ if (geteuid() == 0) {
+ fprintf(stderr, " (skipped as root)\n");
+ return;
+ }
+ REQUIRE(mkdir("rodir", 0750) == 0);
+ logger_set_file("rodir/app.log", 256);
+ REQUIRE(chmod("rodir", 0550) == 0);
+ for (int i = 0; i < 10; i++) log_info("t", "line %02d ..............................", i);
+ logger_close();
+ REQUIRE(read_gz("rodir/app.log", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "--- log rotation failed") != NULL);
+ CHECK(strstr(buf, "line 00") != NULL && strstr(buf, "line 09") != NULL);
+ CHECK(!file_exists("rodir/app.log.rotating"));
+
+ REQUIRE(chmod("rodir", 0750) == 0);
+ REQUIRE(write_file("rodir/app.log.rotating", "pending\n") == 0);
+ REQUIRE(chmod("rodir", 0550) == 0);
+ logger_set_file("rodir/app.log", 0); /* cannot create the temp archive */
+ logger_close();
+ CHECK(file_exists("rodir/app.log.rotating"));
+ CHECK(!file_exists("rodir/app.log.1.gz"));
+ chmod("rodir", 0750);
+}
+
+static void test_path_owned_by_other_user(void) {
+ /* system directories owned by service accounts, when present */
+ static const char* dirs[] = {"/var/cache/man", "/var/lib/colord", "/var/spool/cups",
+ "/var/lib/gdm3", "/var/log/cups"};
+ char why[512], path[PATH_MAX];
+ for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
+ struct stat st;
+ if (stat(dirs[i], &st) != 0 || !S_ISDIR(st.st_mode) || st.st_uid == 0 ||
+ st.st_uid == geteuid() || (st.st_mode & S_IWOTH))
+ continue;
+ snprintf(path, sizeof(path), "%s/app.log", dirs[i]);
+ CHECK(!logger_path_is_safe(path, why, sizeof(why)));
+ CHECK(strstr(why, "owned by another user") != NULL);
+ return;
+ }
+ fprintf(stderr, " (skipped: no directory owned by another user found)\n");
+}
+
+static void test_interrupted_rotation_recovered(void) {
+ char buf[1024];
+ REQUIRE(write_file("crash.log.rotating", "left over from a crash\n") == 0);
+ logger_set_file("crash.log", 0);
+ logger_close();
+ CHECK(!file_exists("crash.log.rotating"));
+ REQUIRE(read_gz("crash.log.1.gz", buf, sizeof(buf)) > 0);
+ CHECK(strstr(buf, "left over from a crash") != NULL);
+}
+
+int main(void) {
+ scratch_enter("logger");
+
+ RUN_TEST(test_parse_level);
+ RUN_TEST(test_set_get_level_and_env);
+ RUN_TEST(test_path_is_safe);
+ RUN_TEST(test_path_is_safe_world_writable_parent);
+ RUN_TEST(test_path_is_safe_rejects_symlink);
+ RUN_TEST(test_file_output_and_level_filter);
+ RUN_TEST(test_file_appends);
+ RUN_TEST(test_unsafe_file_refused);
+ RUN_TEST(test_rotation_creates_gzip_archive);
+ RUN_TEST(test_rotation_keeps_max_archives);
+ RUN_TEST(test_interrupted_rotation_recovered);
+ RUN_TEST(test_debug_level_and_reopen);
+ RUN_TEST(test_path_in_root_dir_and_too_long);
+ RUN_TEST(test_unwritable_dir_uses_console);
+ RUN_TEST(test_failed_compression_keeps_pending);
+ RUN_TEST(test_readonly_dir_rotation_fails_safely);
+ RUN_TEST(test_path_owned_by_other_user);
+
+ scratch_leave();
+ return test_summary();
+}
diff --git a/dvledrx/tests/test_session_manager.c b/dvledrx/tests/test_session_manager.c
new file mode 100644
index 0000000..6b87f23
--- /dev/null
+++ b/dvledrx/tests/test_session_manager.c
@@ -0,0 +1,435 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Unit tests for session_manager against the fake MTL (tests/fake_mtl.c) and a
+ * virtual X server (run under xvfb-run). Checks every frame taken from MTL is
+ * returned, sessions/windows are set up as configured and failures unwind.
+ */
+
+#include "test_common.h"
+
+#include
+#include
+#include
+
+#include "config_reader.h"
+#include "fake_mtl.h"
+#include "logger.h"
+#include "session_manager.h"
+
+static char g_display_env[64];
+static char g_output[32]; /* first XRandR output of the X server */
+
+static void sleep_ms(int ms) {
+ struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L};
+ nanosleep(&ts, NULL);
+}
+
+static void no_display(void) { unsetenv("DISPLAY"); }
+static void with_display(void) { setenv("DISPLAY", g_display_env, 1); }
+
+/* Parse json into app the way main() does (fmt yuv422p10le, transport from fmt). */
+static int make_app(struct rx_app_context* app, const char* json) {
+ memset(app, 0, sizeof(*app));
+ if (write_file("cfg.json", json) != 0 || parse_rx_config("cfg.json", &app->json_config) != 0)
+ return -1;
+ app->width = (uint32_t)app->json_config.video.width;
+ app->height = (uint32_t)app->json_config.video.height;
+ app->fps = ST_FPS_P30;
+ app->fmt = ST_FRAME_FMT_YUV422PLANAR10LE;
+ app->st20p_sessions = app->json_config.num_rx_sessions;
+ return 0;
+}
+
+/* Main-loop stand-in: pump X events for ms milliseconds. */
+static void pump(session_manager_t* m, int ms) {
+ for (int t = 0; t < ms; t += 10) {
+ if (m->composite) process_composite_x11_events(m->composite);
+ for (int i = 0; i < m->st20p_count && m->st20p_sessions; i++) {
+ process_x11_events(&m->st20p_sessions[i].display);
+ for (int k = 0; k < m->st20p_sessions[i].num_mirror_displays; k++)
+ process_x11_events(&m->st20p_sessions[i].mirror_display[k]);
+ }
+ sleep_ms(10);
+ }
+}
+
+/* Every frame handed out by MTL must come back exactly once, nothing leaked. */
+static void check_frames_balanced(void) {
+ CHECK_EQ_INT(fake_mtl.frames_put, fake_mtl.frames_got);
+ CHECK_EQ_INT(fake_mtl.bad_puts, 0);
+ CHECK_EQ_INT(fake_mtl.leaked, 0);
+}
+
+/* init + start, run for ms, then stop/cleanup as main() does. */
+static int run_app(struct rx_app_context* app, int ms, session_manager_t* seen) {
+ session_manager_t m;
+ if (session_manager_init(&m, app) != 0) return -1;
+ int rc = session_manager_start(&m, app);
+ if (rc == 0) pump(&m, ms);
+ if (seen) *seen = m;
+ app->exit = true;
+ session_manager_cleanup(&m);
+ CHECK(!session_manager_is_running(&m));
+ return rc;
+}
+
+#define IFACE "\"interfaces\": [ { \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.168.85.20\" } ]"
+
+/* ------------------------------------------------------------------ tests */
+
+static void test_null_args(void) {
+ struct rx_app_context app;
+ session_manager_t m;
+ memset(&app, 0, sizeof(app));
+ CHECK_EQ_INT(session_manager_init(NULL, &app), -1);
+ CHECK_EQ_INT(session_manager_init(&m, NULL), -1);
+ CHECK_EQ_INT(session_manager_start(NULL, &app), -1);
+ CHECK_EQ_INT(session_manager_start(&m, NULL), -1);
+ session_manager_stop(NULL);
+ session_manager_cleanup(NULL);
+ CHECK(!session_manager_is_running(NULL));
+}
+
+static void test_init_failure(void) {
+ struct rx_app_context app;
+ session_manager_t m;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0);
+ fake_mtl_reset();
+ fake_mtl.fail_init = true;
+ CHECK_EQ_INT(session_manager_init(&m, &app), -1);
+ CHECK(m.mtl == NULL);
+ session_manager_cleanup(&m);
+ CHECK_EQ_INT(fake_mtl.uninit_calls, 0);
+}
+
+static void test_init_params(void) {
+ struct rx_app_context app;
+ session_manager_t m;
+ REQUIRE(make_app(&app,
+ "{ \"tasklet_sleep\": true, \"interfaces\": [\n"
+ " { \"nic_index\": 0, \"name\": \"0000:03:00.0\", \"sip\": \"192.168.50.65\", \"dip\": \"239.1.1.1\" },\n"
+ " { \"nic_index\": 1, \"name\": \"0000:03:00.1\", \"sip\": \"192.168.50.66\", \"dip\": \"239.1.1.2\" } ],\n"
+ " \"video\": { \"width\": 96, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"nic_index\": 0, \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n"
+ " { \"nic_index\": 0, \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } },\n"
+ " { \"nic_index\": 1, \"udp_port\": 20004, \"crop\": { \"x\": 64, \"w\": 32, \"h\": 16 } } ] }") == 0);
+ fake_mtl_reset();
+ REQUIRE(session_manager_init(&m, &app) == 0);
+ const struct mtl_init_params* p = &fake_mtl.last_init;
+ CHECK_EQ_INT(p->num_ports, 2);
+ CHECK_EQ_STR(p->port[0], "0000:03:00.0");
+ CHECK_EQ_STR(p->port[1], "0000:03:00.1");
+ CHECK(p->sip_addr[1][0] == 192 && p->sip_addr[1][3] == 66);
+ CHECK_EQ_INT(p->rx_queues_cnt[0], 2 + 2); /* sessions on the port + 2 housekeeping */
+ CHECK_EQ_INT(p->rx_queues_cnt[1], 1 + 2);
+ CHECK(p->flags & MTL_FLAG_TASKLET_SLEEP);
+ CHECK(p->flags & MTL_FLAG_RX_VIDEO_MIGRATE);
+ CHECK(!(p->flags & MTL_FLAG_RX_USE_CNI)); /* no VF */
+ CHECK(app.mtl == m.mtl && m.mtl != NULL);
+ CHECK_EQ_INT(m.st20p_count, 3);
+ session_manager_cleanup(&m);
+ CHECK_EQ_INT(fake_mtl.uninit_calls, 1);
+}
+
+/* Odd NIC names go through the PCI address check without touching sysfs paths. */
+static void test_unusual_nic_names(void) {
+ const char* names[] = {"03:00.0", "0000:03:00.8", "../../etc", "0000:3:00.0", "0000:03:00.0x", ""};
+ for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
+ char json[512];
+ struct rx_app_context app;
+ session_manager_t m;
+ snprintf(json, sizeof(json),
+ "{ \"interfaces\": [ { \"name\": \"%s\", \"sip\": \"10.0.0.1\", \"dip\": \"239.1.1.1\" } ],\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }", names[i]);
+ REQUIRE(make_app(&app, json) == 0);
+ fake_mtl_reset();
+ CHECK_EQ_INT(session_manager_init(&m, &app), 0);
+ CHECK(!(fake_mtl.last_init.flags & MTL_FLAG_RX_USE_CNI));
+ session_manager_cleanup(&m);
+ }
+}
+
+static void test_single_session_without_display(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 32, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20010, \"payload_type\": 112,\n"
+ " \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0);
+ no_display();
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 120; /* > 50 for the progress log */
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 300, &seen), 0);
+ with_display();
+ CHECK(seen.composite == NULL);
+ CHECK_EQ_INT(fake_mtl.frames_got, 120);
+ check_frames_balanced();
+ CHECK_EQ_INT(fake_mtl.create_calls, 1);
+ CHECK_EQ_INT(fake_mtl.free_calls, 1);
+ CHECK_EQ_INT(fake_mtl.wake_calls, 1);
+ const struct st20p_rx_ops* ops = &fake_mtl.ops[0];
+ CHECK_EQ_STR(ops->name, "st20p_rx_0");
+ CHECK_EQ_INT(ops->port.udp_port[MTL_SESSION_PORT_P], 20010);
+ CHECK_EQ_INT(ops->port.payload_type, 112);
+ CHECK(ops->port.ip_addr[MTL_SESSION_PORT_P][0] == 239 &&
+ ops->port.ip_addr[MTL_SESSION_PORT_P][3] == 20);
+ CHECK_EQ_STR(ops->port.port[MTL_SESSION_PORT_P], "0000:03:00.0");
+ CHECK_EQ_INT(ops->width, 32);
+ CHECK_EQ_INT(ops->height, 16);
+ CHECK_EQ_INT(ops->transport_fmt, ST20_FMT_YUV_422_10BIT);
+ CHECK_EQ_INT(ops->output_fmt, ST_FRAME_FMT_YUV422PLANAR10LE);
+ CHECK(ops->flags & ST20P_RX_FLAG_BLOCK_GET);
+}
+
+/* Frames handed to the display thread; runs past 1 s for the fps report. */
+static void test_single_session_window(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 48, \"height\": 32 },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 48, \"h\": 32 } } ] }") == 0);
+ fake_mtl_reset();
+ fake_mtl.empty_every = 7; /* frames without data are returned by the RX thread */
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 1200, &seen), 0);
+ CHECK(fake_mtl.frames_got > 10);
+ check_frames_balanced();
+}
+
+/* No crop: the session uses the full video size. */
+static void test_session_size_from_video(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 40, \"height\": 20 },\n"
+ " \"rx_sessions\": [ { } ] }") == 0);
+ app.width = 40;
+ app.height = 20;
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 5;
+ CHECK_EQ_INT(run_app(&app, 100, NULL), 0);
+ CHECK_EQ_INT(fake_mtl.ops[0].width, 40);
+ CHECK_EQ_INT(fake_mtl.ops[0].height, 20);
+ check_frames_balanced();
+}
+
+static void test_transport_format_mapping(void) {
+ const struct { enum st_frame_fmt f; enum st20_fmt t; } map[] = {
+ {ST_FRAME_FMT_YUV422PLANAR10LE, ST20_FMT_YUV_422_10BIT},
+ {ST_FRAME_FMT_YUV422PLANAR12LE, ST20_FMT_YUV_422_12BIT},
+ {ST_FRAME_FMT_YUV444PLANAR10LE, ST20_FMT_YUV_444_10BIT},
+ {ST_FRAME_FMT_YUV444PLANAR12LE, ST20_FMT_YUV_444_12BIT},
+ {ST_FRAME_FMT_GBRPLANAR10LE, ST20_FMT_RGB_10BIT},
+ {ST_FRAME_FMT_GBRPLANAR12LE, ST20_FMT_RGB_12BIT},
+ {ST_FRAME_FMT_YUV420PLANAR8, ST20_FMT_YUV_420_8BIT},
+ {ST_FRAME_FMT_YUV420CUSTOM8, ST20_FMT_YUV_420_8BIT},
+ {ST_FRAME_FMT_BGRA, ST20_FMT_YUV_422_10BIT}, /* unknown: default */
+ };
+ no_display();
+ for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); i++) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0);
+ app.fmt = map[i].f;
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 0;
+ CHECK_EQ_INT(run_app(&app, 0, NULL), 0);
+ CHECK_EQ_INT(fake_mtl.ops[0].transport_fmt, map[i].t);
+ CHECK_EQ_INT(fake_mtl.ops[0].output_fmt, map[i].f);
+ }
+
+ /* An explicit transport format wins over the derived one. */
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0);
+ app.transport_fmt = ST20_FMT_RGB_8BIT;
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 0;
+ CHECK_EQ_INT(run_app(&app, 0, NULL), 0);
+ CHECK_EQ_INT(fake_mtl.ops[0].transport_fmt, ST20_FMT_RGB_8BIT);
+ with_display();
+}
+
+/* Two sessions without display_mode: one composite window sized to the crops.
+ * Frames keep coming until stop, so a thread may be waiting at the barrier then. */
+static void test_composite_crops(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0);
+ fake_mtl_reset();
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 400, &seen), 0);
+ CHECK(seen.composite != NULL);
+ CHECK(fake_mtl.frames_got > 2);
+ check_frames_balanced();
+}
+
+/* Composite: session 1 fails to create while session 0 already waits at the barrier for it. */
+static void test_composite_create_failure_unwinds(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0);
+ fake_mtl_reset();
+ fake_mtl.fail_create_at = 1;
+ session_manager_t m;
+ REQUIRE(session_manager_init(&m, &app) == 0);
+ CHECK_EQ_INT(session_manager_start(&m, &app), -1);
+ sleep_ms(100); /* let session 0 reach the barrier */
+ app.exit = true;
+ session_manager_cleanup(&m);
+ CHECK_EQ_INT(fake_mtl.free_calls, 1);
+ check_frames_balanced();
+}
+
+/* Composite window cannot open: sessions still receive. */
+static void test_composite_without_display(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0);
+ no_display();
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 10;
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 200, &seen), 0);
+ with_display();
+ CHECK(seen.composite == NULL);
+ CHECK_EQ_INT(fake_mtl.frames_got, 20);
+ check_frames_balanced();
+}
+
+/* per_output: fixed windows, a same-size mirror (copied) and a different-size one (converted). */
+static void test_per_output_windows_and_mirrors(void) {
+ char json[2048];
+ snprintf(json, sizeof(json),
+ "{ " IFACE ", \"video\": { \"width\": 64, \"height\": 32 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 32 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 32 } } ],\n"
+ " \"displays\": [\n"
+ " { \"output\": \"%s\", \"sessions\": [\n"
+ " { \"session\": 0, \"x\": 0, \"y\": 0, \"scale_w\": 16, \"scale_h\": 16 },\n"
+ " { \"session\": 1, \"x\": 16, \"y\": 0 },\n"
+ " { \"session\": 0, \"x\": 0, \"y\": 40, \"scale_w\": 16, \"scale_h\": 16 },\n"
+ " { \"session\": 1, \"x\": 40, \"y\": 40, \"scale_w\": 8, \"scale_h\": 8 } ] },\n"
+ " { \"output\": \"NO-SUCH-OUTPUT\", \"sessions\": [ { \"session\": 1 } ] } ] }",
+ g_output);
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, json) == 0);
+ fake_mtl_reset();
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 1100, &seen), 0);
+ CHECK(seen.composite == NULL);
+ CHECK(fake_mtl.frames_got > 4);
+ check_frames_balanced();
+}
+
+/* per_output by index without a window/scale: fullscreen on the session's monitor. */
+static void test_per_output_fullscreen_by_index(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"display_mode\": \"per_output\", \"video\": { \"width\": 32, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ] }") == 0);
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 8;
+ CHECK_EQ_INT(run_app(&app, 300, NULL), 0);
+ CHECK_EQ_INT(fake_mtl.frames_got, 8);
+ check_frames_balanced();
+}
+
+/* Output not connected: no window, frames are still received and returned. */
+static void test_per_output_missing_output(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"video\": { \"width\": 32, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"crop\": { \"w\": 32, \"h\": 16 } } ],\n"
+ " \"displays\": [ { \"output\": \"NO-SUCH-OUTPUT\", \"sessions\": [ { \"session\": 0 } ] } ] }") == 0);
+ fake_mtl_reset();
+ fake_mtl.frames_per_session = 6;
+ session_manager_t seen;
+ CHECK_EQ_INT(run_app(&app, 200, &seen), 0);
+ CHECK_EQ_INT(fake_mtl.frames_got, 6);
+ check_frames_balanced();
+}
+
+/* Second session fails to create: start fails, the first session is still stopped and freed. */
+static void test_create_failure_unwinds(void) {
+ struct rx_app_context app;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"display_mode\": \"per_output\", \"video\": { \"width\": 64, \"height\": 16 },\n"
+ " \"rx_sessions\": [ { \"udp_port\": 20000, \"crop\": { \"w\": 32, \"h\": 16 } },\n"
+ " { \"udp_port\": 20002, \"crop\": { \"x\": 32, \"w\": 32, \"h\": 16 } } ] }") == 0);
+ no_display();
+ fake_mtl_reset();
+ fake_mtl.fail_create_at = 1;
+ CHECK_EQ_INT(run_app(&app, 0, NULL), -1);
+ with_display();
+ CHECK_EQ_INT(fake_mtl.create_calls, 2);
+ CHECK_EQ_INT(fake_mtl.free_calls, 1);
+ CHECK_EQ_INT(fake_mtl.uninit_calls, 1);
+ check_frames_balanced();
+}
+
+static void test_stop_twice(void) {
+ struct rx_app_context app;
+ session_manager_t m;
+ REQUIRE(make_app(&app, "{ " IFACE ", \"rx_sessions\": [ { \"crop\": { \"w\": 16, \"h\": 8 } } ] }") == 0);
+ no_display();
+ fake_mtl_reset();
+ REQUIRE(session_manager_init(&m, &app) == 0);
+ REQUIRE(session_manager_start(&m, &app) == 0);
+ CHECK(session_manager_is_running(&m));
+ app.exit = true;
+ session_manager_stop(&m);
+ session_manager_stop(&m);
+ CHECK_EQ_INT(fake_mtl.free_calls, 1);
+ session_manager_cleanup(&m);
+ session_manager_cleanup(&m);
+ CHECK_EQ_INT(fake_mtl.uninit_calls, 1);
+ with_display();
+ check_frames_balanced();
+}
+
+/* Name of the X server's first output (Xvfb calls it "screen"). */
+static int find_first_output(void) {
+ Display* dpy = XOpenDisplay(NULL);
+ if (!dpy) return -1;
+ int n = 0;
+ XRRMonitorInfo* mons = XRRGetMonitors(dpy, DefaultRootWindow(dpy), True, &n);
+ XRRScreenResources* res = XRRGetScreenResourcesCurrent(dpy, DefaultRootWindow(dpy));
+ if (n > 0 && mons[0].noutput > 0 && res) {
+ XRROutputInfo* info = XRRGetOutputInfo(dpy, res, mons[0].outputs[0]);
+ if (info) {
+ snprintf(g_output, sizeof(g_output), "%s", info->name);
+ XRRFreeOutputInfo(info);
+ }
+ }
+ if (res) XRRFreeScreenResources(res);
+ if (mons) XRRFreeMonitors(mons);
+ XCloseDisplay(dpy);
+ return g_output[0] ? 0 : -1;
+}
+
+int main(void) {
+ XInitThreads();
+ const char* d = getenv("DISPLAY");
+ if (!d || find_first_output() != 0) {
+ fprintf(stderr, "needs an X server (run under xvfb-run)\n");
+ return 77; /* meson: skipped */
+ }
+ snprintf(g_display_env, sizeof(g_display_env), "%s", d);
+ logger_set_level(LOG_LEVEL_ERROR + 1);
+ scratch_enter("session");
+
+ RUN_TEST(test_null_args);
+ RUN_TEST(test_init_failure);
+ RUN_TEST(test_init_params);
+ RUN_TEST(test_unusual_nic_names);
+ RUN_TEST(test_single_session_without_display);
+ RUN_TEST(test_single_session_window);
+ RUN_TEST(test_session_size_from_video);
+ RUN_TEST(test_transport_format_mapping);
+ RUN_TEST(test_composite_crops);
+ RUN_TEST(test_composite_create_failure_unwinds);
+ RUN_TEST(test_composite_without_display);
+ RUN_TEST(test_per_output_windows_and_mirrors);
+ RUN_TEST(test_per_output_fullscreen_by_index);
+ RUN_TEST(test_per_output_missing_output);
+ RUN_TEST(test_create_failure_unwinds);
+ RUN_TEST(test_stop_twice);
+
+ scratch_leave();
+ return test_summary();
+}
diff --git a/dvledrx/tests/test_x11.c b/dvledrx/tests/test_x11.c
new file mode 100644
index 0000000..91b61e1
--- /dev/null
+++ b/dvledrx/tests/test_x11.c
@@ -0,0 +1,480 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright(c) 2026 Intel Corporation
+ *
+ * Unit tests for x11_handler on a virtual X server (run under xvfb-run):
+ * windows on outputs, frame conversion into the window image, mirrors,
+ * window events (expose, resize, close, Escape) and the composite window.
+ */
+
+#include "test_common.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "config_reader.h"
+#include "logger.h"
+#include "st_pipeline_api.h"
+#include "x11_handler.h"
+
+static Display* g_dpy; /* the test's own connection, used to send events */
+static char g_display_env[64];
+static char g_output[32];
+
+static void sleep_ms(int ms) {
+ struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L};
+ nanosleep(&ts, NULL);
+}
+
+static Window win_of(const struct x11_display_ctx* d) { return (Window)(uintptr_t)d->window; }
+
+/* Let the server deliver what we sent, then run the handler's event pump. */
+static void pump(struct x11_display_ctx* d) {
+ XSync(g_dpy, False);
+ for (int i = 0; i < 5; i++) {
+ sleep_ms(10);
+ process_x11_events(d);
+ }
+}
+
+static void send_event(Window w, long mask, XEvent* ev) {
+ XSendEvent(g_dpy, w, False, mask, ev);
+ XSync(g_dpy, False);
+}
+
+static void send_key(Window w, bool press) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xkey.type = press ? KeyPress : KeyRelease;
+ ev.xkey.display = g_dpy;
+ ev.xkey.window = w;
+ ev.xkey.root = DefaultRootWindow(g_dpy);
+ ev.xkey.keycode = XKeysymToKeycode(g_dpy, XK_Escape);
+ ev.xkey.same_screen = True;
+ send_event(w, press ? KeyPressMask : KeyReleaseMask, &ev);
+}
+
+static void send_expose(Window w) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xexpose.type = Expose;
+ ev.xexpose.window = w;
+ ev.xexpose.width = 1;
+ ev.xexpose.height = 1;
+ send_event(w, ExposureMask, &ev);
+}
+
+static void send_close(Window w) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xclient.type = ClientMessage;
+ ev.xclient.window = w;
+ ev.xclient.message_type = XInternAtom(g_dpy, "WM_PROTOCOLS", False);
+ ev.xclient.format = 32;
+ ev.xclient.data.l[0] = (long)XInternAtom(g_dpy, "WM_DELETE_WINDOW", False);
+ send_event(w, NoEventMask, &ev);
+}
+
+static void send_reparent_to_root(Window w) {
+ XEvent ev;
+ memset(&ev, 0, sizeof(ev));
+ ev.xreparent.type = ReparentNotify;
+ ev.xreparent.event = w;
+ ev.xreparent.window = w;
+ ev.xreparent.parent = DefaultRootWindow(g_dpy);
+ send_event(w, StructureNotifyMask, &ev);
+}
+
+/* 16-bit planar test frame, every sample = value (chroma neutral for YUV). */
+static void frame_alloc(struct st_frame* f, enum st_frame_fmt fmt, uint32_t w, uint32_t h,
+ uint16_t value) {
+ memset(f, 0, sizeof(*f));
+ f->fmt = fmt;
+ f->width = w;
+ f->height = h;
+ bool sub = fmt == ST_FRAME_FMT_YUV422PLANAR10LE || fmt == ST_FRAME_FMT_YUV422PLANAR12LE;
+ bool gbr = fmt == ST_FRAME_FMT_GBRPLANAR10LE || fmt == ST_FRAME_FMT_GBRPLANAR12LE;
+ bool b12 = fmt == ST_FRAME_FMT_YUV422PLANAR12LE || fmt == ST_FRAME_FMT_YUV444PLANAR12LE ||
+ fmt == ST_FRAME_FMT_GBRPLANAR12LE;
+ uint16_t neutral = (uint16_t)(128 << (b12 ? 4 : 2));
+ for (int p = 0; p < 3; p++) {
+ uint32_t pw = (p > 0 && sub) ? w / 2 : w;
+ uint16_t* plane = malloc((size_t)pw * h * 2);
+ for (size_t i = 0; i < (size_t)pw * h; i++) plane[i] = (p > 0 && !gbr) ? neutral : value;
+ f->addr[p] = plane;
+ f->linesize[p] = (size_t)pw * 2;
+ f->data_size += (size_t)pw * h * 2;
+ }
+}
+
+static void frame_free(struct st_frame* f) {
+ for (int p = 0; p < ST_MAX_PLANES; p++) free(f->addr[p]);
+ memset(f, 0, sizeof(*f));
+}
+
+static const uint8_t* px(const struct x11_display_ctx* d, uint32_t x, uint32_t y) {
+ return (const uint8_t*)d->image_data + ((size_t)y * d->width + x) * 4;
+}
+
+/* ------------------------------------------------------------------ tests */
+
+static void test_init_without_x_server(void) {
+ struct x11_display_ctx d;
+ unsetenv("DISPLAY");
+ CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, NULL, false, NULL), -1);
+ struct composite_x11_ctx c;
+ CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 1), -1);
+ setenv("DISPLAY", ":250", 1); /* nothing listens there */
+ CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, NULL, false, NULL), -1);
+ CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 1), -1);
+ setenv("DISPLAY", g_display_env, 1);
+ CHECK_EQ_INT(init_composite_x11_display(NULL, 16, 16, 1), -1);
+ CHECK_EQ_INT(init_composite_x11_display(&c, 16, 16, 0), -1);
+}
+
+static void test_null_and_uninitialised_are_ignored(void) {
+ struct x11_display_ctx d;
+ memset(&d, 0, sizeof(d));
+ cleanup_x11_display(NULL);
+ cleanup_x11_display(&d);
+ display_frame_x11(NULL, NULL);
+ display_frame_x11(&d, NULL);
+ process_x11_events(NULL);
+ process_x11_events(&d);
+ CHECK(!display_copy_x11(NULL, &d));
+ CHECK(!display_copy_x11(&d, &d));
+ struct composite_x11_ctx c;
+ memset(&c, 0, sizeof(c));
+ cleanup_composite_x11_display(NULL);
+ cleanup_composite_x11_display(&c);
+ process_composite_x11_events(NULL);
+ process_composite_x11_events(&c);
+ composite_write_strip(&c, NULL, 0);
+ composite_write_crop(&c, NULL, NULL);
+}
+
+/* Ordinary window: frame of the same size converts straight into the image. */
+static void test_normal_window_direct_frame(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 8, 4, 0, NULL, false, NULL) == 0);
+ CHECK(d.initialized);
+ CHECK_EQ_STR(d.output_name, "(window)");
+ CHECK_EQ_INT(d.wall_kind, X11_WALL_NONE);
+ CHECK_EQ_INT(d.width, 8);
+ CHECK_EQ_INT(d.height, 4);
+
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 8, 4, 235 << 2); /* white */
+ for (int i = 0; i < 6; i++) display_frame_x11(&d, &f); /* > 5: past the diagnostics */
+ const uint8_t* p = px(&d, 7, 3);
+ CHECK(p[0] == 255 && p[1] == 255 && p[2] == 255);
+ frame_free(&f);
+
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR12LE, 8, 4, 16 << 4); /* black, 12-bit */
+ display_frame_x11(&d, &f);
+ CHECK(px(&d, 0, 0)[0] == 0 && px(&d, 0, 0)[1] == 0 && px(&d, 0, 0)[2] == 0);
+ frame_free(&f);
+
+ frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR12LE, 8, 4, 235 << 4); /* white, 12-bit */
+ display_frame_x11(&d, &f);
+ CHECK(px(&d, 3, 2)[0] == 255 && px(&d, 3, 2)[1] == 255 && px(&d, 3, 2)[2] == 255);
+ frame_free(&f);
+
+ send_expose(win_of(&d));
+ pump(&d);
+ cleanup_x11_display(&d);
+ CHECK(!d.initialized);
+}
+
+/* Frame size != window size: scaled conversion, BGRA scaler fallback, placeholder. */
+static void test_frame_scaling_and_fallbacks(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 8, 8, 1, NULL, false, NULL) == 0);
+
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 0);
+ ((uint16_t*)f.addr[2])[0] = 200 << 2; /* R of source pixel (0,0) */
+ display_frame_x11(&d, &f); /* 4x4 -> 8x8 nearest */
+ CHECK(px(&d, 1, 1)[2] == 200 && px(&d, 0, 0)[2] == 200 && px(&d, 2, 0)[2] == 0);
+ frame_free(&f);
+
+ uint8_t bgra[4 * 2 * 4];
+ for (size_t i = 0; i < sizeof(bgra); i++) bgra[i] = (uint8_t)(i * 9);
+ struct st_frame b = {0};
+ b.fmt = ST_FRAME_FMT_BGRA; /* not handled by the fused scaler */
+ b.width = 4;
+ b.height = 2;
+ b.addr[0] = bgra;
+ b.data_size = sizeof(bgra);
+ display_frame_x11(&d, &b);
+ CHECK(memcmp(px(&d, 7, 7), bgra + (1 * 4 + 3) * 4, 4) == 0);
+
+ frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR10LE, 8, 8, 0);
+ f.width = 0; /* no size: last size is kept, conversion fails, animated placeholder */
+ f.height = 0;
+ display_frame_x11(&d, &f);
+ frame_free(&f);
+ display_frame_x11(&d, NULL); /* waiting-for-video placeholder */
+ cleanup_x11_display(&d);
+}
+
+static void test_mirror_copy(void) {
+ struct x11_display_ctx a, b, c;
+ REQUIRE(init_x11_display(&a, 6, 4, 0, NULL, false, NULL) == 0);
+ REQUIRE(init_x11_display(&b, 6, 4, 1, NULL, false, NULL) == 0);
+ REQUIRE(init_x11_display(&c, 3, 4, 2, NULL, false, NULL) == 0);
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_YUV444PLANAR10LE, 6, 4, 235 << 2);
+ display_frame_x11(&a, &f);
+ CHECK(display_copy_x11(&b, &a));
+ CHECK(memcmp(a.image_data, b.image_data, 6 * 4 * 4) == 0);
+ CHECK(!display_copy_x11(&c, &a)); /* different size */
+ frame_free(&f);
+ cleanup_x11_display(&c);
+ cleanup_x11_display(&b);
+ cleanup_x11_display(&a);
+}
+
+/* The window manager (here: us) resizes the window: the image follows. */
+static void test_resize_and_non_shm_buffers(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 8, 4, 0, NULL, false, NULL) == 0);
+ d.use_shm = false; /* next buffer uses plain XPutImage */
+ XResizeWindow(g_dpy, win_of(&d), 12, 6);
+ pump(&d);
+ CHECK_EQ_INT(d.width, 12);
+ CHECK_EQ_INT(d.height, 6);
+ CHECK(d.shminfo == NULL);
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 12, 6, 235 << 2);
+ display_frame_x11(&d, &f);
+ CHECK(px(&d, 11, 5)[1] == 255);
+ frame_free(&f);
+ XResizeWindow(g_dpy, win_of(&d), 12, 6); /* same size: nothing to do */
+ pump(&d);
+ CHECK_EQ_INT(d.width, 12);
+ cleanup_x11_display(&d);
+}
+
+/* Fullscreen on an output (by name and by index) and Escape toggling. */
+static void test_fullscreen_output_and_escape(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 16, 16, 0, g_output, true, NULL) == 0);
+ CHECK_EQ_STR(d.output_name, g_output);
+ CHECK_EQ_INT(d.wall_kind, X11_WALL_FULLSCREEN);
+ CHECK(d.width == (uint32_t)d.mon_w && d.height == (uint32_t)d.mon_h);
+
+ send_key(win_of(&d), true); /* Escape: normal window */
+ pump(&d);
+ CHECK(d.windowed);
+ send_key(win_of(&d), true); /* held key repeating: ignored */
+ pump(&d);
+ CHECK(d.windowed);
+ send_key(win_of(&d), false);
+ send_key(win_of(&d), true); /* fresh press: back to wall mode */
+ send_key(win_of(&d), false);
+ pump(&d);
+ CHECK(!d.windowed);
+ cleanup_x11_display(&d);
+
+ struct x11_display_ctx by_index;
+ REQUIRE(init_x11_display(&by_index, 16, 16, 0, "", true, NULL) == 0);
+ CHECK_EQ_STR(by_index.output_name, g_output);
+ cleanup_x11_display(&by_index);
+}
+
+static void test_missing_output(void) {
+ struct x11_display_ctx d;
+ CHECK_EQ_INT(init_x11_display(&d, 16, 16, 0, "NO-SUCH-OUTPUT", true, NULL), -1);
+ CHECK_EQ_INT(init_x11_display(&d, 16, 16, 7, NULL, true, NULL), -1); /* no 8th monitor */
+ CHECK(!d.initialized);
+}
+
+/* Fixed window on an output; Escape leaves and re-enters wall mode, including
+ * the WM-release (ReparentNotify) and timeout paths. */
+static void test_fixed_window_and_escape(void) {
+ struct crop_config rect = {10, 20, 32, 16};
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 64, 64, 0, g_output, true, &rect) == 0);
+ CHECK_EQ_INT(d.wall_kind, X11_WALL_FIXED);
+ CHECK_EQ_INT(d.width, 32);
+ CHECK_EQ_INT(d.height, 16);
+ CHECK_EQ_INT(d.out_x, 10);
+ CHECK_EQ_INT(d.out_y, 20);
+
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_YUV422PLANAR10LE, 32, 16, 235 << 2);
+ display_frame_x11(&d, &f);
+ if (d.depth == 32) CHECK(px(&d, 0, 0)[3] == 255); /* ARGB window stays opaque */
+ frame_free(&f);
+
+ send_key(win_of(&d), true);
+ send_key(win_of(&d), false);
+ pump(&d);
+ CHECK(d.windowed);
+
+ send_key(win_of(&d), true); /* back: waits for the WM to release the window */
+ send_key(win_of(&d), false);
+ pump(&d);
+ CHECK(d.wall_pending);
+ send_key(win_of(&d), true); /* toggle while pending: ignored */
+ send_key(win_of(&d), false);
+ pump(&d);
+ CHECK(d.wall_pending);
+ send_reparent_to_root(win_of(&d));
+ pump(&d);
+ CHECK(!d.wall_pending);
+ CHECK(!d.windowed);
+
+ send_key(win_of(&d), true); /* windowed again, then back via the 1 s timeout */
+ send_key(win_of(&d), false);
+ pump(&d);
+ send_key(win_of(&d), true);
+ send_key(win_of(&d), false);
+ pump(&d);
+ CHECK(d.wall_pending);
+ sleep_ms(1100);
+ process_x11_events(&d);
+ CHECK(!d.wall_pending);
+ cleanup_x11_display(&d);
+
+ struct crop_config big = {0, 0, 100000, 16}; /* larger than the output: warning only */
+ REQUIRE(init_x11_display(&d, 16, 16, 0, g_output, true, &big) == 0);
+ cleanup_x11_display(&d);
+}
+
+/* Composite window: crop and strip writes, expose, resize to scaled and back. */
+static void test_composite_window(void) {
+ struct composite_x11_ctx c;
+ REQUIRE(init_composite_x11_display(&c, 8, 4, 1) == 0);
+ CHECK(c.initialized);
+
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 255 << 2); /* white */
+ struct crop_config crop = {6, 2, 4, 4}; /* partly outside: clamped */
+ composite_write_crop(&c, &f, &crop);
+ const uint8_t* img = (const uint8_t*)c.image_data;
+ CHECK(img[(3 * 8 + 7) * 4] == 255); /* (7,3) written */
+ CHECK(img[(1 * 8 + 7) * 4] == 0); /* (7,1) above the crop untouched */
+ frame_free(&f);
+
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 8, 4, 128 << 2);
+ composite_write_strip(&c, &f, 0);
+ CHECK(img[0] == 128);
+ frame_free(&f);
+
+ Window w = (Window)(uintptr_t)c.window;
+ send_expose(w);
+ XResizeWindow(g_dpy, w, 16, 8);
+ XSync(g_dpy, False);
+ sleep_ms(30);
+ process_composite_x11_events(&c);
+ CHECK_EQ_INT(c.win_width, 16);
+ CHECK(c.scaled_ximage != NULL);
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 8, 4, 64 << 2);
+ composite_write_strip(&c, &f, 0); /* flush through the scaled image */
+ CHECK(((const uint8_t*)c.scaled_image_data)[(7 * 16 + 15) * 4] == 64);
+ frame_free(&f);
+
+ XResizeWindow(g_dpy, w, 8, 4); /* native size again: scaled image dropped */
+ XSync(g_dpy, False);
+ sleep_ms(30);
+ process_composite_x11_events(&c);
+ CHECK(c.scaled_ximage == NULL);
+ cleanup_composite_x11_display(&c);
+ CHECK(!c.initialized);
+}
+
+/* A session alone at a 2-session barrier would wait forever; abort releases it. */
+static void test_composite_abort_releases_waiters(void) {
+ composite_abort(NULL);
+ struct composite_x11_ctx c;
+ REQUIRE(init_composite_x11_display(&c, 4, 4, 2) == 0);
+ composite_abort(&c);
+ struct st_frame f;
+ frame_alloc(&f, ST_FRAME_FMT_GBRPLANAR10LE, 4, 4, 255 << 2);
+ struct crop_config crop = {0, 0, 4, 4};
+ composite_write_crop(&c, &f, &crop); /* returns instead of blocking */
+ composite_write_strip(&c, &f, 0);
+ CHECK(((const uint8_t*)c.image_data)[0] == 255);
+ frame_free(&f);
+ cleanup_composite_x11_display(&c);
+}
+
+/* X protocol errors are logged, not fatal. */
+static void test_x_error_is_logged(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 4, 4, 0, NULL, false, NULL) == 0); /* installs the handler */
+ XMapWindow(g_dpy, (Window)0x1fffff0); /* BadWindow */
+ XSync(g_dpy, False);
+ cleanup_x11_display(&d);
+}
+
+/* Close button on any window (session or composite) asks the app to stop. */
+static void test_close_button(void) {
+ struct x11_display_ctx d;
+ REQUIRE(init_x11_display(&d, 4, 4, 0, NULL, false, NULL) == 0);
+ CHECK(!x11_close_requested());
+ send_close(win_of(&d));
+ pump(&d);
+ CHECK(x11_close_requested());
+ cleanup_x11_display(&d);
+
+ struct composite_x11_ctx c;
+ REQUIRE(init_composite_x11_display(&c, 4, 4, 1) == 0);
+ send_close((Window)(uintptr_t)c.window);
+ sleep_ms(20);
+ process_composite_x11_events(&c);
+ CHECK(x11_close_requested());
+ cleanup_composite_x11_display(&c);
+}
+
+static int find_first_output(void) {
+ int n = 0;
+ Window root = DefaultRootWindow(g_dpy);
+ XRRMonitorInfo* mons = XRRGetMonitors(g_dpy, root, True, &n);
+ XRRScreenResources* res = XRRGetScreenResourcesCurrent(g_dpy, root);
+ if (n > 0 && mons[0].noutput > 0 && res) {
+ XRROutputInfo* info = XRRGetOutputInfo(g_dpy, res, mons[0].outputs[0]);
+ if (info) {
+ snprintf(g_output, sizeof(g_output), "%s", info->name);
+ XRRFreeOutputInfo(info);
+ }
+ }
+ if (res) XRRFreeScreenResources(res);
+ if (mons) XRRFreeMonitors(mons);
+ return g_output[0] ? 0 : -1;
+}
+
+int main(void) {
+ XInitThreads();
+ const char* env = getenv("DISPLAY");
+ if (!env || !(g_dpy = XOpenDisplay(NULL)) || find_first_output() != 0) {
+ fprintf(stderr, "needs an X server (run under xvfb-run)\n");
+ return 77; /* meson: skipped */
+ }
+ snprintf(g_display_env, sizeof(g_display_env), "%s", env);
+ logger_set_level(LOG_LEVEL_ERROR + 1);
+ scratch_enter("x11");
+
+ RUN_TEST(test_init_without_x_server);
+ RUN_TEST(test_null_and_uninitialised_are_ignored);
+ RUN_TEST(test_normal_window_direct_frame);
+ RUN_TEST(test_frame_scaling_and_fallbacks);
+ RUN_TEST(test_mirror_copy);
+ RUN_TEST(test_resize_and_non_shm_buffers);
+ RUN_TEST(test_fullscreen_output_and_escape);
+ RUN_TEST(test_missing_output);
+ RUN_TEST(test_fixed_window_and_escape);
+ RUN_TEST(test_composite_window);
+ RUN_TEST(test_composite_abort_releases_waiters);
+ RUN_TEST(test_x_error_is_logged);
+ RUN_TEST(test_close_button);
+
+ XCloseDisplay(g_dpy);
+ scratch_leave();
+ return test_summary();
+}
diff --git a/config/tx_1session_12bit.json b/dvledtx/config/tx_1session_12bit.json
similarity index 100%
rename from config/tx_1session_12bit.json
rename to dvledtx/config/tx_1session_12bit.json
diff --git a/config/tx_2k_2nic.json b/dvledtx/config/tx_2k_2nic.json
similarity index 100%
rename from config/tx_2k_2nic.json
rename to dvledtx/config/tx_2k_2nic.json
diff --git a/config/tx_2k_multi_session.json b/dvledtx/config/tx_2k_multi_session.json
similarity index 100%
rename from config/tx_2k_multi_session.json
rename to dvledtx/config/tx_2k_multi_session.json
diff --git a/config/tx_2k_single_session.json b/dvledtx/config/tx_2k_single_session.json
similarity index 100%
rename from config/tx_2k_single_session.json
rename to dvledtx/config/tx_2k_single_session.json
diff --git a/config/tx_4k_multi_session.json b/dvledtx/config/tx_4k_multi_session.json
similarity index 100%
rename from config/tx_4k_multi_session.json
rename to dvledtx/config/tx_4k_multi_session.json
diff --git a/config/tx_4k_single_session.json b/dvledtx/config/tx_4k_single_session.json
similarity index 100%
rename from config/tx_4k_single_session.json
rename to dvledtx/config/tx_4k_single_session.json
diff --git a/config/tx_fullhd_multi_nic.json b/dvledtx/config/tx_fullhd_multi_nic.json
similarity index 100%
rename from config/tx_fullhd_multi_nic.json
rename to dvledtx/config/tx_fullhd_multi_nic.json
diff --git a/config/tx_fullhd_multi_session.json b/dvledtx/config/tx_fullhd_multi_session.json
similarity index 100%
rename from config/tx_fullhd_multi_session.json
rename to dvledtx/config/tx_fullhd_multi_session.json
diff --git a/config/tx_fullhd_screen_capture.json b/dvledtx/config/tx_fullhd_screen_capture.json
similarity index 100%
rename from config/tx_fullhd_screen_capture.json
rename to dvledtx/config/tx_fullhd_screen_capture.json
diff --git a/config/tx_fullhd_screen_capture_multi_session.json b/dvledtx/config/tx_fullhd_screen_capture_multi_session.json
similarity index 100%
rename from config/tx_fullhd_screen_capture_multi_session.json
rename to dvledtx/config/tx_fullhd_screen_capture_multi_session.json
diff --git a/config/tx_fullhd_single_session.json b/dvledtx/config/tx_fullhd_single_session.json
similarity index 100%
rename from config/tx_fullhd_single_session.json
rename to dvledtx/config/tx_fullhd_single_session.json
diff --git a/config/tx_gbrp12le_pf_8session.json b/dvledtx/config/tx_gbrp12le_pf_8session.json
similarity index 100%
rename from config/tx_gbrp12le_pf_8session.json
rename to dvledtx/config/tx_gbrp12le_pf_8session.json
diff --git a/fuzz/corpus/empty_object.json b/dvledtx/fuzz/corpus/empty_object.json
similarity index 100%
rename from fuzz/corpus/empty_object.json
rename to dvledtx/fuzz/corpus/empty_object.json
diff --git a/fuzz/corpus/fmt_gbrp10le.json b/dvledtx/fuzz/corpus/fmt_gbrp10le.json
similarity index 100%
rename from fuzz/corpus/fmt_gbrp10le.json
rename to dvledtx/fuzz/corpus/fmt_gbrp10le.json
diff --git a/fuzz/corpus/fmt_yuv422p12le.json b/dvledtx/fuzz/corpus/fmt_yuv422p12le.json
similarity index 100%
rename from fuzz/corpus/fmt_yuv422p12le.json
rename to dvledtx/fuzz/corpus/fmt_yuv422p12le.json
diff --git a/fuzz/corpus/fmt_yuv444p10le.json b/dvledtx/fuzz/corpus/fmt_yuv444p10le.json
similarity index 100%
rename from fuzz/corpus/fmt_yuv444p10le.json
rename to dvledtx/fuzz/corpus/fmt_yuv444p10le.json
diff --git a/fuzz/corpus/malformed.json b/dvledtx/fuzz/corpus/malformed.json
similarity index 100%
rename from fuzz/corpus/malformed.json
rename to dvledtx/fuzz/corpus/malformed.json
diff --git a/fuzz/corpus/multi_nic.json b/dvledtx/fuzz/corpus/multi_nic.json
similarity index 100%
rename from fuzz/corpus/multi_nic.json
rename to dvledtx/fuzz/corpus/multi_nic.json
diff --git a/fuzz/corpus/multi_session.json b/dvledtx/fuzz/corpus/multi_session.json
similarity index 100%
rename from fuzz/corpus/multi_session.json
rename to dvledtx/fuzz/corpus/multi_session.json
diff --git a/fuzz/corpus/ptp_config.json b/dvledtx/fuzz/corpus/ptp_config.json
similarity index 100%
rename from fuzz/corpus/ptp_config.json
rename to dvledtx/fuzz/corpus/ptp_config.json
diff --git a/fuzz/corpus/screen_capture.json b/dvledtx/fuzz/corpus/screen_capture.json
similarity index 100%
rename from fuzz/corpus/screen_capture.json
rename to dvledtx/fuzz/corpus/screen_capture.json
diff --git a/fuzz/corpus/valid_config.json b/dvledtx/fuzz/corpus/valid_config.json
similarity index 100%
rename from fuzz/corpus/valid_config.json
rename to dvledtx/fuzz/corpus/valid_config.json
diff --git a/fuzz/fuzz_config_reader.c b/dvledtx/fuzz/fuzz_config_reader.c
similarity index 100%
rename from fuzz/fuzz_config_reader.c
rename to dvledtx/fuzz/fuzz_config_reader.c
diff --git a/fuzz/fuzz_config_reader_libfuzzer.c b/dvledtx/fuzz/fuzz_config_reader_libfuzzer.c
similarity index 100%
rename from fuzz/fuzz_config_reader_libfuzzer.c
rename to dvledtx/fuzz/fuzz_config_reader_libfuzzer.c
diff --git a/include/app_context.h b/dvledtx/include/app_context.h
similarity index 100%
rename from include/app_context.h
rename to dvledtx/include/app_context.h
diff --git a/include/core/session_manager.h b/dvledtx/include/core/session_manager.h
similarity index 100%
rename from include/core/session_manager.h
rename to dvledtx/include/core/session_manager.h
diff --git a/include/ffmpeg/ffmpeg_decoder.h b/dvledtx/include/ffmpeg/ffmpeg_decoder.h
similarity index 100%
rename from include/ffmpeg/ffmpeg_decoder.h
rename to dvledtx/include/ffmpeg/ffmpeg_decoder.h
diff --git a/include/ffmpeg/ffmpeg_frame_handler.h b/dvledtx/include/ffmpeg/ffmpeg_frame_handler.h
similarity index 100%
rename from include/ffmpeg/ffmpeg_frame_handler.h
rename to dvledtx/include/ffmpeg/ffmpeg_frame_handler.h
diff --git a/include/ffmpeg/ffmpeg_tx.h b/dvledtx/include/ffmpeg/ffmpeg_tx.h
similarity index 100%
rename from include/ffmpeg/ffmpeg_tx.h
rename to dvledtx/include/ffmpeg/ffmpeg_tx.h
diff --git a/include/mtl/mtl_tx.h b/dvledtx/include/mtl/mtl_tx.h
similarity index 100%
rename from include/mtl/mtl_tx.h
rename to dvledtx/include/mtl/mtl_tx.h
diff --git a/include/util/config_reader.h b/dvledtx/include/util/config_reader.h
similarity index 100%
rename from include/util/config_reader.h
rename to dvledtx/include/util/config_reader.h
diff --git a/include/util/logger.h b/dvledtx/include/util/logger.h
similarity index 100%
rename from include/util/logger.h
rename to dvledtx/include/util/logger.h
diff --git a/dvledtx/meson.build b/dvledtx/meson.build
new file mode 100644
index 0000000..659652c
--- /dev/null
+++ b/dvledtx/meson.build
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright 2026 Intel Corporation
+
+# TX path selection — see the root meson_options.txt for details.
+enable_mtl_tx = get_option('enable_mtl_tx')
+tx_c_args = ['-DALLOW_EXPERIMENTAL_API', '-D_FORTIFY_SOURCE=2', '-O2']
+if enable_mtl_tx
+ tx_c_args += ['-DENABLE_MTL_TX']
+endif
+
+tx_deps = [m_dep, pthread_dep, libavformat_dep, libavcodec_dep, libavutil_dep,
+ libswscale_dep, avdevice_dep, mtl_dep]
+
+executable('dvledtx',
+ sources: [
+ 'src/main.c',
+ 'src/util/config_reader.c',
+ 'src/core/session_manager.c',
+ 'src/ffmpeg/ffmpeg_decoder.c',
+ 'src/ffmpeg/ffmpeg_frame_handler.c',
+ 'src/ffmpeg/ffmpeg_tx.c',
+ 'src/mtl/mtl_tx.c',
+ 'src/util/logger.c',
+ ],
+ include_directories: include_directories('include'),
+ dependencies: tx_deps,
+ c_args: tx_c_args,
+ link_args: ['-s'],
+ build_rpath: '',
+ install: false
+)
+
+subdir('tests')
diff --git a/src/core/session_manager.c b/dvledtx/src/core/session_manager.c
similarity index 100%
rename from src/core/session_manager.c
rename to dvledtx/src/core/session_manager.c
diff --git a/src/ffmpeg/ffmpeg_decoder.c b/dvledtx/src/ffmpeg/ffmpeg_decoder.c
similarity index 100%
rename from src/ffmpeg/ffmpeg_decoder.c
rename to dvledtx/src/ffmpeg/ffmpeg_decoder.c
diff --git a/src/ffmpeg/ffmpeg_frame_handler.c b/dvledtx/src/ffmpeg/ffmpeg_frame_handler.c
similarity index 100%
rename from src/ffmpeg/ffmpeg_frame_handler.c
rename to dvledtx/src/ffmpeg/ffmpeg_frame_handler.c
diff --git a/src/ffmpeg/ffmpeg_tx.c b/dvledtx/src/ffmpeg/ffmpeg_tx.c
similarity index 100%
rename from src/ffmpeg/ffmpeg_tx.c
rename to dvledtx/src/ffmpeg/ffmpeg_tx.c
diff --git a/src/main.c b/dvledtx/src/main.c
similarity index 100%
rename from src/main.c
rename to dvledtx/src/main.c
diff --git a/src/mtl/mtl_tx.c b/dvledtx/src/mtl/mtl_tx.c
similarity index 100%
rename from src/mtl/mtl_tx.c
rename to dvledtx/src/mtl/mtl_tx.c
diff --git a/src/util/config_reader.c b/dvledtx/src/util/config_reader.c
similarity index 100%
rename from src/util/config_reader.c
rename to dvledtx/src/util/config_reader.c
diff --git a/src/util/logger.c b/dvledtx/src/util/logger.c
similarity index 100%
rename from src/util/logger.c
rename to dvledtx/src/util/logger.c
diff --git a/dvledtx/tests/meson.build b/dvledtx/tests/meson.build
new file mode 100644
index 0000000..e5cdf8b
--- /dev/null
+++ b/dvledtx/tests/meson.build
@@ -0,0 +1,152 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright 2026 Intel Corporation
+
+cmocka_dep = dependency('cmocka', required: false, method: 'pkg-config')
+if not cmocka_dep.found()
+ subdir_done()
+endif
+
+tx_test_deps = [m_dep, pthread_dep, libavformat_dep, libavcodec_dep,
+ libavutil_dep, libswscale_dep, avdevice_dep, cmocka_dep]
+tx_fixture_dir = meson.current_source_dir() / '../config'
+tx_test_inc = include_directories('../include', '/usr/local/include/mtl')
+tx_test_c_args = ['-DALLOW_EXPERIMENTAL_API']
+
+tx_test_config_reader = executable('test_config_reader',
+ sources: ['test_config_reader.c', '../src/util/config_reader.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args + ['-DFIXTURE_DIR="' + tx_fixture_dir + '"'],
+ install: false,
+ build_by_default: false
+)
+test('config_reader', tx_test_config_reader)
+
+tx_test_logger = executable('test_logger',
+ sources: ['test_logger.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: [m_dep, pthread_dep, cmocka_dep],
+ install: false,
+ build_by_default: false
+)
+test('logger', tx_test_logger)
+
+tx_test_ffmpeg_decoder = executable('test_ffmpeg_decoder',
+ sources: ['test_ffmpeg_decoder.c', '../src/ffmpeg/ffmpeg_decoder.c',
+ '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_decoder', tx_test_ffmpeg_decoder)
+
+tx_test_ffmpeg_frame_handler = executable('test_ffmpeg_frame_handler',
+ sources: ['test_ffmpeg_frame_handler.c', '../src/ffmpeg/ffmpeg_frame_handler.c',
+ '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_frame_handler', tx_test_ffmpeg_frame_handler)
+
+tx_test_ffmpeg_tx = executable('test_ffmpeg_tx',
+ sources: ['test_ffmpeg_tx.c', '../src/ffmpeg/ffmpeg_tx.c',
+ '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ link_args: ['-Wl,--wrap=av_guess_format'],
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_tx', tx_test_ffmpeg_tx)
+
+# session_manager.c compiled in; ffmpeg_decoder.c excluded — mocks provided in the test file.
+tx_test_session_manager = executable('test_session_manager',
+ sources: ['test_session_manager.c', '../src/core/session_manager.c',
+ '../src/mtl/mtl_tx.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps + (mtl_dep.found() ? [mtl_dep] : []),
+ c_args: tx_test_c_args,
+ install: false,
+ build_by_default: false
+)
+test('session_manager', tx_test_session_manager)
+
+# main.c is included directly in the test file to expose static functions.
+tx_test_main = executable('test_main',
+ sources: ['test_main.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ install: false,
+ build_by_default: false
+)
+test('main', tx_test_main)
+
+tx_test_ffmpeg_decoder_mock = executable('test_ffmpeg_decoder_mock',
+ sources: ['test_ffmpeg_decoder_mock.c', '../src/ffmpeg/ffmpeg_decoder.c',
+ '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ link_args: ['-Wl,--wrap=av_guess_format'],
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_decoder_mock', tx_test_ffmpeg_decoder_mock)
+
+tx_test_ffmpeg_tx_mock = executable('test_ffmpeg_tx_mock',
+ sources: ['test_ffmpeg_tx_mock.c', '../src/ffmpeg/ffmpeg_decoder.c',
+ '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/ffmpeg/ffmpeg_tx.c',
+ '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ link_args: ['-Wl,--wrap=av_guess_format'],
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_tx_mock', tx_test_ffmpeg_tx_mock)
+
+tx_test_ffmpeg_watchdog = executable('test_ffmpeg_watchdog',
+ sources: ['test_ffmpeg_watchdog.c', '../src/ffmpeg/ffmpeg_decoder.c',
+ '../src/ffmpeg/ffmpeg_frame_handler.c', '../src/util/logger.c'],
+ include_directories: tx_test_inc,
+ dependencies: tx_test_deps,
+ c_args: tx_test_c_args,
+ link_args: ['-Wl,--wrap=av_read_frame', '-Wl,--wrap=av_guess_format'],
+ install: false,
+ build_by_default: false
+)
+test('ffmpeg_watchdog', tx_test_ffmpeg_watchdog)
+
+if mtl_dep.found()
+ tx_test_mtl = executable('test_mtl_tx',
+ sources: ['test_mtl_tx.c', '../src/mtl/mtl_tx.c', '../src/util/logger.c'],
+ include_directories: [tx_test_inc, include_directories('/usr/local/include/mtl')],
+ dependencies: tx_test_deps + [mtl_dep],
+ c_args: tx_test_c_args + ['-DENABLE_MTL_TX'],
+ install: false,
+ build_by_default: false
+ )
+ test('mtl_tx', tx_test_mtl)
+
+ tx_test_mtl_stub = executable('test_mtl_tx_stub',
+ sources: ['test_mtl_tx_stub.c', '../src/mtl/mtl_tx.c', '../src/util/logger.c'],
+ include_directories: [tx_test_inc, include_directories('/usr/local/include/mtl')],
+ dependencies: tx_test_deps + [mtl_dep],
+ c_args: tx_test_c_args + ['-DENABLE_MTL_TX'],
+ link_args: ['-Wl,--wrap=mtl_init', '-Wl,--wrap=mtl_uninit',
+ '-Wl,--wrap=mtl_pmd_by_port_name', '-Wl,--wrap=st20p_tx_create',
+ '-Wl,--wrap=st20p_tx_free', '-Wl,--wrap=st20p_tx_frame_size',
+ '-Wl,--wrap=st20p_tx_get_frame', '-Wl,--wrap=st20p_tx_put_frame'],
+ install: false,
+ build_by_default: false
+ )
+ test('mtl_tx_stub', tx_test_mtl_stub)
+endif
diff --git a/tests/test_config_reader.c b/dvledtx/tests/test_config_reader.c
similarity index 100%
rename from tests/test_config_reader.c
rename to dvledtx/tests/test_config_reader.c
diff --git a/tests/test_ffmpeg_decoder.c b/dvledtx/tests/test_ffmpeg_decoder.c
similarity index 100%
rename from tests/test_ffmpeg_decoder.c
rename to dvledtx/tests/test_ffmpeg_decoder.c
diff --git a/tests/test_ffmpeg_decoder_mock.c b/dvledtx/tests/test_ffmpeg_decoder_mock.c
similarity index 100%
rename from tests/test_ffmpeg_decoder_mock.c
rename to dvledtx/tests/test_ffmpeg_decoder_mock.c
diff --git a/tests/test_ffmpeg_frame_handler.c b/dvledtx/tests/test_ffmpeg_frame_handler.c
similarity index 100%
rename from tests/test_ffmpeg_frame_handler.c
rename to dvledtx/tests/test_ffmpeg_frame_handler.c
diff --git a/tests/test_ffmpeg_tx.c b/dvledtx/tests/test_ffmpeg_tx.c
similarity index 100%
rename from tests/test_ffmpeg_tx.c
rename to dvledtx/tests/test_ffmpeg_tx.c
diff --git a/tests/test_ffmpeg_tx_mock.c b/dvledtx/tests/test_ffmpeg_tx_mock.c
similarity index 100%
rename from tests/test_ffmpeg_tx_mock.c
rename to dvledtx/tests/test_ffmpeg_tx_mock.c
diff --git a/tests/test_ffmpeg_watchdog.c b/dvledtx/tests/test_ffmpeg_watchdog.c
similarity index 100%
rename from tests/test_ffmpeg_watchdog.c
rename to dvledtx/tests/test_ffmpeg_watchdog.c
diff --git a/tests/test_logger.c b/dvledtx/tests/test_logger.c
similarity index 100%
rename from tests/test_logger.c
rename to dvledtx/tests/test_logger.c
diff --git a/tests/test_main.c b/dvledtx/tests/test_main.c
similarity index 100%
rename from tests/test_main.c
rename to dvledtx/tests/test_main.c
diff --git a/tests/test_mtl_tx.c b/dvledtx/tests/test_mtl_tx.c
similarity index 100%
rename from tests/test_mtl_tx.c
rename to dvledtx/tests/test_mtl_tx.c
diff --git a/tests/test_mtl_tx_stub.c b/dvledtx/tests/test_mtl_tx_stub.c
similarity index 100%
rename from tests/test_mtl_tx_stub.c
rename to dvledtx/tests/test_mtl_tx_stub.c
diff --git a/tests/test_session_manager.c b/dvledtx/tests/test_session_manager.c
similarity index 100%
rename from tests/test_session_manager.c
rename to dvledtx/tests/test_session_manager.c
diff --git a/meson.build b/meson.build
index a85c8b2..53502ea 100644
--- a/meson.build
+++ b/meson.build
@@ -16,38 +16,14 @@ libswscale_dep = dependency('libswscale', required: true)
# TX path selection — see meson_options.txt for details.
enable_mtl_tx = get_option('enable_mtl_tx')
-# MTL is a required dependency for both TX paths — see src/mtl/mtl_tx.c.
+# MTL is required by both transmit and receive applications.
mtl_dep = dependency('mtl', required: true)
-c_args = ['-DALLOW_EXPERIMENTAL_API', '-D_FORTIFY_SOURCE=2', '-O2']
# libavdevice is needed regardless of the TX path: the mtl_st20p muxer TX
# path uses it directly, and the MTL-native TX path still needs it for the
# x11grab screen-capture input decoder (avdevice_register_all()).
avdevice_dep = dependency('libavdevice', required: true)
-if enable_mtl_tx
- c_args += ['-DENABLE_MTL_TX']
-endif
-
-all_deps = [m_dep, pthread_dep,
- libavformat_dep, libavcodec_dep, libavutil_dep, libswscale_dep,
- avdevice_dep, mtl_dep]
-
-executable('dvledtx',
- sources: [
- 'src/main.c',
- 'src/util/config_reader.c',
- 'src/core/session_manager.c',
- 'src/ffmpeg/ffmpeg_decoder.c',
- 'src/ffmpeg/ffmpeg_frame_handler.c',
- 'src/ffmpeg/ffmpeg_tx.c',
- 'src/mtl/mtl_tx.c',
- 'src/util/logger.c'
- ],
- include_directories: include_directories('include'),
- dependencies: all_deps,
- c_args: c_args,
- link_args: ['-s'],
- build_rpath: '',
- install: false
-)
+# Each application owns its Meson definition and its tests subdirectory.
+subdir('dvledtx')
+subdir('dvledrx')
diff --git a/scripts/build.sh b/scripts/build.sh
index bcb6a87..6efc7e7 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright 2026 Intel Corporation
-# Build script for dvledtx
+# Build script for dvledtx and dvledrx
# Usage: bash scripts/build.sh [meson options]
# e.g. bash scripts/build.sh -Denable_mtl_tx=true
@@ -11,7 +11,7 @@ set -e
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
-echo "Building dvledtx..."
+echo "Building dvledtx and dvledrx..."
echo "Cleaning previous build..."
rm -rf build
@@ -19,11 +19,15 @@ rm -rf build
echo "Setting up build directory..."
meson setup build "$@"
-echo "Compiling dvledtx..."
+echo "Compiling applications..."
ninja -C build
+test -x ./build/dvledtx/dvledtx
+test -x ./build/dvledrx/dvledrx
+
echo "Build completed successfully!"
echo ""
-echo "To run the application:"
-echo " ./build/dvledtx --help"
+echo "To run the applications:"
+echo " ./build/dvledtx/dvledtx --help"
+echo " ./build/dvledrx/dvledrx --help"
echo ""
diff --git a/scripts/test.sh b/scripts/test.sh
index 9ccfc11..fd3a570 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -2,18 +2,17 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright 2026 Intel Corporation
-# Unit test runner for dvledtx
+# Unit test runner for dvledtx and dvledrx
# Usage: bash scripts/test.sh [--no-coverage]
#
-# Runs all cmocka unit tests under tests/ and prints a pass/fail summary.
+# Runs both applications' unit tests and prints a pass/fail summary.
# When gcovr is available (and --no-coverage is not passed), a per-file
-# line coverage report is printed after the test results.
+# line coverage report is printed for both source trees.
set -e
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-TESTS_DIR="$REPO_ROOT/tests"
-BUILD_DIR="$TESTS_DIR/build"
+BUILD_DIR="$REPO_ROOT/build-tests"
COVERAGE=true
if [[ "${1:-}" == "--no-coverage" ]]; then
@@ -36,15 +35,14 @@ if $COVERAGE && [[ -z "$GCOVR" ]]; then
fi
# ── Build ──────────────────────────────────────────────────────────────────
-echo "Setting up test build..."
-cd "$TESTS_DIR"
+echo "Setting up combined test build..."
rm -rf "$BUILD_DIR"
MESON_ARGS=()
$COVERAGE && MESON_ARGS+=("-Db_coverage=true")
-meson setup build "${MESON_ARGS[@]}" >/dev/null
-ninja -C build >/dev/null
+meson setup "$BUILD_DIR" "$REPO_ROOT" "${MESON_ARGS[@]}" >/dev/null
+ninja -C "$BUILD_DIR" >/dev/null
# ── Detect a live X display for opt-in screen-capture (x11grab) tests ───────
# The screen-capture success tests run only when DVLED_TEST_DISPLAY is set to
@@ -72,19 +70,21 @@ echo ""
echo "Running unit tests..."
echo "──────────────────────────────────────────────────────────────────────"
set +e
-meson test -C build --print-errorlogs 2>&1
+meson test -C "$BUILD_DIR" --print-errorlogs 2>&1
TEST_EXIT=$?
set -e
# ── Coverage ───────────────────────────────────────────────────────────────
if $COVERAGE; then
echo ""
- echo "Coverage report (src/ — main.c excluded)"
+ echo "Coverage report (dvledtx/src/ and dvledrx/src/ — main.c excluded)"
echo "──────────────────────────────────────────────────────────────────────"
"$GCOVR" \
- --root "$REPO_ROOT/src" \
- --filter "$REPO_ROOT/src/" \
- --exclude "$REPO_ROOT/src/main.c" \
+ --root "$REPO_ROOT" \
+ --filter "$REPO_ROOT/dvledtx/src/" \
+ --filter "$REPO_ROOT/dvledrx/src/" \
+ --exclude "$REPO_ROOT/dvledtx/src/main.c" \
+ --exclude "$REPO_ROOT/dvledrx/src/main.c" \
--gcov-ignore-parse-errors=negative_hits.warn_once_per_file \
"$BUILD_DIR" \
--txt
diff --git a/tests/meson.build b/tests/meson.build
deleted file mode 100644
index aa6aa60..0000000
--- a/tests/meson.build
+++ /dev/null
@@ -1,212 +0,0 @@
-# SPDX-License-Identifier: BSD-3-Clause
-# Copyright 2026 Intel Corporation
-
-project('dvledtx-tests', 'c')
-
-cc = meson.get_compiler('c')
-
-m_dep = cc.find_library('m', required: true)
-pthread_dep = cc.find_library('pthread', required: true)
-
-libavformat_dep = dependency('libavformat', required: true)
-libavcodec_dep = dependency('libavcodec', required: true)
-libavutil_dep = dependency('libavutil', required: true)
-libswscale_dep = dependency('libswscale', required: true)
-avdevice_dep = dependency('libavdevice', required: true)
-mtl_dep = dependency('mtl', required: false)
-
-cmocka_dep = dependency('cmocka', required: false, method: 'pkg-config')
-
-if not cmocka_dep.found()
- subdir_done()
-endif
-
-all_deps = [m_dep, pthread_dep,
- libavformat_dep, libavcodec_dep, libavutil_dep, libswscale_dep,
- avdevice_dep]
-fixture_dir = meson.project_source_root() / '../config'
-test_inc = include_directories('../include', '/usr/local/include/mtl')
-test_deps = all_deps + [cmocka_dep]
-test_c_args = ['-DALLOW_EXPERIMENTAL_API']
-
-test_config_reader = executable('test_config_reader',
- sources: [
- 'test_config_reader.c',
- '../src/util/config_reader.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args + ['-DFIXTURE_DIR="' + fixture_dir + '"'],
- install: false
-)
-test('config_reader', test_config_reader)
-
-test_logger = executable('test_logger',
- sources: [
- 'test_logger.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: [m_dep, pthread_dep, cmocka_dep],
- install: false
-)
-test('logger', test_logger)
-
-test_ffmpeg_decoder = executable('test_ffmpeg_decoder',
- sources: [
- 'test_ffmpeg_decoder.c',
- '../src/ffmpeg/ffmpeg_decoder.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- install: false
-)
-test('ffmpeg_decoder', test_ffmpeg_decoder)
-
-test_ffmpeg_frame_handler = executable('test_ffmpeg_frame_handler',
- sources: [
- 'test_ffmpeg_frame_handler.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- install: false
-)
-test('ffmpeg_frame_handler', test_ffmpeg_frame_handler)
-
-test_ffmpeg_tx = executable('test_ffmpeg_tx',
- sources: [
- 'test_ffmpeg_tx.c',
- '../src/ffmpeg/ffmpeg_tx.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- link_args: ['-Wl,--wrap=av_guess_format'],
- install: false
-)
-test('ffmpeg_tx', test_ffmpeg_tx)
-
-# session_manager.c compiled in; ffmpeg_decoder.c excluded — mocks provided in the test file.
-test_session_manager = executable('test_session_manager',
- sources: [
- 'test_session_manager.c',
- '../src/core/session_manager.c',
- '../src/mtl/mtl_tx.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps + (mtl_dep.found() ? [mtl_dep] : []),
- c_args: test_c_args,
- install: false
-)
-test('session_manager', test_session_manager)
-
-# main.c is included directly in the test file to expose static functions.
-# session_manager.c and config_reader.c excluded — stubs provided in the test file.
-test_main = executable('test_main',
- sources: [
- 'test_main.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- install: false
-)
-test('main', test_main)
-
-# av_guess_format wrapped to return the 'null' muxer instead of mtl_st20p.
-test_ffmpeg_decoder_mock = executable('test_ffmpeg_decoder_mock',
- sources: [
- 'test_ffmpeg_decoder_mock.c',
- '../src/ffmpeg/ffmpeg_decoder.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- link_args: ['-Wl,--wrap=av_guess_format'],
- install: false
-)
-test('ffmpeg_decoder_mock', test_ffmpeg_decoder_mock)
-
-test_ffmpeg_tx_mock = executable('test_ffmpeg_tx_mock',
- sources: [
- 'test_ffmpeg_tx_mock.c',
- '../src/ffmpeg/ffmpeg_decoder.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/ffmpeg/ffmpeg_tx.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- link_args: ['-Wl,--wrap=av_guess_format'],
- install: false
-)
-test('ffmpeg_tx_mock', test_ffmpeg_tx_mock)
-
-# Watchdog tests: --wrap=av_read_frame to inject perpetual decode failure.
-test_ffmpeg_watchdog = executable('test_ffmpeg_watchdog',
- sources: [
- 'test_ffmpeg_watchdog.c',
- '../src/ffmpeg/ffmpeg_decoder.c',
- '../src/ffmpeg/ffmpeg_frame_handler.c',
- '../src/util/logger.c',
- ],
- include_directories: test_inc,
- dependencies: test_deps,
- c_args: test_c_args,
- link_args: ['-Wl,--wrap=av_read_frame',
- '-Wl,--wrap=av_guess_format'],
- install: false
-)
-test('ffmpeg_watchdog', test_ffmpeg_watchdog)
-
-if mtl_dep.found()
- test_mtl_tx = executable('test_mtl_tx',
- sources: [
- 'test_mtl_tx.c',
- '../src/mtl/mtl_tx.c',
- '../src/util/logger.c',
- ],
- include_directories: [test_inc, include_directories('/usr/local/include/mtl')],
- dependencies: test_deps + [mtl_dep],
- c_args: test_c_args + ['-DENABLE_MTL_TX'],
- install: false
- )
- test('mtl_tx', test_mtl_tx)
-
- # Stub-based tests: MTL library entry points are wrapped so the session
- # lifecycle and frame-send paths run with no real MTL/DPDK hardware.
- test_mtl_tx_stub = executable('test_mtl_tx_stub',
- sources: [
- 'test_mtl_tx_stub.c',
- '../src/mtl/mtl_tx.c',
- '../src/util/logger.c',
- ],
- include_directories: [test_inc, include_directories('/usr/local/include/mtl')],
- dependencies: test_deps + [mtl_dep],
- c_args: test_c_args + ['-DENABLE_MTL_TX'],
- link_args: ['-Wl,--wrap=mtl_init',
- '-Wl,--wrap=mtl_uninit',
- '-Wl,--wrap=mtl_pmd_by_port_name',
- '-Wl,--wrap=st20p_tx_create',
- '-Wl,--wrap=st20p_tx_free',
- '-Wl,--wrap=st20p_tx_frame_size',
- '-Wl,--wrap=st20p_tx_get_frame',
- '-Wl,--wrap=st20p_tx_put_frame'],
- install: false
- )
- test('mtl_tx_stub', test_mtl_tx_stub)
-endif