Мы написали свой кодек для lossless трансляции экрана на Rust

Я Артур Валиев, разработчик EVRT

Когда мы форкнули RustDesk и начали строить EvertyDesk — корпоративный удалённый рабочий стол с поддержкой Hyper-V, Proxmox, VirtualBox и умным агентом — первое, во что упёрлись, было видео.

H.264 — стандарт де-факто. TeamViewer, AnyDesk, Windows App — все используют H.264. Он работает. Но он lossy. И вот здесь начинается проблема, о которой в статьях про remote desktop обычно не говорят.

Когда оператор смотрит на терминал с git diff или читает трейс в VSCode — один размытый пиксель в букве это уже другой символ. H.264 при любом разумном битрейте вносит артефакты в области с резким контрастом. Точно туда, где у нас весь текст. Это неприемлемо.

H.264 в lossless режиме (QP=0, lossless profile) существует, но поддержка в декодерах — через пень-колоду, а для mostly-static контента предикторы рассчитанные под видео с движением дают нулевой выигрыш над тривиальным delta-кодером.

Нам не нужен motion estimation. Нам нужно: вот предыдущий кадр, вот текущий, вот список изменившихся тайлов, сожми только их.

Мы написали EVRTCK.

Wire format

Описывать протокол словами скучно. Вот пакет побайтово:

	Size	Field	Notes
0	4 B	magic	0x45 0x56 0x43 0x4B — ASCII «EVCK»
4	1 B	version	0x01 — единственная реализованная версия
5	1 B	flags	0x01 = KEYFRAME · 0x02 = NOP (кадр идентичен предыдущему)
6	4 B	frame_id	u32 LE, монотонно возрастающий
10	4 B	width	u32 LE, пикселей
14	4 B	height	u32 LE, пикселей
18	2 B	map_bytes	u16 LE = ceil(tile_count / 8)
20	N B	tile_map	битовый массив: бит i установлен если тайл i грязный
20+N	…	payloads	последовательно, только для грязных тайлов

Каждый payload начинается с байта mode. Три возможных значения:

SOLID	0x01	4 байта RGBA — весь тайл залит одним цветом
DELTA	0x02	u32 length + ZRLE-сжатый XOR-дельта
ZSTD	0x03	u32 length + zstd level-1 XOR-дельта

Тайл — 32×32 пикселя = 4096 байт в BGRA. На 1080p: 60×34 = 2040 тайлов. Tile map: ceil(2040/8) = 255 байт. Полный пакет для 0% dirty = 275 байт. При 15% dirty (308 грязных тайлов, пользователь печатает в терминале) — ~1.7 КБ. Против 7 864 320 байт raw BGRA.

Кодирование тайла: три режима

MODE_SOLID

Если все 1024 пикселя тайла одного цвета — фон, заливка, иконка — весь payload это 5 байт (mode + RGBA). Встречается значительно чаще, чем кажется: большинство UI-элементов монохромны по тайлам.

fn try_solid(tile: &[u8]) -> Option<[u8; 4]> {
    let mut chunks = tile.chunks_exact(4);
    let first = chunks.next()?;
    let color = [first[0], first[1], first[2], first[3]];
    for chunk in chunks {
        if chunk != color { return None; }
    }
    Some(color)
}

Для остальных тайлов — XOR-дельта между текущим и предыдущим кадром, затем компрессия. Выбор между ZRLE и zstd: если len(zrle) < raw_delta × 0.9 — ZRLE, иначе zstd level 1.

ZRLE эффективен когда большинство байт дельты — нули: небольшое изменение в текстовом курсоре, один символ в строке. Runs of zeros кодируются в 2 байта. zstd level 1 (~500 МБ/с throughput) выигрывает при высокой энтропии дельты — перемещение окна, частичный скролл.

Одна тонкость: prev хранится в BGRA (raw capture format от DXGI/GDI), а на wire отдаём RGBA. Конвертация — в момент сборки тайла в contiguous буфер, без отдельного прохода:

for y in y0..y1 {
    for x in x0..x1 {
        let off = (y * width + x) * 4;
        // BGRA → RGBA: swap индексов 0 ↔ 2, G и A не трогаем
        tile.push(rgba[off + 2]);       // R
        tile.push(rgba[off + 1]);       // G
        tile.push(rgba[off    ]);       // B
        tile.push(rgba[off + 3]);       // A
        tile_prev.push(prev[off + 2]);
        tile_prev.push(prev[off + 1]);
        tile_prev.push(prev[off    ]);
        tile_prev.push(prev[off + 3]);
    }
}

