diff --git a/CLAUDE.md b/CLAUDE.md index bb5dd42..f533abc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,11 +90,14 @@ before running `tools/extract_*.py` or the hardware-testing kernel cells. Per-chip options, all default ON: `DEVOURER_JAGUAR1`, `DEVOURER_8814` (requires JAGUAR1), `DEVOURER_JAGUAR2_8822B`, `DEVOURER_JAGUAR2_8821C`, -`DEVOURER_JAGUAR3_8822C`, `DEVOURER_JAGUAR3_8822E`. `DEVOURER_PCIE` (default +`DEVOURER_JAGUAR3_8822C`, `DEVOURER_JAGUAR3_8822E`, `DEVOURER_KESTREL_8852B`, +`DEVOURER_KESTREL_8852C`. `DEVOURER_PCIE` (default OFF, Linux-only, requires JAGUAR2_8821C) adds the vfio-pci transport + `pcieprobe`; OFF builds are byte-identical to before it existed. Turning groups off drops -their firmware blobs + PHY tables (an 8812AU-only `rxdemo` is ~1.0 MB vs -~2.6 MB). Configure fails on no-chip-selected or 8814-without-JAGUAR1. Each +their firmware blobs + PHY tables (an 8812AU-only `rxdemo` is ~1.6 MB vs +~6.3 MB all-on; ~4.2 MB with only the two Kestrel dies dropped — their +verbatim-vendored halbb/halrf plane dominates). Configure fails on +no-chip-selected or 8814-without-JAGUAR1. Each group exports a PUBLIC `DEVOURER_HAVE_*` define; sites referencing a dropped group sit behind `#if defined(DEVOURER_HAVE_*)`, and the factory returns `nullptr` (logs) for a chip whose support isn't built. diff --git a/README.md b/README.md index 8048285..857d61a 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,11 @@ long-range digital video links. ## Why devourer - **No kernel driver, no driver hell.** Everything runs in your process via - libusb. The same code works on Linux, macOS, Windows, and Android (Termux) - — including platforms the vendor drivers never supported. + libusb — on Linux, macOS, Windows and Android alike, including platforms the + vendor drivers never supported. On Android that means no root and no custom + kernel: [PixelPilot](https://github.com/OpenIPC/PixelPilot) uses devourer as + the receive path of an FPV ground station running on an ordinary phone, with + the adapter opened straight from the USB permission the app is granted. - **Faster on-air than the kernel driver.** Ready-to-receive and ready-to-transmit come up quicker than the vendor `.ko` on every supported chip, and raw injection skips the kernel networking stack the vendor driver @@ -36,6 +39,20 @@ long-range digital video links. from the schedule by authenticated agreement between the two ends, and revisited later by keyed probes in case it recovers ([how](docs/fhss.md)). +- **Wi-Fi 6, on the same API.** The 802.11ax parts (RTL8852BU/8852CU) run one + HAL over both dies, with HE injection, 160 MHz on the 8852C and 6 GHz on the + 8832CU. Including the standard's long-range corner — **HE ER SU + DCM**, worth + roughly 8–10 dB stacked, and the only extended-range lever that works against + *someone else's* 802.11ax gear ([how](docs/he-extended-range.md)). Trigger + frames air correctly for scheduled-uplink work, though the hardware-timed + response needs AP firmware these parts don't ship + ([what closes, what doesn't](docs/he-trigger-ul.md)). +- **A link that changes channel on evidence, not on a timer.** Beside per-slot + hopping there's the slow lever: a second adapter surveys candidates while the + video keeps flowing, delivery on the live channel decides when a move is + worth it, and the two ends migrate under an authenticated + ground-proposes/drone-commits protocol that cannot split-brain + ([how](docs/adaptive-channel-migration.md)). - **Narrowband modes the kernel can't do.** 5 and 10 MHz channels on every supported generation — including the decade-old RTL8812AU and RTL8814AU the vendor never gave narrowband — half/quarter the bandwidth, more range from @@ -61,7 +78,7 @@ long-range digital video links. magic inside the library. New to low-level RF? Start with the [visual RF primer](docs/rf-primer.md) — -eight short animations that make the rest click. Its sibling, the +fifteen short animations that make the rest click. Its sibling, the [visual driver primer](docs/driver-primer.md), does the same for the silicon and vendor-driver vocabulary (firmware, efuse, DMAC/CMAC, halbb/halrf, IQK…). @@ -156,6 +173,9 @@ the `env:` tags in [`src/DeviceConfig.h`](src/DeviceConfig.h). | `svctx` | per-video-layer rate ladders (unequal error protection) | | `txpower` | runtime TX-power API walkthrough | | `tdma` | TSF-slotted burst TDMA (narrowband ↔ wide on one channel) | +| `chanmig` / `chanscout` | evidence-driven channel migration: the protocol, and the survey adapter that feeds it | +| `dwelltx` | dwell-1 hopping data plane on the standard Linux driver | +| `kestrelprobe` | Wi-Fi 6 (RTL8852B/C) bring-up probe, layer by layer | | `timesync` | over-the-air clock distribution (master / slave / UE roles) | | `sense` | Wi-Fi motion sensing from beamforming reports | | `doctor` | adapter-health triage → HEALTHY / SUSPECT / FAILING | @@ -164,9 +184,12 @@ the `env:` tags in [`src/DeviceConfig.h`](src/DeviceConfig.h). All chips compile in by default; per-chip CMake options (`DEVOURER_JAGUAR1`, `DEVOURER_8814`, `DEVOURER_JAGUAR2_8822B`, `DEVOURER_JAGUAR2_8821C`, -`DEVOURER_JAGUAR3_8822C`, `DEVOURER_JAGUAR3_8822E`) drop unneeded firmware -and tables — an 8812AU-only `rxdemo` is ~1.0 MB versus ~2.6 MB with -everything on. +`DEVOURER_JAGUAR3_8822C`, `DEVOURER_JAGUAR3_8822E`, `DEVOURER_KESTREL_8852B`, +`DEVOURER_KESTREL_8852C`) drop unneeded firmware and tables — an 8812AU-only +`rxdemo` is ~1.6 MB against ~6.3 MB with everything on, and dropping just the +two Wi-Fi 6 dies takes it to ~4.2 MB (their verbatim-vendored halbb/halrf plane +is the single largest contributor). `DEVOURER_PCIE` (default OFF, Linux-only) +adds the vfio-pci transport for the RTL8821CE. ## Using the library @@ -205,13 +228,35 @@ one `IRtlDevice` interface covers all four generations. ## Going deeper -**Primers** — start here: +**Start here.** There is a lot below; this is the order that works. Read the +[visual RF primer](docs/rf-primer.md) first — fifteen animations covering the +concepts every other doc assumes (subcarriers, EVM, AGC, hopping, OFDMA, +extended range). Then pick the one thing you came for: building a video link → +[adaptive link](docs/adaptive-link.md); surviving interference → +[FHSS](docs/fhss.md); getting more range → +[narrowband](docs/narrowband.md); coordinating several radios → +[time distribution](docs/time-distribution.md); making the driver itself do +something new → [visual driver primer](docs/driver-primer.md), then +[logging](docs/logging.md) for the event schema every test script reads. If a +chip is misbehaving, skip to [adapter doctor](docs/adapter-doctor.md) and the +per-chip quirks notes at the bottom. + +**Primers:** - [Visual RF primer](docs/rf-primer.md) — animated intro to the concepts behind everything below. - [Visual driver primer](docs/driver-primer.md) — animated intro to the chip and vendor-driver machinery: registers, efuse, firmware, MAC, PHY tables, - calibration, coexistence. + calibration, coexistence, firmware offload. + +**Wi-Fi 6 (802.11ax):** + +- [HE extended range](docs/he-extended-range.md) — the ER SU / DCM range + ladder, what each rung buys and costs, and the on-air matrix across both + dies. +- [HE trigger-based uplink](docs/he-trigger-ul.md) — Trigger frames, resource + units, TWT and sounding: the API, and an honest account of which paths the + shipped client firmware executes and which it silently drops. **Link engineering:** @@ -249,6 +294,14 @@ one `IRtlDevice` interface covers all four generations. one that decides, why a transmitter may argue only that a move leaves *it* worse off rather than that it disagrees, and what stops an adversary who can make channels look bad from herding the link onto one it then jams. +- [Adaptive channel migration](docs/adaptive-channel-migration.md) — the slow + counterpart to hopping: a scout adapter surveying candidates while the video + keeps flowing, a scoring engine where the receiver's delivery is + authoritative, and a gate that only moves a working link on evidence. The + [wire protocol](docs/channel-migration-protocol.md) is the authenticated + ground-proposes/drone-commits exchange, and + [its validation](docs/channel-migration-validation.md) is the failure matrix + every row of which converges without split-brain. - [Narrowband](docs/narrowband.md) — 5/10 MHz channels across all three generations: the baseband re-clock, the per-chip register machinery, and the walls (RF re-latch edges, per-die clock coupling, the 5 MHz/5 GHz CFO limit). @@ -296,6 +349,8 @@ one `IRtlDevice` interface covers all four generations. - [8822E quirks](docs/8822e-quirks.md) — the RTL8812EU/8822EU definitive quirks list: what the chip needs, what devourer does, the reproducers. +- [8852C quirks](docs/8852c-quirks.md) — the same for the Wi-Fi 6 die, + including which sensing facilities are 2.4 GHz-only and why. - [Logging](docs/logging.md) — the two-plane output schema: JSONL machine events on stdout, human diagnostics on stderr. diff --git a/docs/driver-primer.md b/docs/driver-primer.md index dcefb0e..a9f480d 100644 --- a/docs/driver-primer.md +++ b/docs/driver-primer.md @@ -7,7 +7,7 @@ H2C, DIG. If you've ever opened a Realtek vendor tree and bounced off ten thousand files of alphabet soup, start here. Its sibling, the [RF primer](rf-primer.md), shows what the *radio physics* looks like — subcarriers, constellations, gain control. Like the RF primer this is a -picture book: thirteen short animations in the DEVOURER live-monitor style, +picture book: fourteen short animations in the DEVOURER live-monitor style, ordered from the silicon up. Read it top to bottom and both the vendor trees and devourer's `src/` will read like prose. @@ -352,7 +352,40 @@ receiver, so the chips run an external handshake bus and another arbitration table (`mac_ax/coex.c` carries the mailbox). Different radios, same shape: shared spectrum, negotiated time slices. -## 14. Reading a vendor tree +## 14. Offload — when the host stops driving + +![Firmware IO-offload — who walks the registers](img/fw_offload.gif) + +Section 6 introduced H2C as a *message* pipe. It is also a *speed* technique, +and this is the one that turns a slow operation into a fast one without any new +silicon. + +Take a channel switch. Stripped to essentials it is a short list of register +writes — the synthesizer command, a baseband reset, the power target. Done the +ordinary way, each of those writes is a USB vendor-control transfer, and the +host pays a full bus round-trip per write while the radio waits. The registers +are cheap; the **bus** is what costs. So the fast path stops sending the writes +one at a time and instead sends the *list*: one H2C carrying the whole +write-list, replayed by the on-chip CPU locally, with nothing crossing the bus +in between. On the 8822B the vendor firmware has a dedicated command for +exactly this (H2C 0x1D), and the same shape appears again as a general +masked-write buffer on Wi-Fi 6 — and again at init time, where the long +bring-up register tables ride the same mechanism. + +The measured effect, for one channel switch: a full re-initialisation costs +~65 ms, a host-driven fast retune ~2.5 ms, and the firmware command 1.03 ms of +median dead air. On Kestrel the host-side cost of a hop collapses from ~9 ms to +~0.15 ms as twenty round-trips become one bulk-OUT. + +And then the honest part, which is the reason this section exists at all: +**offload frees the host, not the radio.** The synthesizer still has to settle, +and that floor — about 1.5 ms on Kestrel — does not care who wrote the +registers. Time-to-usable improves from ~5 ms to ~1.7 ms, not to zero. Offload +buys back exactly the portion of the dead time that was the bus's fault, and +not one microsecond more. Whenever a mechanism looks like it deleted a cost, +check which part of the cost it was actually holding. + +## 15. Reading a vendor tree Now the payoff — the same concepts, found in each generation's source layout. The architecture visibly evolves: a per-chip monolith, then a monolith with an @@ -459,6 +492,12 @@ find each in the vendor trees and in devourer. - [`narrowband.md`](narrowband.md) and [`frequency-hopping.md`](frequency-hopping.md) — what devourer builds on top of §10's channel machinery. +- [`experiments/kernel-channel-switch-offload.md`](experiments/kernel-channel-switch-offload.md) + — §14's offload isolated and benchmarked against the kernel driver, and + [`experiments/mcc-fcs-investigation.md`](experiments/mcc-fcs-investigation.md) + for the neighbouring firmware feature that was measured and *rejected*. +- [`8852c-quirks.md`](8852c-quirks.md) — the same density as the 8822E notes, + for the Wi-Fi 6 die. - [`la-capture.md`](la-capture.md) — using a BB debug block to capture raw IQ: §2's register plane pointed at §1's signal path. - [`performance-tuning.md`](performance-tuning.md) — where the URB plumbing of §2 diff --git a/docs/img/fw_offload.gif b/docs/img/fw_offload.gif new file mode 100644 index 0000000..9feac2d Binary files /dev/null and b/docs/img/fw_offload.gif differ diff --git a/docs/img/he_extended_range.gif b/docs/img/he_extended_range.gif new file mode 100644 index 0000000..0485d01 Binary files /dev/null and b/docs/img/he_extended_range.gif differ diff --git a/docs/img/he_ofdma_trigger.gif b/docs/img/he_ofdma_trigger.gif new file mode 100644 index 0000000..e42449f Binary files /dev/null and b/docs/img/he_ofdma_trigger.gif differ diff --git a/docs/img/ldpc_waterfall.gif b/docs/img/ldpc_waterfall.gif new file mode 100644 index 0000000..39257a2 Binary files /dev/null and b/docs/img/ldpc_waterfall.gif differ diff --git a/docs/rf-primer.md b/docs/rf-primer.md index 15f73ab..e983c0e 100644 --- a/docs/rf-primer.md +++ b/docs/rf-primer.md @@ -3,10 +3,11 @@ devourer talks to a Wi-Fi radio at a very low level — subcarriers, constellations, gain control, the transmit and receive chains. If you're new to that machinery, the terms in the other docs (per-tone SNR, EVM, CCA, AGC, occupied bandwidth) can -feel like jargon. This page is a picture book: twelve short animations, each +feel like jargon. This page is a picture book: fifteen short animations, each built in the DEVOURER live-monitor style, that show what the machinery actually -looks like — from a single subcarrier all the way to a hopping, diversity-combined, -bandwidth-hopping link. Read it top to bottom and the rest of the docs will click. +looks like — from a single subcarrier all the way to a hopping, +diversity-combined, multi-user link. Read it top to bottom and the rest of the +docs will click. Everything here is grounded in what devourer measures — the constellation noise follows the textbook AWGN model, the spectrum levels are from a real USRP B210 @@ -57,6 +58,25 @@ OFDM symbol, given a **cyclic-prefix** guard (the tail copied to the front, so echoes off walls don't smear the next symbol), and finally up-converted and radiated. That last waveform is exactly what the spectrum analyzer below shows. +![LDPC coding gain](img/ldpc_waterfall.gif) + +The second stage of that line — the **code** — is worth a picture of its own, +because it is the one you get to choose per frame. 802.11 offers two: the older +convolutional code (BCC) and **LDPC**. Sweep the transmit power down until the +link falls over and plot delivery against power, and both curves drop off a +cliff — that shape is why it's called a **waterfall**. The stronger code's cliff +simply sits further left: at the 10 %-delivery crossing, about 3 dB further at +MCS7/20 MHz on the bench. Three decibels of nothing but arithmetic — no extra +power, no antenna, no lower rate. + +The catch is the other end. Coding only pays if the receiver decodes it, and on +this hardware that is not uniform: the RTL8821A misses VHT-LDPC (HT is fine), +and the RTL8814A decodes both but exposes no per-frame indicator, so you can't +*see* it in the receive telemetry even though it worked. That table is +bench-derived and deliberately not the vendor driver's advertised policy, which +claims none of the 11ac parts can decode LDPC at all — a claim the 8812A +disproves on air. Ask the device (`GetAdapterCaps`) rather than the datasheet. + ## 4. On the air — a bare tone vs a modulated carrier ![CW tone vs modulated spectrum](img/spectrum_compare.gif) @@ -251,6 +271,73 @@ per-chip mechanics, and bench tables are in [`time-distribution.md`](time-distribution.md); the closed discipline loop is a runnable tool (`tests/pcie_ptp_beacon.cpp`). +## 11. Many stations in one channel — OFDMA and the Trigger frame + +![802.11ax Trigger frames — built, aired, read back](img/he_ofdma_trigger.gif) + +Everything so far has assumed one transmitter at a time: stations contend, one +wins, it gets the whole channel for the length of its frame. 802.11ax breaks +that assumption in the frequency direction. The comb from section 1 is carved +into **resource units** — 26, 52, 106 or 242 tones — and several stations can +transmit *simultaneously*, each inside its own slice of the same 20 MHz. + +Sharing like that only works if somebody assigns the slices, so 11ax moves +uplink scheduling into the MAC. The AP sends a **Trigger** frame whose per-user +fields each name a station, the resource unit it may use, an MCS, and a target +receive power. A compliant station that finds its identifier in a Trigger +answers exactly one **SIFS** later — about 16 µs — inside the granted slice. +Nobody contends, nobody backs off, and the uplink jitter that plagues ordinary +Wi-Fi disappears. This is the standard's road to cellular-style scheduled +access, and it's why a Wi-Fi 6 radio is interesting for a coordinated link and +not just a faster one. + +The animation is what that machinery looks like from this side of it. You write +a grant table — who, which resource unit, what MCS, how many spatial streams, +what receive power to aim for — devourer builds the Trigger frame around it and +transmits it on the Kestrel generation, and an independent monitor pulls every +one of those parameters back off the air unchanged. Command in, frame out, +parameters verified: that round trip is what makes the adapter useful as an +**instrument** for 11ax work. You can put an arbitrary, exactly-specified +Trigger on the air and watch what other equipment does with it — which is +otherwise gear you rent. + +One boundary comes with it. The *reply* is hardware-timed: the receiving MAC +has to arm a receive window at trigger + SIFS, and it only does that for a +Trigger its own firmware scheduled. Frames injected from the host ride the +ordinary transmit path, so a station gets a perfectly valid Trigger and no +timing cue to answer it — and the shipped client firmware accepts the +scheduling commands (trigger scheduler, TWT, sounding) without executing them. +Generating the schedule is available; owning it needs firmware these parts +don't carry. [`he-trigger-ul.md`](he-trigger-ul.md) has the API and the full +account of which paths close. + +## 12. Reaching further — extended range and dual-carrier modulation + +![HE extended range — the range ladder](img/he_extended_range.gif) + +The same generation has a corner built for the opposite goal. **HE ER SU** is a +20 MHz-only format that stacks three tricks, each worth roughly 3 dB and each +paid for in rate. + +The first is acquisition. Before a receiver can decode anything it has to +*find* the frame, and at long range the preamble fails before the payload does; +ER SU repeats the signalling field so the preamble survives a weaker signal. +The second is concentration: the 106-tone variant puts the same transmit power +into half as many tones, so every tone that is still lit gets twice as much. +The third is **DCM** — dual-carrier modulation — which sends every symbol twice +on two tones far enough apart that a narrow fade can't take both copies. Notice +which kind of gain each one is: the first two help you acquire, the last one +helps you decode, and only the last one costs payload rate. + +Fully stacked at the lowest rate, the ladder buys roughly 8–10 dB over a plain +HE frame. Compare that with the **narrowband** modes of section 9: re-clocking +to 5 or 10 MHz is a bigger and more predictable win, about 3 dB per halving of +the noise bandwidth, but it is *private* — both ends have to be running +devourer. ER SU is smaller and weighted toward acquisition, and it +interoperates with any 802.11ax device. Devourer at both ends? Use narrowband. +Standard gear at the far end? This is the lever you have. +[`he-extended-range.md`](he-extended-range.md) has the on-air matrix. + --- ## Where to go next @@ -269,6 +356,10 @@ With the machinery in hand, the rest reads straight: - [`adaptive-link-building-blocks.md`](adaptive-link-building-blocks.md) — the levers, sensors, and probes that turn all of the above into an adaptive link, and [`adaptive-link.md`](adaptive-link.md) — the objective they serve. +- [`he-trigger-ul.md`](he-trigger-ul.md) and + [`he-extended-range.md`](he-extended-range.md) — the 802.11ax half from + sections 11 and 12: Trigger frames and the scheduling surface, the ER SU / + DCM range ladder and its measured on-air matrix. - [`narrowband.md`](narrowband.md) — the 5/10 MHz re-clock machinery, the cheap bandwidth switch, and the burst-TDMA example from section 9. - [`time-distribution.md`](time-distribution.md) — the full time-sync machinery diff --git a/tools/fw_offload_gif.py b/tools/fw_offload_gif.py new file mode 100644 index 0000000..d862892 --- /dev/null +++ b/tools/fw_offload_gif.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Animated firmware IO-offload — 'who walks the registers', in the DEVOURER +live-monitor style. + + tools/fw_offload_gif.py -o docs/img/fw_offload.gif + +Section 6 of the driver primer introduces H2C as a message pipe. This is the +same pipe used as a *speed* technique. Changing channel means writing a short +list of registers. Done the ordinary way each write is a USB vendor-control +transfer, and the host pays a bus round-trip per write — the registers are +cheap, the bus is not. Done as an offload, the host sends one H2C carrying the +whole list and the chip's own CPU replays it locally, with nothing crossing the +bus in between. + +The honest half: this frees the host, not the radio. The synthesizer still has +to settle, and that floor does not move no matter who wrote the registers. +Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +NW = 10 # register writes the channel switch needs +ACT = 34 # frames to play one lane through + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="fw_offload.gif") + ap.add_argument("--hold", type=int, default=20) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + padL, padT = 104, 100 + gw = 548 + hA = padT + 24 + cA = hA + 80 + hB = cA + 74 + cB = hB + 80 + panelW = 300 + W = padL + gw + 30 + panelW + H = cB + 92 + bandH = 26 + + def wx(i, span=0.62, start=0.05): + return padL + gw * (start + span * (i / max(1, NW - 1))) + + imgs = [] + for fi in range(ACT + args.hold): + prog = min(1.0, (fi + 1) / ACT) + cut = padL + gw * prog + img, d = new_frame(W, H) + chrome(d, W, H, "FIRMWARE IO-OFFLOAD", + "the registers are cheap; the bus is not — so send the list " + "once and let the chip replay it", fi) + + for (hy, cy, title, tcol) in ( + (hA, cA, "HOST DRIVES — one USB round-trip per register", AMBER), + (hB, cB, "OFFLOADED — one H2C carries the whole write list", OK)): + d.text((padL, hy - 32), title, font=font(11), fill=tcol) + # the thin bar above HOST is how long the host itself is occupied + d.text((padL - 54, hy - 15), "busy", font=font(10), fill=(70, 84, 104)) + for by, lbl, col in ((hy, "HOST", CYAN), (cy, "CHIP", DIM)): + d.rectangle([padL, by, padL + gw, by + bandH], outline=(0, 70, 80)) + d.text((padL - 54, by + 6), lbl, font=font(10), fill=col) + # the bus, drawn as the space the arrows have to cross + d.text((padL - 54, (hy + cy) / 2 - 4), "bus", font=font(10), + fill=(70, 84, 104)) + d.line([padL, (hy + cy) / 2 + bandH / 2, padL + gw, + (hy + cy) / 2 + bandH / 2], fill=GRID) + + # ---- lane A: a round trip per write -------------------------------- + busy_to = padL + for i in range(NW): + x = wx(i) + if x > cut: + break + busy_to = max(busy_to, x + 16) + d.rectangle([x - 5, hA + 4, x + 5, hA + bandH - 4], fill=AMBER) + d.line([x, hA + bandH, x, cA], fill=(120, 100, 40)) + d.rectangle([x - 4, cA + 6, x + 4, cA + bandH - 6], fill=(120, 100, 40)) + d.line([x + 9, cA, x + 9, hA + bandH], fill=(70, 60, 30)) + if busy_to > padL: + bar_end = min(busy_to, cut) + d.rectangle([padL + 2, hA - 9, bar_end, hA - 5], fill=AMBER) + + + # ---- lane B: one message, then a local replay ---------------------- + h2c_x0, h2c_x1 = wx(0) - 8, wx(0) + 54 + if cut > h2c_x0: + x1 = min(h2c_x1, cut) + d.rectangle([h2c_x0, hB + 4, x1, hB + bandH - 4], fill=OK) + if x1 - h2c_x0 > 40: + d.text((h2c_x0 + 5, hB + 7), "H2C", font=font(11, True), + fill=(8, 11, 18)) + d.rectangle([padL + 2, hB - 9, x1, hB - 5], fill=OK) + if cut > h2c_x1: + d.line([h2c_x1 - 6, hB + bandH, h2c_x1 - 6, cB], fill=(40, 140, 96)) + for i in range(NW): # the same writes, replayed inside the chip + x = h2c_x1 + 8 + i * 13 + if x > cut or x > padL + gw - 10: + break + d.rectangle([x, cB + 6, x + 7, cB + bandH - 6], fill=OK) + if prog > 0.6: + d.text((h2c_x1 + 8, cB + bandH + 6), + "replayed on-chip — nothing crosses the bus", + font=font(10), fill=OK) + + + # ---- the floor that offload cannot move ---------------------------- + if prog > 0.72: + fx = padL + gw * 0.84 # the floor applies to both lanes + d.rectangle([fx, hA - 12, padL + gw, cB + bandH + 2], + outline=(90, 78, 30)) + hx = fx + 5 + while hx < padL + gw: + d.line([hx, hA - 12, hx - 8, cB + bandH + 2], fill=(52, 46, 26)) + hx += 14 + tx0 = padL + gw * 0.30 + d.text((tx0, cB + bandH + 22), "and then the synthesizer still has " + "to settle — same for both", font=font(10), fill=AMBER) + d.line([tx0 + 320, cB + bandH + 26, fx, cB + bandH + 26], + fill=(90, 78, 30)) + + # ---- readout ------------------------------------------------------- + x0 = padL + gw + 28 + d.text((x0, padT - 26), "MEASURED — 8822B CHANNEL SWITCH", font=font(12), + fill=CYAN) + y = padT + for lbl, val, col in ( + ("full re-init", "~65 ms", WARN), + ("host fast retune", "~2.5 ms", AMBER), + ("firmware H2C 0x1D", "1.03 ms", OK)): + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 152, y - 3), val, font=font(14, True), fill=col) + y += 28 + d.text((x0, y), "median dead air, on-air", font=font(10), + fill=(70, 84, 104)) + y += 30 + + d.text((x0, y), "SAME LEVER, KESTREL", font=font(12), fill=CYAN) + y += 24 + for lbl, val, col in (("host cost", "9 ms → 0.15 ms", OK), + ("time to usable", "~5 ms → ~1.7 ms", AMBER), + ("settle floor", "~1.5 ms", WARN)): + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 152, y - 3), val, font=font(12, True), fill=col) + y += 26 + y += 8 + for ln in ("twenty round-trips collapse", "into one bulk-OUT — but the", + "radio is unmoved. offload buys", "host time, and only the part", + "of the dead air that was the", "bus's fault."): + d.text((x0, y), ln, font=font(10), fill=INK) + y += 14 + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/he_extended_range_gif.py b/tools/he_extended_range_gif.py new file mode 100644 index 0000000..09044a3 --- /dev/null +++ b/tools/he_extended_range_gif.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Animated HE extended range — 'the range ladder', in the DEVOURER +live-monitor style. + + tools/he_extended_range_gif.py -o docs/img/he_extended_range.gif + +802.11ax has a low-rate corner built for reach, and it stacks in three steps. +ER SU repeats the signalling field so the preamble is acquired at lower SNR; +the 106-tone variant pours the same transmit power into half as many tones; and +DCM sends every symbol twice, on two tones far enough apart that one narrow +fade cannot take both copies. Each step is worth roughly 3 dB, and each is paid +for in rate. + +The figure climbs the ladder one rung at a time: the tone comb changes shape, +the link-budget bar grows, the rate bar shrinks, and the last act shows a fade +eating one DCM copy while the other survives. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +NB = 48 # tone groups drawn across the 20 MHz channel +FADE = (26, 31) # the narrow fade that lands in the last act + +# name, cumulative dB, relative rate, constraint +LADDER = [ + ("HE SU MCS0", 0.0, 1.00, "the baseline — full 242-tone RU"), + ("+ ER 242-tone", 3.0, 1.00, "MCS 0-2, NSS 1"), + ("+ ER 106-tone", 6.0, 0.50, "MCS 0, NSS 1"), + ("+ DCM", 9.0, 0.25, "MCS 0/1; excludes STBC"), +] +ACT = 15 # frames per rung + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="he_extended_range.gif") + ap.add_argument("--hold", type=int, default=18) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + padL, padT = 96, 96 + gw = 600 + combY, combH = padT + 26, 96 + barY = combY + combH + 62 + panelW = 236 + W = padL + gw + 24 + panelW + H = barY + 128 + bw = gw / NB + + imgs = [] + total = len(LADDER) * ACT + for fi in range(total + args.hold): + k = min(len(LADDER) - 1, fi // ACT) + sub = min(1.0, (fi - k * ACT + 1) / ACT) if fi < total else 1.0 + name, gain, rate, constraint = LADDER[k] + er106 = k >= 2 + dcm = k >= 3 + img, d = new_frame(W, H) + chrome(d, W, H, "802.11ax — EXTENDED RANGE", + "three stackable steps that trade rate for reach, and " + "interoperate with any 802.11ax device", fi) + + # ---- the tone comb ------------------------------------------------- + d.text((padL, combY - 20), "the channel's tones — where the power goes", + font=font(11), fill=DIM) + d.line([padL, combY + combH, padL + gw, combY + combH], fill=(0, 70, 80)) + for i in range(NB): + x = padL + i * bw + active = (i >= NB // 2) if er106 else True + faded = dcm and FADE[0] <= i <= FADE[1] + if not active: + d.rectangle([x + 1, combY + combH - 6, x + bw - 1, combY + combH], + fill=(24, 30, 40)) + continue + # concentrating the same power into half the tones raises each one + hgt = combH * (0.80 if er106 else 0.46) + col = CYAN + if faded: + hgt *= 0.12 + col = WARN + d.rectangle([x + 1, combY + combH - hgt, x + bw - 1, combY + combH], + fill=col) + if er106: + d.text((padL + 6, combY + combH - 16), "no power here", + font=font(10), fill=(70, 84, 104)) + d.text((padL + gw * 0.42, combY + combH + 6), + "upper half only — twice the power per tone", + font=font(10), fill=OK) + + # DCM: link the tone pairs that carry the same bits + if dcm: + q = NB // 8 + for i in range(NB // 2, NB // 2 + q): + if i + q >= NB: + break + xa = padL + i * bw + bw / 2 + xb = padL + (i + q) * bw + bw / 2 + d.line([xa, combY - 2, xa, combY - 8], fill=AMBER) + d.line([xb, combY - 2, xb, combY - 8], fill=AMBER) + d.line([xa, combY - 8, xb, combY - 8], fill=AMBER) + d.text((padL + gw / 2 + 6, combY - 34), + "same bits on two tones, far apart", font=font(10), fill=AMBER) + if FADE[0] >= NB // 2: + d.text((padL + gw * 0.42, combY + combH + 22), + "a narrow fade takes one copy — the other decodes", + font=font(10), fill=WARN) + + # ---- link budget and rate ----------------------------------------- + def bar(y, label, frac, col, value): + d.text((padL - 74, y + 2), label, font=font(10), fill=DIM) + d.rectangle([padL, y, padL + gw * 0.62, y + 20], outline=(0, 70, 80)) + d.rectangle([padL + 1, y + 1, padL + 1 + (gw * 0.62 - 2) * frac, + y + 19], fill=col) + d.text((padL + gw * 0.62 + 12, y + 2), value, font=font(12, True), + fill=col) + + shown = gain - 3.0 * (1 - sub) if k else 0.0 + bar(barY, "link budget", max(0.0, shown) / 12.0, OK, f"+{shown:.0f} dB") + bar(barY + 34, "rate", rate, AMBER if rate < 1 else CYAN, + f"x{rate:.2f}".replace("x0.25", "x1/4").replace("x0.50", "x1/2") + .replace("x1.00", "x1")) + + # the ladder itself, rungs lit as we climb + ly = barY + 74 + for i, (n, g, _, _) in enumerate(LADDER): + on = i <= k + d.text((padL + i * 152, ly), ("● " if on else "○ ") + n, + font=font(11, True) if i == k else font(11), + fill=OK if i == k else (INK if on else (60, 72, 90))) + + # ---- readout ------------------------------------------------------- + x0 = padL + gw + 22 + d.text((x0, padT - 2), "LIVE READOUT", font=font(12), fill=CYAN) + y = padT + 24 + + def line(lbl, val, c=INK): + nonlocal y + d.text((x0, y), lbl, font=font(11), fill=DIM) + d.text((x0 + 96, y - 3), val, font=font(14, True), fill=c) + y += 28 + + line("mode", name.replace("+ ", ""), OK if k else INK) + line("vs HE SU", f"+{gain:.0f} dB" if gain else "—", OK if gain else DIM) + line("rate", "x1" if rate == 1 else ("x1/2" if rate == 0.5 else "x1/4"), + AMBER if rate < 1 else INK) + line("bandwidth", "20 MHz", DIM) + y += 4 + d.text((x0, y), constraint, font=font(10), fill=DIM) + y += 22 + + caps = ( + ("the starting point: one", "full-width 20 MHz RU,", "BPSK, nothing stacked."), + ("ER SU repeats the", "signalling field, so the", "preamble survives a", + "weaker signal. rate is", "unchanged."), + ("the 106-tone variant", "spends the same power", "on half the tones —", + "each one gets twice as", "much."), + ("DCM sends every symbol", "twice, far apart in", "frequency. it is the", + "only rung that pays in", "rate for payload, not", "preamble, gain."), + ) + for ln in caps[k]: + d.text((x0, y), ln, font=font(11), fill=INK) + y += 15 + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/he_ofdma_trigger_gif.py b/tools/he_ofdma_trigger_gif.py new file mode 100644 index 0000000..c41b5c3 --- /dev/null +++ b/tools/he_ofdma_trigger_gif.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Animated 802.11ax Trigger frames — 'built, aired, read back', in the DEVOURER +live-monitor style. + + tools/he_ofdma_trigger_gif.py -o docs/img/he_ofdma_trigger.gif + +What devourer does with 802.11ax uplink scheduling, exactly: it composes a +Trigger frame from a per-user grant table — who, which resource unit, what MCS, +how many spatial streams, what receive power to aim for — puts it on the air, +and an independent monitor decodes it back with every commanded parameter +intact. That round trip is the validated capability, and it makes the adapter a +usable instrument for 11ax work: arbitrary, exactly-specified Trigger frames on +demand. + +Three columns, because that is what the measurement is: what was asked for, what +the frame actually looks like, and what came back off the air. The footer states +the boundary in one line — the frame is exact, but the hardware-timed reply +belongs to firmware these client parts do not ship — without spending half the +canvas miming it. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# who, AID, which resource unit, MCS, spatial streams, target RSSI +USERS = [ + ("UE-A", 1, "52-tone #1", "MCS4", 1, -60), + ("UE-B", 2, "52-tone #2", "MCS2", 1, -62), + ("UE-C", 3, "52-tone #3", "MCS6", 1, -58), +] +FIELDS = [ + ("frame control", "0x24 — Trigger", AMBER), + ("duration", "", DIM), + ("RA", "broadcast", DIM), + ("TA", "our MAC", DIM), + ("common info", "BW · GI/LTF · AP power", CYAN), + ("user info", "UE-A · RU · MCS · NSS · RSSI", CYAN), + ("user info", "UE-B · RU · MCS · NSS · RSSI", CYAN), + ("user info", "UE-C · RU · MCS · NSS · RSSI", CYAN), + ("FCS", "", DIM), +] + +P1, P2, P3 = 10, 30, 50 # compose / assemble + air / decode + + +def check(d, x, y, col): + d.line([x, y + 5, x + 4, y + 9], fill=col, width=2) + d.line([x + 4, y + 9, x + 11, y], fill=col, width=2) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="he_ofdma_trigger.gif") + ap.add_argument("--hold", type=int, default=20) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + padT = 106 + ax, aw = 46, 252 + bx, bw = 330, 300 + cx, cw = 672, 268 + W = cx + cw + 34 + H = 476 + + imgs = [] + for fi in range(P3 + args.hold): + n_cmd = max(0, min(len(USERS) + 1, (fi * (len(USERS) + 1)) // P1)) + n_fld = 0 if fi < P1 else min(len(FIELDS), + ((fi - P1) * len(FIELDS)) // (P2 - P1)) + n_dec = 0 if fi < P2 else min(len(USERS) + 1, + ((fi - P2) * (len(USERS) + 1)) // (P3 - P2)) + aired = fi >= P2 + img, d = new_frame(W, H) + chrome(d, W, H, "802.11ax TRIGGER FRAMES — BUILT, AIRED, READ BACK", + "arbitrary per-user grants composed on the host, transmitted, " + "and decoded off the air unchanged", fi) + + def col_head(x, w, text, colr): + d.text((x, padT - 26), text, font=font(12), fill=colr) + d.line([x, padT - 8, x + w, padT - 8], fill=(0, 70, 80)) + + # ---- what was asked for -------------------------------------------- + col_head(ax, aw, "COMMANDED — the grant table", CYAN) + y = padT + 4 + if n_cmd > 0: + d.text((ax, y), "AP TX power", font=font(11), fill=DIM) + d.text((ax + 148, y), "20 dBm", font=font(11, True), fill=INK) + y += 26 + for i, (who, aid, ru, mcs, nss, rssi) in enumerate(USERS): + if i + 1 >= n_cmd: + break + d.rectangle([ax, y, ax + aw, y + 52], outline=(0, 60, 72)) + d.text((ax + 8, y + 6), who, font=font(12, True), fill=CYAN) + d.text((ax + 62, y + 8), f"AID {aid}", font=font(10), fill=DIM) + d.text((ax + 8, y + 24), f"RU {ru}", font=font(10), fill=INK) + d.text((ax + 8, y + 37), f"{mcs} · NSS {nss} · target {rssi} dBm", + font=font(10), fill=INK) + y += 58 + + # ---- the frame that carries it ------------------------------------- + col_head(bx, bw, "THE FRAME ON AIR", OK if aired else AMBER) + y = padT + 4 + for i, (name, note, colr) in enumerate(FIELDS): + if i >= n_fld: + break + d.rectangle([bx, y, bx + bw, y + 20], outline=(0, 60, 72), + fill=(16, 22, 30) if colr is DIM else None) + d.text((bx + 8, y + 3), name, font=font(10), fill=colr) + if note: + d.text((bx + 106, y + 3), note, font=font(10), fill=DIM) + y += 23 + if n_fld >= len(FIELDS): + y += 6 + d.text((bx, y), "the channel's slices, as granted", font=font(10), + fill=DIM) + y += 16 + slot = bw / 4 + for i in range(4): + sx = bx + i * slot + on = i < len(USERS) + d.rectangle([sx + 1, y, sx + slot - 2, y + 26], + fill=CYAN if on else (24, 30, 40)) + d.text((sx + 10, y + 7), USERS[i][0] if on else "—", + font=font(11, True), fill=(8, 11, 18) if on else DIM) + y += 34 + if aired: + d.text((bx, y), "transmitted on the ordinary management path", + font=font(10), fill=OK) + + # ---- what came back -------------------------------------------------- + col_head(cx, cw, "DECODED — independent monitor", OK if n_dec else DIM) + y = padT + 4 + if n_dec > 0: + d.text((cx, y), "AP TX power", font=font(11), fill=DIM) + d.text((cx + 130, y), "20 dBm", font=font(11, True), fill=INK) + check(d, cx + 214, y + 1, OK) + y += 26 + for i, (who, aid, ru, mcs, nss, rssi) in enumerate(USERS): + if i + 1 >= n_dec: + break + d.rectangle([cx, y, cx + cw, y + 52], outline=(0, 60, 72)) + d.text((cx + 8, y + 6), who, font=font(12, True), fill=OK) + d.text((cx + 62, y + 8), f"AID {aid}", font=font(10), fill=DIM) + d.text((cx + 8, y + 24), f"RU {ru}", font=font(10), fill=INK) + d.text((cx + 8, y + 37), f"{mcs} · NSS {nss} · target {rssi} dBm", + font=font(10), fill=INK) + check(d, cx + cw - 26, y + 20, OK) + y += 58 + + if n_dec > len(USERS): + d.rectangle([cx, y + 6, cx + cw, y + 38], outline=OK, width=2) + d.ellipse([cx + 10, y + 16, cx + 20, y + 26], fill=OK) + d.text((cx + 30, y + 12), "EVERY PARAMETER MATCHES", + font=font(12, True), fill=OK) + + # ---- the one line that keeps it honest ------------------------------- + if n_dec > len(USERS): + d.line([ax, H - 54, W - 34, H - 54], fill=(0, 55, 66)) + d.text((ax, H - 44), "the frame is exact. the hardware-timed reply " + "is not ours to schedule — only a firmware-scheduled trigger " + "arms the receiving", font=font(10), fill=DIM) + d.text((ax, H - 30), "MAC's SIFS window, and the shipped client " + "firmware does not air one.", font=font(10), fill=DIM) + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ldpc_waterfall_gif.py b/tools/ldpc_waterfall_gif.py new file mode 100644 index 0000000..adbb4c4 --- /dev/null +++ b/tools/ldpc_waterfall_gif.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Animated LDPC coding gain — 'what the error-correcting code buys', in the +DEVOURER live-monitor style. + + tools/ldpc_waterfall_gif.py -o docs/img/ldpc_waterfall.gif + +Section 3 of the RF primer walks a block of bits through the transmit pipeline +and mentions forward error correction in passing. This is the payoff: sweep the +transmit power down until the link falls over, and plot delivery against power +for the two codes 802.11 offers. Both curves fall off a cliff — that shape is +why it is called a waterfall — but the stronger code's cliff sits to the left. +The horizontal distance between them, read at the 10 %-delivery crossing, is the +coding gain: about 3 dB at MCS7 / 20 MHz on the bench. + +The second half is the part that decides whether you can use it: LDPC only pays +if the receiver decodes it, and on this hardware that is not uniform. The truth +table is bench-derived (src/AdapterCaps.h), deliberately not the vendor driver's +interop-advertisement policy, which claims none of the 11ac parts can. + +The curve SHAPE here is illustrative; the measured quantity is the gap, and the +figure says so on its face. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, GRID, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +STEPS = 32 # TX power indices swept, 0.25 dB apart -> 8 dB of travel +GAIN_STEPS = 12 # 12 quarter-dB steps = the ~3 dB the bench recorded +BCC_MID, SHARP = 20.0, 2.2 + +# chip, HT-LDPC, VHT-LDPC, per-frame flag (src/AdapterCaps.h + the HAL tables) +TRUTH = [ + ("RTL8812A / 8811A", True, True, True), + ("RTL8814A", True, True, False), + ("RTL8821A", True, False, True), + ("RTL8822B / 8821C", True, True, True), + ("RTL8822C / 8822E", True, True, True), +] + + +def delivery(p, mid): + return 1.0 / (1.0 + math.exp(-(p - mid) / SHARP)) + + +def crossing(mid, frac=0.10): + """The power index where the curve passes `frac` delivery.""" + return mid + SHARP * math.log(frac / (1.0 - frac)) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="ldpc_waterfall.gif") + ap.add_argument("--hold", type=int, default=18) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + padL, padT = 92, 100 + pw, ph = 500, 212 + panelW = 316 + W = padL + pw + 32 + panelW + H = padT + ph + 128 + ldpc_mid = BCC_MID - GAIN_STEPS + + def px(p): + return padL + (p / STEPS) * pw + + def py(v): + return padT + ph - v * ph + + DRAW, MARK = 30, 12 # frames to draw the curves / show the gap + total = DRAW + MARK + len(TRUTH) * 4 + + imgs = [] + for fi in range(total + args.hold): + drawn = min(1.0, (fi + 1) / DRAW) + marked = fi >= DRAW + rows = max(0, (fi - DRAW - MARK) // 4 + 1) if fi >= DRAW + MARK else 0 + img, d = new_frame(W, H) + chrome(d, W, H, "LDPC CODING GAIN", + "sweep the power down until the link breaks — the stronger code " + "breaks later", fi) + + # axes + d.rectangle([padL, padT, padL + pw, padT + ph], outline=(0, 70, 80)) + for v, lbl in ((1.0, "100%"), (0.5, "50%"), (0.10, "10%"), (0.0, "0")): + yy = py(v) + d.line([padL, yy, padL + pw, yy], + fill=(60, 50, 30) if v == 0.10 else GRID) + d.text((padL - 42, yy - 7), lbl, font=font(10), + fill=AMBER if v == 0.10 else DIM) + d.text((padL - 78, padT + ph / 2 - 24), "d\ne\nl\ni\nv\ne\nr\ny", + font=font(10), fill=DIM) + d.text((padL, padT + ph + 10), + "TX power index (one step = 0.25 dB) →", font=font(11), fill=DIM) + + # the two waterfalls + for mid, col, tag in ((BCC_MID, AMBER, "BCC"), (ldpc_mid, CYAN, "LDPC")): + pts = [] + n = int(STEPS * drawn * 4) + for j in range(max(2, n)): + p = j / 4.0 + pts.append((px(p), py(delivery(p, mid)))) + if len(pts) > 1: + d.line(pts, fill=col, width=3) + if drawn > 0.55: + d.text((px(mid) + 10, py(delivery(mid, mid)) - 20), tag, + font=font(12, True), fill=col) + + # the gap at the 10% crossing — the thing that was actually measured + if marked: + cb, cl = crossing(BCC_MID), crossing(ldpc_mid) + yy = py(0.10) + for c, col in ((cb, AMBER), (cl, CYAN)): + d.line([px(c), yy - 9, px(c), yy + 9], fill=col, width=2) + d.line([px(cl), yy - 22, px(cb), yy - 22], fill=INK) + for c, dx in ((cl, 5), (cb, -5)): + d.line([px(c), yy - 22, px(c) + dx, yy - 26], fill=INK) + d.line([px(c), yy - 22, px(c) + dx, yy - 18], fill=INK) + mid_x = (px(cl) + px(cb)) / 2 + d.rectangle([mid_x - 30, yy - 41, mid_x + 30, yy - 25], fill=(8, 11, 18)) + d.text((mid_x - 26, yy - 40), "~3 dB", font=font(13, True), fill=INK) + + d.text((padL, padT + ph + 30), + "curve shape illustrative — the measured quantity is this gap, " + "at MCS7 / 20 MHz", font=font(10), fill=(70, 84, 104)) + d.text((padL, padT + ph + 46), + "harness: tests/ldpc_waterfall.sh", font=font(10), fill=(70, 84, 104)) + + # ---- who can actually decode it ------------------------------------ + x0 = padL + pw + 30 + d.text((x0, padT - 26), "…IF THE RECEIVER DECODES IT", font=font(12), + fill=CYAN) + d.text((x0 + 168, padT - 4), "HT", font=font(10), fill=DIM) + d.text((x0 + 204, padT - 4), "VHT", font=font(10), fill=DIM) + d.text((x0 + 248, padT - 4), "flag", font=font(10), fill=DIM) + y = padT + 16 + for i, (chip, ht, vht, flag) in enumerate(TRUTH): + if i >= rows: + break + d.text((x0, y), chip, font=font(11), fill=INK) + for dx, ok in ((172, ht), (210, vht), (254, flag)): + col = OK if ok else WARN + d.ellipse([x0 + dx, y + 2, x0 + dx + 10, y + 12], fill=col) + y += 24 + if rows >= len(TRUTH): + y += 6 + for ln in ("green = the baseband decodes it.", + "the 8821A misses VHT-LDPC only —", + "HT is fine. the 8814A decodes", + "both but reports no per-frame", + "flag, so you cannot see it in", + "the receive telemetry.", + "", + "bench-derived, not the vendor", + "driver's advertised policy —", + "which claims none of these can."): + d.text((x0, y), ln, font=font(10), fill=DIM if ln.startswith( + ("bench", "which", "driver")) else INK) + y += 14 + + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=48) + return 0 + + +if __name__ == "__main__": + sys.exit(main())