From 29958ea884151c12371d9bfa6b3602ed1190b898 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:03:59 +0300 Subject: [PATCH] =?UTF-8?q?txdemo:=20SvcTxDemo=20=E2=80=94=20per-temporal-?= =?UTF-8?q?layer=20UEP=20injector=20for=20SVC-T=20video?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showcase of the per-packet radiotap path: map each HEVC NAL's temporal_id (and IRAP/parameter-set criticality) to a PHY TxMode, so base/IDR layers fly at a robust MCS and enhancement layers ride the fast MCS — a graceful degradation staircase instead of one MCS cliff. App-level only; no WiFiDriver core change. - svc_tx.h: LayerPolicy (TID->TxMode ladder + critical override), parse_hevc_nal, default_policy, and policy_from_env (DEVOURER_SVC_LADDER='CRIT=..;T0=..;T1=..'). - SvcTxDemo: reads length-prefixed HEVC NALs, classifies, fragments to MTU, injects each at its layer's rate via build_stream_radiotap + send_packet. - RadiotapBuilder: factor parse_tx_mode_env -> reusable parse_tx_mode_str(spec) so each ladder rung is a DEVOURER_TX_RATE-style string. - tests/gen_svc_nals.py (synthetic 1:4:8:16 IDR:T0:T1:T2 stream) + tests/svc_uep_onair.sh on-air harness; CLAUDE.md note. Hardware-verified (8814 TX -> 8821 monitor, ch6): the demo's TID counts and the witness MCS histogram both reproduce the 1:4:8:16 layer mix across MCS0/1/4/7, with the robust rungs over-delivered vs the fragile ones (the UEP payoff). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 9 ++ CMakeLists.txt | 8 ++ src/RadiotapBuilder.cpp | 20 ++-- src/RadiotapBuilder.h | 8 +- tests/gen_svc_nals.py | 39 ++++++ tests/svc_uep_onair.sh | 46 ++++++++ txdemo/svc_tx_demo/main.cpp | 228 ++++++++++++++++++++++++++++++++++++ txdemo/svc_tx_demo/svc_tx.h | 120 +++++++++++++++++++ 8 files changed, 468 insertions(+), 10 deletions(-) create mode 100644 tests/gen_svc_nals.py create mode 100644 tests/svc_uep_onair.sh create mode 100644 txdemo/svc_tx_demo/main.cpp create mode 100644 txdemo/svc_tx_demo/svc_tx.h diff --git a/CLAUDE.md b/CLAUDE.md index c16646b..e4d001d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,15 @@ gate any more (an HT-MCS radiotap is honoured unconditionally): (Programmatic equivalent: `dev->SetTxMode(devourer::TxMode{...})`; `dev->ClearTxMode()` reverts to the built-in default.) +`SvcTxDemo` is a per-packet-rate showcase: it reads length-prefixed HEVC NALs +from stdin, classifies each by `temporal_id` / IRAP-or-parameter-set criticality +(`txdemo/svc_tx_demo/svc_tx.h`), and injects each at the PHY rate its SVC-T layer +deserves — a per-layer unequal-error-protection ladder (robust MCS for base/IDR, +fast MCS for enhancement), since the radiotap is honoured per-packet. The ladder +is `DEVOURER_SVC_LADDER="CRIT=;T0=;T1=;..."` where each `` +is a `DEVOURER_TX_RATE` string; unset uses the built-in default. On-air check: +`tests/gen_svc_nals.py` (synthetic 1:4:8:16 layer mix) + `tests/svc_uep_onair.sh`. + `WiFiDriverTxDemo` also honours a TX-gain ramp + duty knob for thermal / TX-power characterisation (drives `RtlJaguarDevice::SetTxPowerOverride` + `ApplyTxPower`): diff --git a/CMakeLists.txt b/CMakeLists.txt index c668d0e..c5aaf56 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,6 +123,14 @@ target_link_libraries(StreamTxDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) # stream_stdin.h (shared binary-stdin framing) lives in txdemo/. target_include_directories(StreamTxDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) +# SVC-T UEP injector: maps each HEVC NAL's temporal_id -> a robust/fast TxMode +# (svc_tx.h), so base/IDR layers fly robust and enhancement layers ride fast. +add_executable(SvcTxDemo + txdemo/svc_tx_demo/main.cpp +) +target_link_libraries(SvcTxDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) +target_include_directories(SvcTxDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) + # Stream-link DUPLEX: one binary, one chip, both directions. Combines the RX # loop from WiFiDriverDemo and the stdin-driven TX from StreamTxDemo — # stdin = length-prefixed PSDU bodies, stdout = lines. diff --git a/src/RadiotapBuilder.cpp b/src/RadiotapBuilder.cpp index a2a0dbc..e4ffab9 100644 --- a/src/RadiotapBuilder.cpp +++ b/src/RadiotapBuilder.cpp @@ -197,14 +197,13 @@ std::vector build_stream_radiotap(const TxMode& cfg) { } } -TxMode parse_tx_mode_env() { +TxMode parse_tx_mode_str(const std::string& spec) { TxMode cfg; /* defaults: legacy 6M, 20 MHz, no SGI/LDPC/STBC */ - const char* raw = std::getenv("DEVOURER_TX_RATE"); - if (raw == nullptr || *raw == '\0') { + if (spec.empty()) { return cfg; } - const std::string s = to_upper_stripped(raw); + const std::string s = to_upper_stripped(spec.c_str()); /* Split on '/': first token = rate, rest = bandwidth (numeric) or modifier * flags (SGI / LDPC / STBC). */ @@ -221,8 +220,8 @@ TxMode parse_tx_mode_env() { } if (tokens.empty() || tokens[0].empty() || !parse_rate_token(tokens[0], &cfg)) { std::fprintf(stderr, - "warning: unrecognised DEVOURER_TX_RATE=%s, " - "falling back to 6M legacy\n", raw); + "warning: unrecognised TX rate '%s', " + "falling back to 6M legacy\n", spec.c_str()); return TxMode{}; } @@ -235,10 +234,15 @@ TxMode parse_tx_mode_env() { cfg.bw_mhz = static_cast(std::atoi(t.c_str())); else if (!t.empty()) std::fprintf(stderr, - "warning: ignoring unrecognised DEVOURER_TX_RATE " - "token '%s'\n", t.c_str()); + "warning: ignoring unrecognised TX-mode token " + "'%s'\n", t.c_str()); } return cfg; } +TxMode parse_tx_mode_env() { + const char* raw = std::getenv("DEVOURER_TX_RATE"); + return parse_tx_mode_str(raw ? raw : ""); +} + } // namespace devourer diff --git a/src/RadiotapBuilder.h b/src/RadiotapBuilder.h index 9b44911..cc62d97 100644 --- a/src/RadiotapBuilder.h +++ b/src/RadiotapBuilder.h @@ -16,6 +16,7 @@ #define RADIOTAP_BUILDER_H #include +#include #include #include "TxMode.h" @@ -26,11 +27,14 @@ namespace devourer { * well-formed radiotap header — no 802.11 frame body. */ std::vector build_stream_radiotap(const TxMode& mode); -/* Parse DEVOURER_TX_RATE into a TxMode. Single slash-separated string: +/* Parse a TX-mode spec string into a TxMode. Single slash-separated string: * [/][/SGI][/LDPC][/STBC] (case-insensitive) * : 6M|9M|12M|18M|24M|36M|48M|54M | MCS0..MCS31 | VHT1SS_MCS0..VHT4SS_MCS9 * : 20|40|80|160 (default 20) - * Unset or unrecognised falls back to 6M legacy. */ + * Empty or unrecognised falls back to 6M legacy. */ +TxMode parse_tx_mode_str(const std::string& spec); + +/* parse_tx_mode_str applied to the DEVOURER_TX_RATE environment variable. */ TxMode parse_tx_mode_env(); } // namespace devourer diff --git a/tests/gen_svc_nals.py b/tests/gen_svc_nals.py new file mode 100644 index 0000000..fc65db0 --- /dev/null +++ b/tests/gen_svc_nals.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Generate length-prefixed synthetic HEVC NAL units with controlled +temporal_ids, to exercise SvcTxDemo's TID -> TxMode UEP mapping on air. + +Output (stdout, binary): ... + +HEVC NAL header (2 bytes): byte0 = (nal_type << 1), byte1 = (tid + 1). +Per GOP we emit a dyadic 3-temporal-layer hierarchical-P pattern plus an IDR: + 1 IDR (critical) + 4 T0 + 8 T1 + 16 T2 (ratio 1:4:8:16) +so the witness rate histogram should be MCS0:MCS1:MCS4:MCS7 ~= 1:4:8:16 +(the default_policy ladder in svc_tx.h). + + python3 tests/gen_svc_nals.py [GOPS] [PAYLOAD] | DEVOURER_PID=0x8812 ... ./build/SvcTxDemo +""" +import struct +import sys + +GOPS = int(sys.argv[1]) if len(sys.argv) > 1 else 6 +PAYLOAD = int(sys.argv[2]) if len(sys.argv) > 2 else 200 + +IDR_W_RADL = 19 # IRAP -> critical +TRAIL_R = 1 # referenced trailing picture + + +def nal(nal_type: int, tid: int) -> bytes: + body = bytes([(nal_type << 1) & 0xFF, (tid + 1) & 0x07]) + bytes(PAYLOAD) + return struct.pack(" TxMode UEP mapping. +# +# Synthetic HEVC NALs (tests/gen_svc_nals.py, ratio 1:4:8:16 = IDR:T0:T1:T2) are +# injected by the 8812; the 8814 kernel monitor witness decodes the per-frame +# MCS. The decoded histogram should track the default_policy ladder +# (critical=MCS0, T0=MCS1, T1=MCS4, T2=MCS7) in the same 1:4:8:16 proportion. +# +# ch6 (2.4 GHz, low current) avoids the USB Vbus-sag gotcha; a fresh power-cycle +# is taken first. Run: sudo bash tests/svc_uep_onair.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SA=57:42:75:05:d6:00 +TX=9-2; WIT=4-2.3.2 +KILL(){ sudo pkill -9 SvcTxDemo 2>/dev/null; sudo pkill -9 -f gen_svc_nals 2>/dev/null; } +trap KILL EXIT + +echo "=== fresh rail (power-cycle hub tree) ===" +sudo modprobe -r rtw88_8812au rtw88_8814au rtw88_8821au 2>/dev/null +for hp in "9 2" "4-2.3 2" "9-1 4"; do sudo uhubctl -a off -l ${hp% *} -p ${hp#* } >/dev/null 2>&1; done; sleep 18 +for hp in "9 2" "4-2.3 2" "9-1 4"; do sudo uhubctl -a on -l ${hp% *} -p ${hp#* } >/dev/null 2>&1; done; sleep 9 +sudo modprobe rtw88_8812au rtw88_8814au rtw88_8821au 2>/dev/null + +# 8814 kernel monitor witness @ ch6 +for i in /sys/bus/usb/devices/$WIT/$WIT:*; do printf '%s' "$(basename "$i")"|sudo tee /sys/bus/usb/drivers_probe>/dev/null 2>&1; done; sleep 4 +W=$(basename "$(readlink -f /sys/bus/usb/devices/$WIT/*:1.0/net/* 2>/dev/null)") +sudo ip link set "$W" down 2>/dev/null; sudo iw dev "$W" set monitor none 2>/dev/null +sudo ip link set "$W" up; sudo iw dev "$W" set channel 6 2>/dev/null +echo "witness=$W $(iw dev "$W" info 2>/dev/null|grep -oE 'channel [0-9]+')" + +# free the 8812 for devourer, inject the SVC stream +for i in /sys/bus/usb/devices/$TX/$TX:*; do ifc=$(basename "$i"); drv=$(readlink -f "$i/driver" 2>/dev/null); [ -n "$drv" ]&&echo "$ifc"|sudo tee "$drv/unbind">/dev/null 2>&1; done; sleep 1 +# Witness-friendly ladder: all 20 MHz, distinct MCS, no LDPC/STBC — so a plain +# 20 MHz monitor decodes every rung. (The realistic default_policy stacks +# 40 MHz/LDPC/STBC on the rungs, which a 20 MHz sniffer can't fully decode; +# that ladder is for real links, not this histogram check.) +python3 "$ROOT/tests/gen_svc_nals.py" 12 | \ + sudo env DEVOURER_VID=0x0bda DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 \ + DEVOURER_SVC_LADDER="CRIT=MCS0;T0=MCS1;T1=MCS4;T2=MCS7" \ + "$ROOT/build/SvcTxDemo" --gap-us 600 >/tmp/svc.log 2>&1 & +sleep 9 +echo "=== SvcTxDemo policy + per-TID counters ===" +grep -E "SVC UEP policy| T[0-9] ->|" /tmp/svc.log | tail -6 +echo "=== witness decoded MCS histogram (expect MCS0:MCS1:MCS4:MCS7 ~ 1:4:8:16) ===" +sudo timeout 6 tcpdump -i "$W" -e -nn "ether src $SA" 2>/dev/null | grep -oE "MCS [0-9]+" | sort -V | uniq -c +KILL diff --git a/txdemo/svc_tx_demo/main.cpp b/txdemo/svc_tx_demo/main.cpp new file mode 100644 index 0000000..d741197 --- /dev/null +++ b/txdemo/svc_tx_demo/main.cpp @@ -0,0 +1,228 @@ +// SvcTxDemo — TID-aware unequal-error-protection (UEP) injector. +// +// Reads a sequence of length-prefixed HEVC NAL units from stdin, classifies +// each by its temporal_id / criticality (svc_tx.h), and injects it at the PHY +// rate its layer deserves (robust MCS for base/IDR, fast MCS for enhancement). +// The per-packet radiotap carries the rate, so each frame is an independent +// PHY mode (radiotap-per-packet wins; see RtlJaguarDevice::send_packet). +// +// Input protocol (stdin): ... EOF +// (Length-prefixed = the AVCC/HVCC/MP4 framing many depacketizers already +// produce. A production link would feed Annex-B or RTP instead — same NAL +// header parse, trivial framing adapter.) +// +// All records are read up front, then injected in a continuous loop (so a +// bench witness can capture a stable rate histogram). A live deployment would +// inject each NAL as it arrives, once. +// +// Usage: +// DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/SvcTxDemo \ +// [--mtu N] [--gap-us US] < nals.bin + +#ifndef NOMINMAX +#define NOMINMAX // keep from defining min()/max() macros (breaks std::min) +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) + #include + #include + #include + typedef int pid_t; + #define sleep(seconds) Sleep((seconds)*1000) +#elif defined(__MINGW32__) || defined(__MINGW64__) + #include + #include + #include + #include +#elif defined(__ANDROID__) + #include + #include +#elif defined(__APPLE__) + #include + #include +#else + #include + #include +#endif + +#include "FrameParser.h" +#include "RadiotapBuilder.h" +#include "RtlUsbAdapter.h" +#include "WiFiDriver.h" +#include "logger.h" +#include "stream_stdin.h" +#include "svc_tx.h" + +#define USB_VENDOR_ID 0x0bda +static constexpr uint16_t kRealtekProductIds[] = { + 0x8812, 0x0811, 0xa811, 0xb811, 0x8813, +}; +static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + +static std::vector build_dot11_probe_req() { + std::vector h = {0x40, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); + h.push_back(0x80); + h.push_back(0x00); + return h; +} + +static const char* mode_str(const devourer::TxMode& m) { + static char buf[24]; + if (m.mode == devourer::TxMode::Mode::HT) + std::snprintf(buf, sizeof(buf), "MCS%u/%u%s%s%s", m.ht_mcs, m.bw_mhz, + m.sgi ? "/SGI" : "", m.ldpc ? "/LDPC" : "", + m.stbc ? "/STBC" : ""); + else if (m.mode == devourer::TxMode::Mode::VHT) + std::snprintf(buf, sizeof(buf), "VHT%uSS_MCS%u/%u", m.vht_nss, m.vht_mcs, + m.bw_mhz); + else + std::snprintf(buf, sizeof(buf), "%uM", m.legacy_rate_500kbps / 2); + return buf; +} + +int main(int argc, char** argv) { + auto logger = std::make_shared(); + + size_t mtu = 1400; + long gap_us = 2000; + long termux_fd = 0; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--mtu" && i + 1 < argc) + mtu = static_cast(std::strtoul(argv[++i], nullptr, 0)); + else if (a == "--gap-us" && i + 1 < argc) + gap_us = std::strtol(argv[++i], nullptr, 0); + else { + char* end = nullptr; + long v = std::strtol(a.c_str(), &end, 0); + if (end && *end == '\0' && v > 0) termux_fd = v; + } + } + if (mtu < 16) mtu = 16; + stream_stdin::set_stdin_binary(); + + libusb_context* context = nullptr; + libusb_device_handle* handle = nullptr; + int rc; + if (termux_fd > 0) { + libusb_set_option(NULL, LIBUSB_OPTION_NO_DEVICE_DISCOVERY); + libusb_set_option(NULL, LIBUSB_OPTION_WEAK_AUTHORITY); + libusb_init(&context); + rc = libusb_wrap_sys_device(context, (intptr_t)termux_fd, &handle); + if (rc < 0) { logger->error("wrap_sys_device: {}", rc); return 1; } + } else { + rc = libusb_init(&context); + if (rc < 0) return rc; + uint16_t target_pid = 0; + if (const char* p = std::getenv("DEVOURER_PID")) + target_pid = static_cast(std::strtoul(p, nullptr, 0)); + uint16_t target_vid = USB_VENDOR_ID; + if (const char* v = std::getenv("DEVOURER_VID")) + target_vid = static_cast(std::strtoul(v, nullptr, 0)); + for (uint16_t pid : kRealtekProductIds) { + if (target_pid != 0 && pid != target_pid) continue; + handle = libusb_open_device_with_vid_pid(context, target_vid, pid); + if (handle != NULL) { logger->info("Opened {:04x}:{:04x}", target_vid, pid); break; } + } + if (handle == NULL && target_pid != 0) + handle = libusb_open_device_with_vid_pid(context, target_vid, target_pid); + if (handle == NULL) { logger->error("No supported device"); libusb_exit(context); return 1; } + } + + if (libusb_kernel_driver_active(handle, 0)) + libusb_detach_kernel_driver(handle, 0); + if (termux_fd == 0 && !std::getenv("DEVOURER_SKIP_RESET")) + libusb_reset_device(handle); + rc = libusb_claim_interface(handle, 0); + assert(rc == 0); + + WiFiDriver wifi_driver{logger}; + auto rtlDevice = wifi_driver.CreateRtlDevice(handle); + + int channel = 6; + if (const char* ch = std::getenv("DEVOURER_CHANNEL")) channel = std::atoi(ch); + int tx_power = 40; + if (const char* p = std::getenv("DEVOURER_TX_POWER")) tx_power = std::atoi(p); + rtlDevice->SetTxPower(static_cast(tx_power)); + rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast(channel), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20}); + sleep(2); + + // Policy (DEVOURER_SVC_LADDER or the built-in default) + precomputed + // radiotaps (one per rung — built once, reused). + const svc::LayerPolicy policy = svc::policy_from_env(); + std::vector rt_crit = devourer::build_stream_radiotap(policy.critical); + std::vector> rt_tid; + for (const auto& m : policy.by_tid) + rt_tid.push_back(devourer::build_stream_radiotap(m)); + logger->info("SVC UEP policy: critical={}", mode_str(policy.critical)); + for (size_t i = 0; i < policy.by_tid.size(); ++i) + logger->info(" T{} -> {}", i, mode_str(policy.by_tid[i])); + + const auto dot11 = build_dot11_probe_req(); + + // Read all length-prefixed NALs up front. + std::vector> nals; + while (true) { + uint8_t lb[4]; + if (stream_stdin::read_exact(stdin, lb, 4) != stream_stdin::ReadResult::Ok) + break; + uint32_t len = lb[0] | (lb[1] << 8) | (lb[2] << 16) | ((uint32_t)lb[3] << 24); + if (len == 0 || len > 200000) break; + std::vector nal(len); + if (stream_stdin::read_exact(stdin, nal.data(), len) != + stream_stdin::ReadResult::Ok) + break; + nals.push_back(std::move(nal)); + } + if (nals.empty()) { logger->error("no NALs on stdin"); return 2; } + logger->info("SvcTxDemo: {} NALs, mtu={}, ch{} — looping", nals.size(), mtu, + channel); + + long sent[9] = {0}; // [0..7] per TID, [8] = critical + long frames = 0; + while (true) { + for (const auto& nal : nals) { + svc::NalInfo info = svc::parse_hevc_nal(nal.data(), nal.size()); + const std::vector& rt = + info.critical ? rt_crit + : rt_tid.empty() ? rt_crit + : rt_tid[info.tid < rt_tid.size() ? info.tid : rt_tid.size() - 1]; + sent[info.critical ? 8 : (info.tid > 7 ? 7 : info.tid)]++; + // Fragment the NAL to the radio MTU; every fragment carries the layer rate. + for (size_t off = 0; off < nal.size(); off += mtu) { + size_t n = std::min(mtu, nal.size() - off); + std::vector frame; + frame.reserve(rt.size() + dot11.size() + n); + frame.insert(frame.end(), rt.begin(), rt.end()); + frame.insert(frame.end(), dot11.begin(), dot11.end()); + frame.insert(frame.end(), nal.begin() + off, nal.begin() + off + n); + rtlDevice->send_packet(frame.data(), frame.size()); + if (gap_us > 0) + std::this_thread::sleep_for(std::chrono::microseconds(gap_us)); + } + if (++frames % 200 == 0) { + printf("frames=%ld crit=%ld T0=%ld T1=%ld T2=%ld T3+=%ld\n", frames, + sent[8], sent[0], sent[1], sent[2], + sent[3] + sent[4] + sent[5] + sent[6] + sent[7]); + fflush(stdout); + } + } + } + return 0; +} diff --git a/txdemo/svc_tx_demo/svc_tx.h b/txdemo/svc_tx_demo/svc_tx.h new file mode 100644 index 0000000..eb81000 --- /dev/null +++ b/txdemo/svc_tx_demo/svc_tx.h @@ -0,0 +1,120 @@ +// TID -> TxMode unequal-error-protection (UEP) shim for SVC-T video over +// devourer. Maps each HEVC NAL's temporal_id (and IRAP/parameter-set +// criticality) to a PHY TxMode, so the most important layers fly at the most +// robust rate and the enhancement layers ride the fast rate — a graceful +// degradation staircase instead of a single MCS cliff. +// +// App-level (no WiFiDriver core changes): consumes devourer::TxMode + +// build_stream_radiotap + RtlJaguarDevice::send_packet. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "RadiotapBuilder.h" // parse_tx_mode_str +#include "TxMode.h" + +namespace svc { + +// Per-temporal-layer PHY policy. by_tid[i] = mode for temporal layer i; TIDs +// past the end clamp to the last (least-protected) entry. `critical` overrides +// for frames you cannot lose: IRAP/IDR slices and parameter sets. +struct LayerPolicy { + std::vector by_tid; + devourer::TxMode critical; + + const devourer::TxMode& mode_for(uint8_t tid, bool is_critical) const { + if (is_critical || by_tid.empty()) return critical; + return by_tid[tid < by_tid.size() ? tid : by_tid.size() - 1]; + } +}; + +inline devourer::TxMode HT(uint8_t mcs, uint8_t bw = 20, bool ldpc = false, + bool stbc = false, bool sgi = false) { + devourer::TxMode m; + m.mode = devourer::TxMode::Mode::HT; + m.ht_mcs = mcs; + m.bw_mhz = bw; + m.ldpc = ldpc; + m.stbc = stbc; + m.sgi = sgi; + return m; +} + +// Default 3-temporal-layer ladder for an 8812AU long-range link. +// critical (IDR / VPS/SPS/PPS) : MCS0 20MHz LDPC STBC — max range +// T0 base : MCS1 20MHz LDPC STBC +// T1 (->30fps) : MCS4 20MHz +// T2 (->60fps) : MCS7 40MHz SGI — max throughput +inline LayerPolicy default_policy() { + LayerPolicy p; + p.critical = HT(/*mcs*/ 0, /*bw*/ 20, /*ldpc*/ true, /*stbc*/ true); + p.by_tid = { + HT(1, 20, /*ldpc*/ true, /*stbc*/ true), + HT(4, 20), + HT(7, 40, /*ldpc*/ false, /*stbc*/ false, /*sgi*/ true), + }; + return p; +} + +// HEVC NAL header is 2 bytes: +// nal_unit_type = (b0 >> 1) & 0x3F +// nuh_temporal_id_plus1 = b1 & 0x07 (TID = that - 1) +// Critical = IRAP slices (16..23, incl. IDR/CRA/BLA) or parameter sets +// (VPS=32 / SPS=33 / PPS=34) — losing any of these stalls the decoder. +struct NalInfo { + uint8_t tid = 0; + bool critical = false; + uint8_t type = 0; +}; + +inline NalInfo parse_hevc_nal(const uint8_t* nal, size_t len) { + NalInfo n; + if (len < 2) return n; // malformed -> treat as base layer + n.type = (nal[0] >> 1) & 0x3F; + int tid = static_cast(nal[1] & 0x07) - 1; + n.tid = tid < 0 ? 0 : static_cast(tid); + n.critical = (n.type >= 16 && n.type <= 23) || (n.type >= 32 && n.type <= 34); + return n; +} + +// Build a LayerPolicy from DEVOURER_SVC_LADDER, a ';'-separated list of +// "=" where is CRIT or T0/T1/T2/... and is a +// DEVOURER_TX_RATE token string (e.g. "MCS0/20/LDPC/STBC"). Tn rungs are +// ordered by index into by_tid. Unset -> default_policy(). +// DEVOURER_SVC_LADDER="CRIT=MCS0/20/LDPC/STBC;T0=MCS1/20/LDPC/STBC;T1=MCS4;T2=MCS7/40/SGI" +inline LayerPolicy policy_from_env() { + const char* raw = std::getenv("DEVOURER_SVC_LADDER"); + if (raw == nullptr || *raw == '\0') return default_policy(); + + LayerPolicy p; + p.critical = HT(0, 20, /*ldpc*/ true, /*stbc*/ true); // default if CRIT omitted + std::vector> tids; + const std::string s(raw); + size_t i = 0; + while (i < s.size()) { + size_t sc = s.find(';', i); + std::string tok = s.substr(i, sc == std::string::npos ? std::string::npos : sc - i); + i = (sc == std::string::npos) ? s.size() : sc + 1; + size_t eq = tok.find('='); + if (eq == std::string::npos) continue; + std::string key = tok.substr(0, eq), val = tok.substr(eq + 1); + devourer::TxMode m = devourer::parse_tx_mode_str(val); + if (key == "CRIT" || key == "crit") { + p.critical = m; + } else if (key.size() >= 2 && (key[0] == 'T' || key[0] == 't')) { + tids.emplace_back(std::atoi(key.c_str() + 1), m); + } + } + std::sort(tids.begin(), tids.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + for (const auto& tm : tids) p.by_tid.push_back(tm.second); + if (p.by_tid.empty()) return default_policy(); + return p; +} + +} // namespace svc