XOR после конвертации корректен: оба буфера в одном формате, операция XOR channel-независима.

Параллелизм через rayon

Тайлы независимы — идеально для data-parallel workload. Каждая задача читает только свои строки из cur и prev (shared read, никакой синхронизации), пишет в свой элемент результирующего вектора:

let tile_results: Vec<Option<(Vec<u8>, u8)>> = (0..tile_count)
    .into_par_iter()
    .map(|idx| {
        let tx = idx % tiles_x;
        let ty = idx / tiles_x;
        if tile_is_dirty(rgba, prev, width, height, tx, ty) {
            Some(encode_tile_buf(rgba, prev, width, height, tx, ty))
        } else {
            None
        }
    })
    .collect();

Ни одного Mutex, ни одного Arc. Rust ownership гарантирует корректность статически: rgba и prev — shared &[u8], доступные из любого потока; tile_results собирается из независимых Vec<u8>.

Keyframe throughput: 89 МиБ/с на 720p, 91 МиБ/с на 1080p, 91 МиБ/с на 4K. Три разных разрешения, одна цифра — кодек упёрся в memory bandwidth, а не в вычисления. Rayon максимально использует все ядра, дальнейшая оптимизация на CPU невозможна без смены алгоритма.

IDR, смена кодека и неочевидный баг

Параллельно с EVRTCK живёт H.264 через Media Foundation (Windows) и NVENC (если есть NVIDIA). Переключение — на основе dirty ratio и типа контента. Для видеопотока внутри браузера или анимаций EVRTCK проигрывает: он lossless, у него нет motion compensation, и при 90% dirty он шлёт все грязные тайлы целиком.

Один из первых неочевидных багов: при переключении H.264 → EVRTCK клиент получал первый P-frame EVRTCK и применял его к нулевому буферу (свежеинициализированный prev). Визуальный мусор на несколько секунд до следующего IDR.

Очевидное решение — IDR при переключении. Неочевидность: переключение двунаправленное. При возврате EVRTCK → H.264 H.264 декодер на клиенте тоже должен получить IDR, иначе он применяет P-frame дельты к неправильному опорному кадру.

// Недостаточно: только H264→EVRTCK
if evrt_on && !was_evrt_on { force_recovery_key = true; }

// Правильно: оба направления
if evrt_on != was_evrt_on {
    force_recovery_key = true;
}
was_evrt_on = evrt_on;

IDR_MIN_EVRTCK = 10 секунд против IDR_MIN_H264 = 1200 мс. EVRTCK может позволить себе редкие keyframe-ы: P-frame даже после 10 секунд изменений на UI-контенте весит несколько килобайт. H.264 нуждается в частых IDR для recovery при потере пакетов.

NOP frame: честно об оптимизации

Самый распространённый сценарий в enterprise remote desktop: пользователь читает документ, ждёт деплоя, смотрит в Confluence. Кадры идентичны. До оптимизации:

Rayon запускал 2040 задач на 1080p. Каждая читала строки тайла с stride-ed доступом через 7680 байт (ширина строки 1920×4 байта). Ни одна не находила изменений. Итого: 3.17 мс на холостом ходу при 30 fps.

Решение выглядело элегантно: один rgba == prev перед rayon-циклом. Rust компилирует сравнение слайсов в вызов memcmp, который на x86-64 реализован через AVX2 и сравнивает 32 байта за такт. При bandwidth ~50 ГБ/с это должно было занять ~0.15 мс.

// Быстрый путь: кадр идентичен предыдущему
if !is_keyframe && rgba == prev {
    let mut out = Vec::with_capacity(20);
    out.extend_from_slice(MAGIC);
    out.push(VERSION);
    out.push(FLAG_NOP);              // 0x02
    out.extend_from_slice(&frame_id.to_le_bytes());
    out.extend_from_slice(&(width as u32).to_le_bytes());
    out.extend_from_slice(&(height as u32).to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes()); // map_bytes = 0
    return (out, FrameStats::default());
}

// Criterion: 2.93 мс. Ускорение — 8%.

Почему не 20×. rgba == prev — это memcmp двух буферов по 8.29 МБ каждый. Оба больше L3 кэша. Оба загружаются из RAM: 16.58 МБ при реальном DDR4 bandwidth ~5–6 ГБ/с = ~2.8–3 мс. Rayon раньше читал ровно те же 16.58 МБ, только через strided-доступ с overhead на 2040 задач — вот эти ~0.24 мс мы и сэкономили.

Настоящая победа другая: пакет уменьшился с 275 байт до 20 байт. Декодер при FLAG_NOP читает header, возвращает prev как есть — буквально ноль работы. При 30 fps на статичном экране:

До 61 Кбит/с 30 × 275 байт/кадр × 8 После 5 Кбит/с 30 × 20 байт/кадр × 8

Benchmark: Criterion, реальные числа

Все замеры: Criterion 0.5, 100 сэмплов, opt-level=3 lto=thin, Windows 11 x86-64, CPU backend (GPU Phase 2 в разработке).

Сценарий

Время

Throughput

Keyframe 720p

39.4 мс

89.3 МиБ/с

Keyframe 1080p

86.7 мс

91.2 МиБ/с

Keyframe 4K

344.6 мс

91.8 МиБ/с

P-frame 0% dirty — NOP path

2.93 мс

2.63 ГиБ/с

P-frame 5% dirty — движение курсора

3.17 мс

2.44 ГиБ/с

P-frame 15% dirty — печать в терминале

3.34 мс

2.31 ГиБ/с

P-frame 50% dirty — скролл в браузере

4.18 мс

1.85 ГиБ/с

P-frame 90% dirty — видео в браузере

5.37 мс

1.44 ГиБ/с

Roundtrip encode+decode 1080p 15%

3.99 мс

1.94 ГиБ/с

30 fps бюджет = 33.3 мс. Даже при 90% dirty мы в 6.2× запасе. Интересно что разница между 0% и 5% dirty — почти ноль: overhead rayon и tile scan на 1755 чистых тайлах доминирует над кодированием 102 грязных.

Wire size (синтетические фреймы с solid-color тайлами — реальный контент будет больше, порядки те же):

Dirty

Encoded

vs 7.91 МиБ raw

0%

20 Б

393 216:1

5%

779 Б

10 648:1

15%

1.7 КБ

4 642:1

50%

5.2 КБ

1 551:1

90%

9.2 КБ

877:1

Где мы слабы — честно

Dirty detection завязан на CPU scanWindows Desktop Duplication API (IDXGIOutputDuplication::AcquireNextFrame) возвращает dirty rectangles как метаданные к захваченному кадру — бесплатно, без сканирования. Используй мы их, tile_is_dirty не нужен вообще. Это Phase 2: WGPU-бэкенд уже написан и инициализирует GPU-буферы при старте, но пока работает на CPU fallback.

Нет протокола рассинхронизацииЕсли TCP-буфер переполнился и пакет потерялся — prev у энкодера и декодера расходятся навсегда до следующего IDR (до 10 секунд). Нужен: frame hash в каждом пакете и out-of-band механизм запроса IDR декодером при mismatch. Сейчас этого нет.

TILE_SIZE = 32 захардкоженДля 4K разумнее 64 или 128: tile_map overhead и per-tile spawn cost при 2040 тайлах перевешивают прирост от точного dirty detection. Для мобильных клиентов (Android, 720p) — возможно 16.

Rayon overhead при sparse dirtyПри < 50 грязных тайлах sequential encode быстрее чем spawn 2040 rayon-задач. Adaptive threshold: если dirty_count < 50 — sequential, иначе — rayon. Не реализовано.

90% dirty → лучше IDR чем P-frameПри высоком dirty_ratio overhead на tile_map, per-tile headers и 2040 rayon-задач делает keyframe эффективнее. Конкретный порог нигде не специфицирован и не измерен.

Абстрактные цифры encode-time ничего не говорят сетевому инженеру. Вот что уходит в провод при 30 fps и 60 fps — по сценариям:

Сценарий

Encode

Размер пакета

@ 30 fps

@ 60 fps

Idle — статичный экран

2.93 мс

20 Б

5 Кбит/с

10 Кбит/с

Мигание курсора, статус-бар

3.17 мс

779 Б

187 Кбит/с

373 Кбит/с

Печать в терминале (15% dirty)

3.34 мс

1.7 КБ

408 Кбит/с

816 Кбит/с

Скролл в браузере (50% dirty)

4.18 мс

5.2 КБ

1.2 Мбит/с

2.4 Мбит/с

Видео в браузере (90% dirty)

5.37 мс

9.2 КБ

2.2 Мбит/с

4.4 Мбит/с

Raw BGRA 1080p — без кодека

7.86 МБ

1 887 Мбит/с

3 774 Мбит/с

При типичном корпоративном сценарии — оператор читает логи, иногда переключает вкладки — реальный трафик не выходит за 200–400 Кбит/с. Это меньше, чем голосовой звонок в Telegram.

Единственный сценарий где EVRTCK проигрывает H.264 — видео в браузере при 60 fps: 4.4 Мбит/с против ~2 Мбит/с у lossy H.264. Там мы переключаемся на H.264 автоматически по dirty_ratio. Текст и UI — наша территория.

Latency budget: 33.3 мс на 30 fps

Этап

Время

Источник

DXGI capture (IDXGIOutputDuplication)

~1 мс

Windows API overhead

EVRTCK encode — idle (NOP path)

2.93 мс

Criterion

EVRTCK encode — typing 15% dirty

3.34 мс

Criterion, 1080p

EVRTCK encode — scroll 50% dirty

4.18 мс

Criterion, 1080p

EVRTCK decode на клиенте

0.65 мс

roundtrip 3.99 − encode 3.34

Сеть LAN 1 Гбит (408 Кбит/с)

< 1 мс

Сеть WAN Москва–СПб VPN

~15 мс

типичный RTT

Итого LAN, typing

~6 мс из 33.3 мс

5.2× запас

Итого WAN Москва–СПб, typing

~21 мс из 33.3 мс

1.6× запас

Encode занимает 10–18% frame budget даже в худшем типичном случае. Ботлнек у нас никогда не кодек — всегда сеть.

EVRTCK против протоколов конкурентов

Протокол

Lossless текст

Idle трафик

Typing трафик

RFB / VNC (ZRLE)

~0 Кбит/с

~800 Кбит/с

RDP / RemoteFX (H.264)

✗ lossy

50–200 Кбит/с

1–3 Мбит/с

H.264 lossless (QP=0)

200–500 Кбит/с

2–5 Мбит/с

EVRTCK

5 Кбит/с

408 Кбит/с

RFB/VNC — ближайший родственник по архитектуре. Разница: VNC не делает XOR-дельту на уровне пикселя, не использует zstd, и нет MODE_SOLID. При изменении одного символа в терминале VNC шлёт весь изменившийся прямоугольник. EVRTCK шлёт тайл с XOR-дельтой где ненулевых байт максимум 100.

Encode по разрешениям: полная таблица

P-frame бенчмарки запускались на 1080p. Для других разрешений — линейная экстраполяция по числу тайлов (числа bandwidth-bound, масштабирование линейное):

Разрешение

Тайлов

Keyframe

Idle NOP

Typing 15%

Scroll 50%

@ 30 fps typing

720p

1 035

39.4 мс

2.93 мс

~1.7 мс

~2.1 мс

~208 Кбит/с

1080p

2 040

86.7 мс

2.93 мс

3.34 мс

4.18 мс

408 Кбит/с

1440p

3 240

~138 мс

~2.93 мс

~4.2 мс

~5.3 мс

~648 Кбит/с

4K

8 160

344.6 мс

~2.93 мс

~8.4 мс

~10.5 мс

~1.6 Мбит/с

Keyframe на 4K — 344 мс — единственная цифра, которая выглядит страшно. Но keyframe случается раз в 10 секунд. 344 мс / 10 000 мс = 3.4% overhead к среднему encode time. На практике незаметно.

NOP latency не масштабируется с разрешением — это всегда memcmp (bandwidth-bound) плюс 20-байтный write. 720p даёт тот же 2.93 мс: оба буфера всё равно вылетают из L3.

Где это работает

EVRTCK — не proof of concept. Это production кодек в EvertyDesk: корпоративный удалённый рабочий стол для российского Enterprise на основе форка RustDesk.

Фича

Статус

EVRTCK lossless для text/UI

Production

Автопереключение EVRTCK ↔ H.264 по dirty_ratio

Production

Smart Agent — heartbeat, push-конфиг, уведомления

Production

Hyper-V / Proxmox / VirtualBox управление

Production

GPU backend (WGPU)

Beta

DXGI dirty rects Phase 2

Roadmap Q3 2026

Исходники EVRTCK выйдут с публичной бетой. Попробовать уже сейчас — desk.everty.ru.

H.264 — отличный кодек для видео. Для экрана, где 90% времени неподвижный текст и пиксель-в-пиксель точность критична — он избыточен в одном месте и недостаточен в другом. EVRTCK решает ровно ту задачу, которую нужно решить, и не решает то, что решать не нужно.

33 строки wire format. Три режима кодирования тайла. 20 байт на статичный кадр. 408 Кбит/с на печать в терминале. 16 unit-тестов, все зелёные.

Если у вас есть вопросы по реализации или собственные цифры на сравнимой задаче — пишите в комментарии.

Комментарии (18)


  1. n0isy
    15.07.2026 01:09

    Где мы слабы — честно

    Привет, Клод. Вы слабы в глобальной оценке, как и все ИИ на данный момент. НЕЛЬЗЯ менять аппаратный кодек на софтверный. НЕЛЬЗЯ делать кодек, без исследования существующих (а их хватает).
    Да. Агенту бывает проще переписать с нуля, чем сделать WebSearch tool call.

    Нет. Человеку НЕЛЬЗЯ на это соглашаться по-умолчанию.

    все зелёные

    ...

    Хабы: Open source

    да? а где?


    1. vaalimusic Автор
      15.07.2026 01:09

      Честно не понял ничего


      1. insecto
        15.07.2026 01:09

        ну засунь тот коммент обратно в чат, он всё объяснит разжуёт


        1. vaalimusic Автор
          15.07.2026 01:09

          Не нужно острить пожалуйста, я иду всем на встречу и пытаюсь отвечать на вопросы.


      1. Dmitri-D
        15.07.2026 01:09

        Работа проделана большая и в исследовательском плане и на практике. Снимаю шляпу.
        Но в 21м веке предпочтительнее иметь дело с аппаратными ускорителями - хотя бы чтобы разгрузить CPU для чего-то полезного. От того что Rayon загрузит его по самое горло для ускорения работы кодека - пользователю продукта легче не будет. Ему ведь не просто открытый desktop нужен с быстрым обменом картинок, а что-то в нем еще и делать желательно и желательно иметь околонулевую загрузку CPU чтобы это, чем пользователь занимается (ну кроме конфлюэнса) работало так же быстро как и без remote десктопа.


  1. Lezvix
    15.07.2026 01:09

    Чем вас AV1 Screen Content Coding или HEVC Screen Content Coding не устроили, оба имеют аппаратную поддержку, оба могут копировать данные без изменений из предыдущего кадра?


    1. vaalimusic Автор
      15.07.2026 01:09

      IBC в AV1/HEVC SCC дублят графику из текущего кадра, а не из предыдущего. Копирование из прошлых кадров это обычный Inter-прогноз с квантованием. В продакшене это не работает, нет железа, нет предсказуемости.
      v1/HEVC кодируют вероятностно и могут пережать статику. Наш EVRTCK дает жесткую гарантию: неизменный тайл весит ровно 1 бит.


  1. CitizenOfDreams
    15.07.2026 01:09

    Интересно, а какое сжатие использовал pcAnywhere, который показывал десктоп через модем 14400 на процессоре Pentium 100 с 16 мегабайтами памяти? И могут ли повторить это современные программисты - хоть с Клодом, хоть без?


    1. vaalimusic Автор
      15.07.2026 01:09

      Честно это крутой вопрос, попробую ответить. pcAnywher использавал RLE поверх dirty rectangles с принудительным снижением глубины цвета до 256 или 16. Никакого delta на уровне пикселя, просто арямоугольник изменился, вот он целиком, зажатый RLE. На 640х480×8 бит это работало потому что выбора не было: 14400 бод = 1440 байт/с, полный кадр = 307 КБ= 213 секунд. Значит слать можно было только то, что изменилось за один keystroke. И они слали.

      Теперь математика сегодня. EVRTCK на статичном экране 20 байт на кадр. 160 бит. 14400-модем пропускает это за 11 мс. На idle-сценарии мы лучше pcAnywhere, потому что FLAG_NOP не передаёт вообще ничего осмысленного декодер просто возвращает предыдущий кадр.

      Разница не в том что современные программисты разучились писать эффективный код. Разница в том что Pentium 100 обслуживал 640×480 в 256 цветах без GPU-compositing, без прозрачности, без HDR, без 4K-экранов с 60 fps. Современный рабочий стол сложнее физически: один кадр 4K BGRA — 33 МБ. pcAnywhere с тем же алгоритмом на том же железе на 4K просто не запустился бы.

      На вопрос "могут ли повторить" да, могут. Вот повторили: 20 байт на статичный кадр, 408 Кбит/с при печати в терминале. На 14400-модеме это работало бы при условии что вы не двигаете окна. Ровно как в 1995.


      1. vaalimusic Автор
        15.07.2026 01:09

        ровно как в 1995. с одной поправкой, тогда это писали за полгода на ассемблере с осциллографом рядом. Сейчас за несколько недель на Rust с Criterion вместо осциллографа. Насчёт с Клодом или без. Не скрываю. Но 20 байт на кадр от этого не становятся 21. Инструмент меняет скорость, не физику.


        1. n0isy
          15.07.2026 01:09

          Дорогой Клод, у меня возник вопрос (не умею обходить бинарное дерево) : а как же у тебя отработается scrool 50% ? Старый кадр будет переиспользован на 50% ? Просто перемещения одних кусочков данных на экране с места на место - это основа всех текущих видеокодеков.
          Я не вижу у Вас с хозяином всех этих хоффманов/сдвигов. Lossless же здесь не является ответом на мой вопрос (это всё будет работать и в Lossless)


          1. vaalimusic Автор
            15.07.2026 01:09

            Стёб? По клоду, да, польуземся. По вопросу отвечу кратко хор-дельта при сдвиге контента не разрежена, motion vector здесь выиграл бы на порядок. Но это вы ли хотели услышать?


      1. CitizenOfDreams
        15.07.2026 01:09

        Разница в том что Pentium 100 обслуживал 640×480 в 256 цветах без GPU-compositing, без прозрачности, без HDR, без 4K-экранов с 60 fps.

        Когда я подключаюсь к удаленному экрану, чтобы, скажем, видеть силу сигнала антенны, которую сейчас кручу гаечным ключом с приставной лестницы - меня вполне устроит 256 цветов без прозрачности. И 60 кадров в секунду мне не нужно, достаточно даже одного - но гарантированного, без лагов и фризов.

        640х480 - это опять же камешек в огород программистов. На экране 640x480 у меня когда-то отлично помещалась панель управления Windows - даже не на полном экране, а в окошке. А теперь ту же панель управления на мониторе 4k приходится скроллить.


        1. vaalimusic Автор
          15.07.2026 01:09

          Спасибо за комментарий, хотя бы что не "облили" меня. В целом Вы правы, это ведь не революция, но очень изящное рещение.


  1. ColdPhoenix
    15.07.2026 01:09

    Если TCP-буфер переполнился и пакет потерялся

    То у вас будет задержка приема(лаг), а не потеря кадра.

    или все же вы про UDP, где потери пакетов возможны?


    1. vaalimusic Автор
      15.07.2026 01:09

      спасибо за замечание. Но тхп пакет не теряется, переполнение буфера даёт backpressure, отправитель блокируется, клиент получает лаг, но все пакеты в итоге доходят в порядке. Desync на чистом TCP возможен в другом сценарии: обрв соединения и переподключение. Новый TCP-стрим decoder сбросил прев в нули, encoder этого не знает и продолжает слать П фрейм дельты к старому prev. До следующего IDR (10 секунд) _ мусор.

      Это и есть реальная проблема, которую я описал неточно. Про UDP - да, если переидем на QUIC (а это в планах), потери пакетов станут настоящей проблемой и frame hash + запрос IDR нужны уже не как теоретическая страховка, а как обязательный элемент протокола, будем копать.


    1. vaalimusic Автор
      15.07.2026 01:09

      Я не правильно написал про потерю пакетов применительно к TCP. Спасибо, что поправили. Что происходит на самом деле, TCP не теряет пакеты, он ритрансмитит но когда буфер переполняется, растёт RTT дальше backpressure и на стороне приложения очередь кадров начинает пухнуть. Чтобы не накапливать задержку, мы явно дропаем старые кадры из очереди на уровне приложения, в целом UDP у нас тоже есть (EVRT умеет оба транспорта), и замечу у EVRT сумасшедшая скорость, и там потери пакетов настоящие без ретрансмита. Но это отдельная тема.