diff --git a/README.md b/README.md index 320789e..79d73c0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ 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. +eight 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…). ## Supported hardware @@ -183,6 +185,9 @@ one `IRtlDevice` interface covers all three generations. - [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. - [Adaptive link](docs/adaptive-link.md) — the energy-minimizing video-link controller design, and [its validation](docs/adaptive-link-validation.md). - [Fused FEC](docs/fused-fec.md) — the cross-layer error-protection stack: diff --git a/docs/8822e-quirks.md b/docs/8822e-quirks.md index 96afdb2..7b41b32 100644 --- a/docs/8822e-quirks.md +++ b/docs/8822e-quirks.md @@ -1,5 +1,8 @@ # RTL8822E (RTL8812EU / RTL8822EU) — definitive quirks +New to the vocabulary used here (DMAC/CMAC, IQK, DIG, coex, efuse…)? The +[visual driver primer](driver-primer.md) defines all of it with animations. + The rtl8822e family (WiFi-only **RTL8812EU** `0bda:a81a`, BT-combo **RTL8822EU** `0bda:e822`, chip-id `0x17`, Jaguar3) as devourer drives it today. Every entry states what the chip needs, what devourer does about it, the residual cost if diff --git a/docs/driver-primer.md b/docs/driver-primer.md new file mode 100644 index 0000000..92e09ce --- /dev/null +++ b/docs/driver-primer.md @@ -0,0 +1,466 @@ +# A visual primer on the driver machinery + +The [RF primer](rf-primer.md) shows what the *radio physics* looks like — +subcarriers, constellations, gain control. This page is its sibling for the +*silicon and software*: what is actually inside a Realtek Wi-Fi chip, and what the +vendor driver vocabulary means — firmware, efuse, FWDL, DMAC/CMAC, halbb/halrf, +LSSI, IQK, H2C, DIG. If you've ever opened a Realtek vendor tree and +bounced off ten thousand files of alphabet soup, this is the decoder ring. Like the +RF primer it's a picture book: thirteen 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. + +Everything here is grounded in the vendor reference drivers vendored under +`reference/` (`rtl8812au` → `rtl88x2bu`/`rtl88x2cu`/`rtl88x2eu`), in Realtek's +Wi-Fi 6 "G6" trees of the same lineage (`rtl8852bu` and kin), and in devourer's +own re-implementations of the same machinery (`src/jaguar1..3`), so every +concept has code you can go read. + +## 1. Anatomy of a Wi-Fi chip + +![Chip anatomy](img/chip_anatomy.gif) + +A USB Wi-Fi dongle is one chip with five machines inside, chained like an assembly +line. The **USB interface** talks to your host. The **MAC** (medium access +controller) owns frames — queues, ACKs, timers, filtering — and embeds a small +CPU, the **WCPU**, that runs the chip's own **firmware** (§5). The **BB** +(baseband) is the digital half of the PHY: the OFDM modem from the +[RF primer §3](rf-primer.md) cast in silicon — FFTs, equalizers, AGC. The **RF** +section is the analog half: a frequency **synthesizer**, mixers, and analog +gain stages that move the baseband signal to channel frequency. Finally the front +end: a **PA** (power amplifier) that gives the TX signal its final watts and an +**LNA** (low-noise amplifier) that gives the faint RX signal its first clean boost — +often external parts, which is why boards with the same chip differ (§3). + +One packaging detail earns its keep later: the digital machinery and the analog +machinery are frequently separate silicon **dies** in one package — the **D-die** +(digital: MAC, BB) and the **A-die** (analog: RF). On the Wi-Fi 6 parts the split is +visible to software: some RF registers live on each die and are reached by different +paths (§9). + +## 2. Registers — the only lever the host has + +![Register access](img/register_access.gif) + +Strip away every abstraction and a driver does exactly two things: it moves +**frames** over bulk endpoints, and it reads/writes **registers** — 8/16/32-bit +cells at fixed addresses that *are* the chip's knobs and dials. On USB a register +access is a vendor **control transfer**: the host submits a **URB** (USB request +block) to the kernel's USB stack, the request crosses the wire with the register +address packed into its setup fields, and the chip answers with the data. That's +the whole trick — `lsusb`-level plumbing, no kernel driver magic. devourer's +`RtlAdapter` (`src/RtlTransport.h`) is nothing but this, and the vendor equivalent +is `usb_ops_linux.c` in each tree. + +The address space is a map you'll internalize fast: the MAC's registers occupy the +low 16-bit space (power control at 0x0000-0x00FF, queues, filters), and the BB's +registers sit above 0x800. The RF dies are *not* in this map at all — reaching them +takes an indirection covered in §9. On the Wi-Fi 6 chips the BB outgrew 16 bits of +address, so control transfers carry a page selector: BB/RF accesses go through a +second 64 KiB window at `addr + 0x10000` (the high half rides the request's +`wIndex` field). + +Two planes, then: control transfers for registers, bulk transfers for frames. +Every animation that follows is ultimately made of these two arrows. + +## 3. One driver, many chips — chip id, cut, RFE + +![Chip identity](img/chip_identity.gif) + +Open any vendor function and you'll find it braided with conditionals. That's +because one driver binary serves an entire family of boards, and it learns *which* +board at runtime by reading identity registers and the efuse (§4): + +- **chip id** — which die design this is. Classically a field in `SYS_CFG` + (devourer dispatches its per-generation HALs from it); on the Wi-Fi 6 parts the + same offset means something else entirely, so a driver must dispatch those + from the USB product ID first. +- **cut** — the silicon revision (A-cut, B-cut, C-cut…). Bug fixes between cuts + change register defaults and calibration recipes, so init tables carry per-cut + entries (§8) and firmware ships per-cut images (§5). +- **RFE type** — the *RF front end* wiring of this particular board: which PA/LNA + parts, antenna switches, and band paths the vendor soldered around the chip. + Read from efuse, it selects between whole alternative blocks of the PHY tables. +- plus package type, TX/RX chain count, and interface — all feeding the same + gating machinery. + +devourer bottles this identity into one context object +(`JaguarPhyContext` in `src/PhyTableLoader.h`) and hands it to the table walker in +§8. When you see vendor code asking `IS_C_CUT(...)` or `rfe_type == 2`, this is +what's being asked: *which exact board am I standing on?* + +## 4. efuse — the chip's birth certificate + +![efuse logical map](img/efuse_map.gif) + +The **efuse** is a small one-time-programmable memory burned at the factory — +the only per-unit data the chip carries. In it: the MAC address, the crystal +trim (each 40 MHz crystal is slightly off; the trim recenters it), the per-rate +**TX power base** values this unit was calibrated to, the thermal-meter baseline +(§11), the RFE type (§3), and a scatter of board options. + +The catch is the format. The burned bytes are not a flat structure but a compact +**physical map**: a chain of headers, each saying "the next few bytes belong at +logical offset N, and only these words of it are present". Fusing works one way — +0→1 bits only — so updates are burned as *additional* patch entries later in the +chain that override earlier ones. The driver walks the chain and materializes the +**logical map**, a fixed-layout structure the rest of the code indexes. Vendor: +`hal/efuse/` in the 11ac trees, `phl/hal_g6/efuse/hal_efuse.c` in the Wi-Fi 6 +tree; devourer: `src/jaguar1/EepromManager.*` and each HAL's +`read_efuse_logical_map`. + +When a doc here mentions "efuse TX power tables" or the +[adapter doctor](adapter-doctor.md) checks "EFUSE read-stability", this map is +what's being read. + +## 5. Firmware and the WCPU — FWDL + +![Firmware download](img/fwdl.gif) + +Why does a Wi-Fi chip run its own code? Because some jobs need reactions faster +than a host can deliver over USB: per-frame rate adaptation, power-save timing, +Bluetooth arbitration (§13), calibration sequencing. Those live in **firmware** +executed by the **WCPU**, the microcontroller inside the MAC. The firmware image +ships inside the driver (in the vendor trees it's a giant C array — +`hal/rtl8812a/hal8812a_fw.c`; in devourer, blobs extracted from those arrays by +`tools/extract_*_fw.py`). + +**FWDL** — *firmware download* — is the bring-up ritual that installs it. The blob +is parsed into sections, each destined for the WCPU's instruction or data memory +(IMEM/DMEM); the host streams them through a download window; a checksum is +verified; the WCPU is released from reset; and the host polls a **ready bit** until +the firmware announces itself alive. Get any step wrong and you have a very quiet +chip: on some parts a failed firmware boot kills TX while RX still works. The +protocol differs per generation — page-window writes on the oldest parts, a +DMA-based path on the 4-chain 8814, HalMAC-mediated on the middle generation +(`hal/halmac/halmac_88xx/`), a dedicated `mac_ax/fwdl.c` module with per-cut RAM +images on Wi-Fi 6 — but the shape is always blob → sections → checksum → ready. +devourer: `src/jaguar1/FirmwareManager.*`, `src/jaguar2/HalmacJaguar2Fw.h`. + +After FWDL the chip is no longer a passive register file — it's a running computer +you share the hardware with. Which raises the next question: how do you talk to it? + +## 6. Talking to a running firmware — H2C and C2H + +![H2C and C2H mailboxes](img/h2c_c2h.gif) + +**H2C** — *host to CPU* — is the command mailbox. The host fills a small +fixed-format box (classically 8 bytes: a command id plus parameters) or, on newer +generations, a full descriptor-framed command packet, and the firmware consumes it: +"set power-save mode", "here is the rate mask", "Bluetooth has the antenna next +slot". **C2H** — *CPU to host* — is the return path: the firmware injects event +frames into the RX stream (TX reports, calibration-done, coex telemetry), which the +RX parser must recognize and route past the 802.11 path. Vendor: +`rtl8812a_cmd.c` in the old trees, `mac_ax/fwcmd.c` (a very large file — the whole +command surface) on Wi-Fi 6. + +The advanced form is **firmware IO-offload**: instead of the host issuing one +control transfer per register write, it packs an entire *write-list* into H2C +payloads and the firmware replays it locally. On the Wi-Fi 6 parts this is how +the thousands of PHY table writes of §8 are meant to travel — batched via +`mac_ax/h2c_agg.c` and the offload command classes — turning ten thousand USB +round-trips into a handful of bulk transfers. The mental shift matters: past this +point, "writing a register" may really mean "asking the firmware to write it", +with all the asynchrony that implies. + +## 7. The MAC — DMAC, CMAC, and a packet's journey + +![Packet journey through the MAC](img/mac_journey.gif) + +The Wi-Fi 6 generation names the MAC's two halves explicitly, and the names are +worth adopting for every generation because the split is real even where unnamed: + +- The **DMAC** (*data MAC*) is the plumbing: DMA engines and buffer management. + Its allocator, the **DLE** (*data link engine*), carves the chip's shared packet + RAM into **pages** and deals them out to TX queues and the RX FIFO — the + bring-up literally programs how many pages each queue owns + (`mac_ax/dle.c`). TX frames arrive from the host with a **TX descriptor** + prepended (rate, power, queue, checksums — devourer builds these in each + generation's frame code); **RX-DMA** runs the other direction, batching received + frames into aggregated bulk transfers so one URB completion carries many frames. +- The **CMAC** (*control MAC*) is the protocol brain: RX filters (what monitor + mode opens up), address matching, hardware ACK/BlockAck generation, the TSF + timer and beaconing machinery of [RF primer §10](rf-primer.md), EDCA channel + access. + +Riding on top is **NIC HFC** — *HCI flow control* (`mac_ax/hci_fc.c`): a credit +scheme in which each TX channel owns a budget of DLE pages, and the host must not +push a frame unless credits cover it. Misconfigure the credit table and the +symptom is maddening: everything green, and one queue silently eating frames. +Vendor: `mac_ax/{init,dle,hci_fc,cmac_tx}.c`; devourer: +`src/jaguar2/HalmacJaguar2MacInit.h`. + +The animation follows one frame down (host → bulk URB → descriptor parse → DLE +pages → CMAC → antenna) and one frame up (antenna → CMAC filter → RX-DMA +aggregation buffer → bulk URB → host). + +## 8. Programming the PHY — the BB/RF register tables + +![PHY table walker](img/phy_tables.gif) + +After power-on the BB and RF are *blank* — thousands of registers of filter +coefficients, AGC curves, and RF tunings away from being a radio. The vendor +encodes all of it as **register tables**: long generated arrays of +`(address, value)` pairs replayed at bring-up. Every generation ships the same +quartet: + +- **phy_reg** — the BB initialization proper; +- the **AGC table** (also *gain table*) — the receiver's gain-vs-signal-level + staircase, indexed by band ([RF primer §5](rf-primer.md) is what it controls); +- **radio A** and **radio B** — one table per RF path/die (§9), programming the + synthesizer and analog stages. Four-chain chips carry radio C/D too; +- a MAC register table for good measure. + +Interleaved with plain writes are *conditional* rows — the vendor's +**check_positive** encoding: "the next block applies only if cut == C, or +rfe_type == 2, or this is the USB flavor". The walker evaluates each gate against +the §3 identity and applies or skips the block. This is how one table serves every +board revision. Look at `hal/phydm/rtl8812a/halhwimg8812a_bb.c` — the +`if (check_positive(...))` ladder *is* the format. In the two 11ac generations +these tables and their runtime live in **phydm** (the vendor's "PHY dynamic +management" layer); the Wi-Fi 6 tree splits that world in two — **halbb** owns the +baseband (`phl/hal_g6/phy/bb/`), **halrf** owns the radio and its calibration +(`phl/hal_g6/phy/rf/`) — with the same conditional-table idea inside. + +devourer re-implements exactly the walker, not phydm: `src/PhyTableLoader.h` +(shared by the first two generations), plus the per-format variant +`PhyTableLoaderJaguar3.h`. The tables +themselves are *generated* from the vendor trees by `tools/extract_*_phy*.py` — +edit the generators, never the outputs. + +## 9. Reaching the radio — LSSI and the RF windows + +![LSSI and RF windows](img/lssi_rf_windows.gif) + +The RF dies keep their own register space — small addresses (an 8-bit space), +20-bit-wide data, one copy per RF path — and none of it is memory-mapped into §2's +address map. Two indirections reach it: + +- **LSSI** (*low-speed serial interface*) — the classic path on the 11ac parts: a + 3-wire serial bus from BB to RF. The host writes a BB register (one per path — + 0xc90 for path A, 0xe90 for path B on the Jaguar parts) with address and data + packed together, and the BB clocks the bits across. Reads come back through a + BB shadow register. It's slow and write-mostly, which is why RF state is often + cached and *composed* rather than read back — see + `RadioManagementModule::phy_set_rf_reg` in `src/jaguar1/`. +- **RF windows** — the Wi-Fi 6 path, where the two-die anatomy of §1 becomes + software-visible. The **DDV** window (*d-die*, direct view) maps the d-die RF + registers into BB address space (apertures at 0xe000/0xf000 — path A and B), so + they're plain window writes; the **DAV** path (*a-die* view) drives the a-die + RF registers through a serial command register (BB 0x370) — LSSI's descendant. + The radio tables of §8 encode the die in an address bit, and the walker + dispatches each row to the right path (the G6 halrf register-access ops). + Some registers exist on *both* dies and must be written + through both paths to take effect — channel tuning (§10) is the famous case. + +## 10. Tuning a channel — RF18, band, bandwidth, LCK + +![Channel tuning](img/channel_tune.gif) + +RF register **0x18** — universally "**RF18**" — is the synthesizer's command word: +channel number, band select, and bandwidth bits packed into one 20-bit register +(per path). A channel switch is never *just* RF18, though; the full recipe is a +small ballet across the layers you now know: BB bandwidth/filter settings, the +band's AGC table (§8), band-dependent front-end switches (§3's RFE wiring), then +RF18 into every path — on the two-die parts, through both DAV and DDV (§9) with a +re-latch strobe after. Some synthesizers only re-latch on a *change* of RF18, so +drivers deliberately write a dummy value first — the kind of quirk this layer is +made of. devourer's channel machinery per generation: +`src/jaguar1/RadioManagementModule.cpp`, `src/jaguar2/HalJaguar2` / +`src/jaguar3/RadioManagementJaguar3`; what devourer +builds on top of it (sub-millisecond retunes, per-packet hopping) is +[frequency-hopping.md](frequency-hopping.md). + +The synthesizer itself is a PLL whose VCO must *lock* onto the target frequency, +and its tuning constants drift with temperature. **LCK** (*LO/lock calibration*) +re-derives them — run at bring-up and re-run when the thermal meter (§11) has +drifted far enough. A synth that tunes without locking transmits garbage on a +wrong frequency; that's what LCK is protecting you from. + +## 11. Calibration — halrf's per-boot rituals + +![RF calibration](img/rf_cal.gif) + +Analog silicon is imperfect per-unit, per-temperature, per-frequency — so after +init tables and before real traffic, the driver calibrates the chip against +itself. The recurring cast, in the order a bring-up runs them: + +- **DACK** (*DAC calibration*) — trims the transmit DACs' offset and gain so the + I and Q converters are true. +- **RX-DCK** (*RX DC calibration*) — nulls the DC offset the receive mixers leak + into the middle of the spectrum (in OFDM terms: keeps the DC subcarrier's bin + clean). +- **IQK** (*I/Q calibration*) — the big one. The I and Q arms of the quadrature + mixers are never perfectly matched in gain and phase; mismatch smears every + constellation point ([RF primer §2](rf-primer.md)). IQK loops the chip's TX + into its own RX, measures the image, and programs correction coefficients. +- **TXGAPK** / **DPK** — TX gain-step and digital-predistortion calibrations on + the newer parts, flattening the PA's behavior across power levels. +- **thermal tracking** — not a one-shot: the RF exposes a thermal meter, and the + driver periodically compares it to the efuse baseline (§4), adjusting TX gain + and re-triggering LCK (§10) as the chip heats. + +Vendor: `hal/phydm/halrf/` in the 11ac trees; per-algorithm modules +(`halrf_dack_8852b.c`, `halrf_iqk.c`, …) under `phl/hal_g6/phy/rf/` on Wi-Fi 6. +devourer: `src/jaguar1/Iqk8812a.*`, `src/jaguar2/Halrf8822b.*`, +`src/jaguar3/Halrf8822c.*` and friends. The animation shows why you care: the +smeared constellation and DC spike before, the clean lattice after. + +## 12. Keeping the receiver alive — BB DM, AGC, and DIG + +![DIG tracking loop](img/dig_loop.gif) + +Bring-up ends but the PHY's work doesn't: the radio environment moves, and a set +of slow feedback loops — the **BB dynamic mechanisms** (vendor: the *DM watchdog*, +phydm's original job description, `halbb_dig.c` and friends on Wi-Fi 6) — keep +retuning the receiver every couple of seconds. The star is **DIG** — *dynamic +initial gain*. The AGC of [RF primer §5](rf-primer.md) settles its gain per-frame, +but it has to start each hunt somewhere; that starting point (the *initial gain*, +in vendor code the IGI value) is a sensitivity dial. Set it low and the receiver +false-triggers on noise all day; set it high and it walks past weak frames. DIG +watches the noise floor and false-alarm counters and steps the dial to track the +environment — the animation is that staircase chasing a moving floor. + +The supporting cast: **CCK-PD** (the equivalent detection threshold for the +legacy 2.4 GHz CCK preamble), **EDCCA** (the energy-detect clear-channel +threshold — when "the air is busy" is declared), **CFO tracking** (trimming +carrier-frequency offset against the crystal's drift), and the thermal loop of +§11. devourer ports the pieces it needs per generation: +`src/jaguar1/PhydmWatchdog.h`, `src/jaguar3/PhydmRuntimeJaguar3.h`, +`src/CfoTracker.h`. + +## 13. Sharing the antenna — coexistence + +![Coexistence arbitration](img/coex.gif) + +Most of these chips are Wi-Fi/**Bluetooth combos** — two radios, one antenna and +front end. **Coexistence** (*coex*, *BTC*) is the machinery that arbitrates: a +hardware arbiter time-slices the antenna between the two, driven by a policy state +machine that mostly lives in firmware (§5) and is steered over H2C with telemetry +back over C2H (§6). The policy tables are big — who wins during a Wi-Fi TX burst +vs. an ongoing BT voice link is a genuinely hard scheduling problem — and the +driver must keep the firmware's coex state fed even on Wi-Fi-only setups, or the +arbiter can conclude BT owns the air and silence Wi-Fi TX entirely (the Jaguar3 +coex runtime exists for exactly this; see [8822e-quirks.md](8822e-quirks.md)). +Vendor: `hal/hal_btcoex.c` in the old trees growing into a full subsystem at +`phl/hal_g6/btc/` on Wi-Fi 6. + +The same idea extends off-chip: **WiFi/BT ↔ LTE coexistence**. In a phone or +module, a cellular modem transmitting in an adjacent band will desense the 2.4 GHz +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 + +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 +abstracted MAC, then a fully layered stack: + +``` +rtl8812au (11ac gen1) rtl88x2bu / rtl88x2cu (gen2) rtl8852bu (Wi-Fi 6, "G6/phl") +───────────────────── ──────────────────────────── ───────────────────────────── +hal/ hal/ phl/ +├─ rtl8812a/ chip code ├─ rtl8822b|c/ chip code ├─ hci/ USB/PCIe TRX +│ ├─ hal8812a_fw.c firmware │ └─ hal8822c_fw.c firmware ├─ hal_g6/ +│ ├─ rtl8812a_cmd.c H2C/C2H ├─ halmac/ MAC layer │ ├─ mac/ +│ ├─ *_hal_init.c bring-up │ └─ halmac_88xx/ │ │ ├─ mac_ax/ the MAC +│ └─ usb/ USB ops │ ├─ *_fw_88xx FWDL │ │ │ fwdl.c FWDL +├─ phydm/ the PHY │ ├─ *_efuse_88xx efuse │ │ │ fwcmd.c H2C/C2H +│ ├─ rtl8812a/ BB/RF/AGC │ └─ halmac_usb_* USB │ │ │ h2c_agg.c IO-offload +│ │ tables (halhwimg*) ├─ phydm/ the PHY │ │ │ dle.c pages +│ └─ halrf/ calibration │ ├─ rtl8822c/ tables │ │ │ hci_fc.c NIC HFC +├─ efuse/ efuse │ └─ halrf/ calibration │ │ │ cmac_tx.c CMAC +├─ hal_btcoex.c BT coex ├─ efuse/ │ │ └─ fw_ax/ firmware blobs +└─ hal_hci/ USB layer └─ btc/ BT coex │ ├─ phy/ +core/ 80211 stack core/ │ │ ├─ bb/ halbb (+ tables) +os_dep/ Linux glue os_dep/ │ │ └─ rf/ halrf (+ cal) + │ ├─ efuse/ efuse + │ └─ btc/ BT coex + core/, os_dep/ stack + glue +``` + +Three habits for navigating any of them: + +- **Search by concept prefix, not by chip.** `halbb_`, `halrf_`, `halmac_`, + `halbtc_`, `phydm_` name the layer; the chip name (`8852b`) names the variant + subdirectory. `hwimg` in a filename means *generated register tables* (§8) — + huge, skim-only. +- **The `_ops` structs are the table of contents.** Each layer binds its + chip-specific functions into an ops struct at init; find the struct and you've + found every entry point that matters. +- **devourer is the same map, shrunk.** `src/jaguar1/` mirrors the rtl8812au + layout (HalModule / EepromManager / FirmwareManager / RadioManagement), + `src/jaguar2..3/` mirror the halmac generation (HalmacJaguar*Fw / MacInit, + Halrf*). When a devourer file puzzles you, its vendor namesake has the + long-form version — and vice versa. + +## Appendix: the glossary + +Every term above plus the ones that didn't earn a full section — with where to +find each in the vendor trees and in devourer. + +| Term | Meaning | § | Vendor / devourer home | +|---|---|---|---| +| MAC | frame engine: queues, ACKs, filters, timers | 1,7 | `mac_ax/` / per-HAL MacInit | +| BB | baseband — digital PHY (modem, AGC, FFTs) | 1 | `phy/bb/`, `phydm/` / table loaders | +| RF | analog PHY: synthesizer, mixers, gain | 1 | `phy/rf/`, radio tables | +| PA / LNA | power amp (TX) / low-noise amp (RX) front end | 1 | board-level; selected by RFE type | +| WCPU | the MAC's embedded CPU running firmware | 1,5 | booted by FWDL | +| A-die / D-die | analog / digital silicon dies in one package | 1,9 | reached via DAV / DDV | +| URB | USB request block — one queued USB transfer | 2 | `usb_ops_linux.c` / `src/RtlTransport.h` | +| chip id | which die design; drives HAL dispatch | 3 | `SYS_CFG` / `WiFiDriver` factory | +| cut | silicon revision (A/B/C…) | 3 | gates tables + firmware images | +| RFE (type) | board's RF front-end wiring variant, from efuse | 3 | gates PHY table blocks | +| efuse | one-time-programmable per-unit memory | 4 | `hal_efuse.c` / `EepromManager` | +| logical map | decoded fixed-layout view of efuse contents | 4 | `read_efuse_logical_map` | +| firmware | code the WCPU runs (rate adapt, PS, coex, cal) | 5 | `hal*_fw.c`, `fw_ax/` / `tools/extract_*_fw.py` | +| FWDL | firmware download: sections → checksum → ready | 5 | `mac_ax/fwdl.c` / `FirmwareManager`, `HalmacJaguar2Fw` | +| H2C | host→CPU command mailbox | 6 | `*_cmd.c`, `mac_ax/fwcmd.c` | +| C2H | CPU→host events in the RX stream | 6 | RX parsers route them | +| fw IO-offload | register write-lists executed by firmware | 6 | `mac_ax/h2c_agg.c` | +| DMAC | data MAC: DMA, queues, buffer plumbing | 7 | `mac_ax/dle.c` etc. | +| CMAC | control MAC: filters, ACK, TSF, EDCA | 7 | `mac_ax/cmac_tx.c` etc. | +| DLE | page allocator for the shared packet RAM | 7 | `mac_ax/dle.c` | +| RX-DMA | chip→host frame path, aggregated into bulk URBs | 7 | RX parsers per generation | +| NIC HFC | per-queue page-credit TX flow control | 7 | `mac_ax/hci_fc.c` | +| TX descriptor | per-frame header: rate, power, queue | 7 | `src/TxDescBits.h`, frame parsers | +| phy_reg | the BB init register table | 8 | `halhwimg*_bb.c` / generated `hal/` tables | +| AGC / gain table | receiver gain staircase table, per band | 8 | `halhwimg*_agc`… | +| radio A/B tables | RF init tables, one per path | 8 | `halhwimg*_rf.c` | +| check_positive | conditional-row encoding inside the tables | 8 | `src/PhyTableLoader.h` | +| phydm | gen1/2 vendor PHY layer (tables + DM loops) | 8,12 | `hal/phydm/` | +| halbb / halrf | Wi-Fi 6 split: baseband / radio+cal layers | 8 | `phl/hal_g6/phy/{bb,rf}/` | +| LSSI | 3-wire serial bus to the RF die (write 0xc90/0xe90) | 9 | `phy_set_rf_reg` | +| DAV / DDV | a-die (serial 0x370) / d-die (window) RF access | 9 | G6 halrf (`phl/hal_g6/phy/rf/`) | +| RF18 | RF reg 0x18: synthesizer channel/band/BW word | 10 | every `set_channel` | +| LCK | synthesizer lock calibration (thermal re-run) | 10 | halrf; `PhydmRuntimeJaguar3` | +| DACK | TX DAC offset/gain calibration | 11 | `halrf_dack*` | +| RX-DCK | RX DC-offset calibration | 11 | `halrf_*dck*` | +| IQK | I/Q imbalance cal via self-loopback | 11 | `halrf_iqk*` / `Iqk8812a`, `Halrf8822b` | +| TXGAPK / DPK | TX gain-step / predistortion calibrations | 11 | `halrf_*` (newer parts) | +| thermal tracking | meter-vs-efuse-baseline gain compensation | 11 | `src/ThermalStatus.h` | +| BB DM | runtime dynamic mechanisms (the watchdog) | 12 | phydm / halbb / `PhydmWatchdog` | +| AGC | per-frame automatic gain control | 12 | [RF primer §5](rf-primer.md) | +| DIG | dynamic *initial* gain — the AGC's start point | 12 | `halbb_dig.c` / watchdogs | +| CCK-PD / EDCCA | CCK preamble-detect / energy-CCA thresholds | 12 | DM loops; `SetCcaMode` | +| CFO | carrier frequency offset (crystal mismatch) | 12 | `src/CfoTracker.h` | +| BT coex (BTC) | Wi-Fi/Bluetooth antenna arbitration | 13 | `btc/`, `hal_btcoex.c` | +| LTE coex | Wi-Fi/BT ↔ cellular cross-chip arbitration | 13 | `mac_ax/coex.c` mailbox | +| TSF / TBTT | MAC µs clock / beacon schedule timer | 7 | [RF primer §10](rf-primer.md) | + +## Where to go next + +- [`rf-primer.md`](rf-primer.md) — the physics these blocks implement; the two + primers are two views of one machine. +- [`8822e-quirks.md`](8822e-quirks.md) — this vocabulary at full density on one + real chip: pin-mux, coex obligations, calibration ordering. +- [`narrowband.md`](narrowband.md) and + [`frequency-hopping.md`](frequency-hopping.md) — what devourer builds on top of + §10's channel machinery. +- [`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 + and §7 becomes throughput. +- `reference/README.md` — how the vendor trees are vendored and why; CLAUDE.md's + **Architecture** section — the devourer-side map. diff --git a/docs/img/channel_tune.gif b/docs/img/channel_tune.gif new file mode 100644 index 0000000..416b002 Binary files /dev/null and b/docs/img/channel_tune.gif differ diff --git a/docs/img/chip_anatomy.gif b/docs/img/chip_anatomy.gif new file mode 100644 index 0000000..b4b652a Binary files /dev/null and b/docs/img/chip_anatomy.gif differ diff --git a/docs/img/chip_identity.gif b/docs/img/chip_identity.gif new file mode 100644 index 0000000..365611e Binary files /dev/null and b/docs/img/chip_identity.gif differ diff --git a/docs/img/coex.gif b/docs/img/coex.gif new file mode 100644 index 0000000..9a5fc76 Binary files /dev/null and b/docs/img/coex.gif differ diff --git a/docs/img/dig_loop.gif b/docs/img/dig_loop.gif new file mode 100644 index 0000000..393b07b Binary files /dev/null and b/docs/img/dig_loop.gif differ diff --git a/docs/img/efuse_map.gif b/docs/img/efuse_map.gif new file mode 100644 index 0000000..e2a6f86 Binary files /dev/null and b/docs/img/efuse_map.gif differ diff --git a/docs/img/fwdl.gif b/docs/img/fwdl.gif new file mode 100644 index 0000000..8384fe4 Binary files /dev/null and b/docs/img/fwdl.gif differ diff --git a/docs/img/h2c_c2h.gif b/docs/img/h2c_c2h.gif new file mode 100644 index 0000000..3e78963 Binary files /dev/null and b/docs/img/h2c_c2h.gif differ diff --git a/docs/img/lssi_rf_windows.gif b/docs/img/lssi_rf_windows.gif new file mode 100644 index 0000000..3b6db9c Binary files /dev/null and b/docs/img/lssi_rf_windows.gif differ diff --git a/docs/img/mac_journey.gif b/docs/img/mac_journey.gif new file mode 100644 index 0000000..c7c82b1 Binary files /dev/null and b/docs/img/mac_journey.gif differ diff --git a/docs/img/phy_tables.gif b/docs/img/phy_tables.gif new file mode 100644 index 0000000..a709c1f Binary files /dev/null and b/docs/img/phy_tables.gif differ diff --git a/docs/img/register_access.gif b/docs/img/register_access.gif new file mode 100644 index 0000000..5b17a57 Binary files /dev/null and b/docs/img/register_access.gif differ diff --git a/docs/img/rf_cal.gif b/docs/img/rf_cal.gif new file mode 100644 index 0000000..4e25555 Binary files /dev/null and b/docs/img/rf_cal.gif differ diff --git a/docs/rf-primer.md b/docs/rf-primer.md index 79a1068..2545b20 100644 --- a/docs/rf-primer.md +++ b/docs/rf-primer.md @@ -208,6 +208,10 @@ runnable tool (`tests/pcie_ptp_beacon.cpp`). With the machinery in hand, the rest reads straight: +- [`driver-primer.md`](driver-primer.md) — this primer's sibling: the same + picture-book treatment for the *silicon and driver* machinery (registers, + efuse, firmware, MAC, PHY tables, calibration) that implements all of the + above. - [`rx-spectrum-sensing.md`](rx-spectrum-sensing.md) — reading energy, noise, and interference off that channel comb, frame-free (includes the animated NHM monitor). diff --git a/tools/channel_tune_gif.py b/tools/channel_tune_gif.py new file mode 100644 index 0000000..ab7f71c --- /dev/null +++ b/tools/channel_tune_gif.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Animated channel tune — 'RF18, band, bandwidth, LCK', in the DEVOURER +live-monitor style. + + tools/channel_tune_gif.py -o docs/img/channel_tune.gif + +A channel switch is a small ballet across the layers: widen the BB filter mask +around the target, swap in the band's AGC table, pack channel + band + BW into +RF register 0x18 (written into every RF path — and on two-die parts through +both DAV and DDV), let the synthesizer needle overshoot and settle, then run +LCK to re-derive the VCO tuning constants before trusting the lock. The +animation stages exactly that recipe, ch 36 -> ch 149 @ 80 MHz. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +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) + +# stages: (frames, key, caption) +STAGES = [ + (8, "idle", "parked on ch 36 - a channel switch is a ballet, not one register"), + (16, "filt", "1. BB filter: widen the mask 20 -> 40 -> 80 MHz for the new bandwidth"), + (14, "agc", "2. AGC table swap - load the 5 GHz gain staircase (a s8 table, not the 2G one)"), + (26, "rf18", "3. RF18: channel+band+BW packed, written per path - both dies (DAV+DDV)"), + (20, "lck", "LCK: re-derive the VCO tuning constants before trusting the synth"), + (12, "done", "locked: ch 149 / 5745 MHz / 80 MHz - now the synth can be trusted"), +] + +CHANS = [36, 40, 44, 48, 52, 56, 60, 64, 100, 108, 116, 124, 132, 140, 149, 157, 165] +LABELED = {36, 52, 64, 100, 124, 149, 165} +CH_FROM, CH_TO = 36, 149 + +# RF18 bit groups (illustrative layout, 20 bits): [7:0] CHANNEL, +# [13:10] BW, [17:16] BAND +GROUPS = [("CHANNEL", 0, 8, CYAN), ("BW", 10, 4, OK), ("BAND", 16, 2, AMBER)] +OLD = {"CHANNEL": "00100100", "BW": "0000", "BAND": "01"} +NEW = {"CHANNEL": "10010101", "BW": "1100", "BAND": "10"} + + +def ch_x(ch, x0, x1): + i = CHANS.index(min(CHANS, key=lambda c: abs(c - ch))) + # position by list index (even spacing reads better than true frequency) + f = (CHANS.index(ch) if ch in CHANS else i) / (len(CHANS) - 1) + return x0 + f * (x1 - x0) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="channel_tune.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x18) + + W, H = 900, 520 + comb_x0, comb_x1, comb_y = 70, 840, 150 + f18_y, cell_w = 236, 27 + f18_x0 = (W - 20 * cell_w) // 2 + side_y = 330 + + imgs = [] + total = sum(s[0] for s in STAGES) + for gi in range(total): + acc, si, ft = 0, 0, 0.0 + for k, (nf, key, cap) in enumerate(STAGES): + if gi < acc + nf: + si, ft = k, (gi - acc) / nf + break + acc += nf + key, cap = STAGES[si][1], STAGES[si][2] + s_of = lambda name: [s[1] for s in STAGES].index(name) + after = lambda name: si > s_of(name) + img, d = new_frame(W, H) + chrome(d, W, H, "CHANNEL TUNE", + "BB filter -> AGC table -> RF18 into every path -> LCK", gi) + + # ================= band comb + synthesizer needle ================= + d.text((40, comb_y + 30), "5 GHz band", font=font(11), fill=DIM) + d.line([comb_x0 - 10, comb_y, comb_x1 + 10, comb_y], + fill=(50, 70, 90), width=2) + for ch in CHANS: + x = ch_x(ch, comb_x0, comb_x1) + d.line([x, comb_y - 6, x, comb_y + 6], fill=(70, 95, 120), width=1) + if ch in LABELED: + d.text((x - 10, comb_y + 12), str(ch), font=font(10), fill=DIM) + + # needle position: parked -> damped-overshoot sweep during rf18 -> target + if si < s_of("rf18"): + pos = float(CH_FROM_IDX := CHANS.index(CH_FROM)) + elif key == "rf18": + t = ft + a, b = CHANS.index(CH_FROM), CHANS.index(CH_TO) + pos = b + (a - b) * math.exp(-5.0 * t) * math.cos(2 * math.pi * 1.2 * t) + pos = max(0.0, min(pos, len(CHANS) - 1.0)) + else: + pos = float(CHANS.index(CH_TO)) + nx = comb_x0 + (pos / (len(CHANS) - 1)) * (comb_x1 - comb_x0) + settled = si >= s_of("lck") + ncol = OK if si == len(STAGES) - 1 else (CYAN if key == "rf18" or settled else DIM) + d.polygon([(nx - 7, comb_y - 26), (nx + 7, comb_y - 26), (nx, comb_y - 8)], + fill=ncol) + d.line([nx, comb_y - 8, nx, comb_y + 8], fill=ncol, width=2) + # readout above the needle (clamped inside the comb) + cur_ch = CHANS[min(range(len(CHANS)), key=lambda i: abs(i - pos))] + rtxt = f"ch {cur_ch} {5000 + 5 * cur_ch} MHz" + rx = min(max(nx - 55, comb_x0), comb_x1 - 130) + d.text((rx, comb_y - 44), rtxt, font=font(11, True), fill=ncol) + # BB filter brackets around the target channel on the comb + bw_now = 20 + if key == "filt": + bw_now = (20, 40, 80)[min(2, int(ft * 3))] + elif after("filt"): + bw_now = 80 + tx_ = ch_x(CH_TO, comb_x0, comb_x1) + half = {20: 14, 40: 28, 80: 56}[bw_now] // 1 + bc = AMBER if key == "filt" else ((60, 70, 40) if after("filt") else GRID) + d.line([tx_ - half, comb_y + 26, tx_ - half, comb_y + 34], fill=bc, width=2) + d.line([tx_ - half, comb_y + 34, tx_ + half, comb_y + 34], fill=bc, width=1) + d.line([tx_ + half, comb_y + 26, tx_ + half, comb_y + 34], fill=bc, width=2) + if key == "filt" or after("filt"): + d.text((tx_ - 28, comb_y + 40), f"{bw_now} MHz", font=font(10), fill=bc) + + # ================= RF18 field diagram ============================== + d.text((f18_x0, f18_y - 20), "RF reg 0x18 (RF18) - 20 bits, per RF path", + font=font(11), fill=DIM) + filled = 0 + if key == "rf18": + filled = int(ft * 3) + 1 # groups fill one after another + elif after("rf18"): + filled = 3 + for b in range(20): + x = f18_x0 + (19 - b) * cell_w # bit 19 leftmost + grp = next((g for g in GROUPS if g[1] <= b < g[1] + g[2]), None) + oc = grp[3] if grp else GRID + d.rectangle([x, f18_y, x + cell_w - 3, f18_y + 24], outline=oc) + if grp: + name, lo, ln, col = grp + gi_ = GROUPS.index(grp) + src = NEW if gi_ < filled else OLD + bit = src[name][ln - 1 - (b - lo)] + hot = key == "rf18" and gi_ == filled - 1 + d.text((x + 9, f18_y + 5), bit, font=font(12, hot), + fill=INK if gi_ < filled else (70, 95, 120)) + else: + d.text((x + 9, f18_y + 5), "0", font=font(12), fill=(45, 60, 78)) + for name, lo, ln, col in GROUPS: + xl = f18_x0 + (19 - (lo + ln - 1)) * cell_w + d.text((xl + 2, f18_y + 32), name, font=font(10), fill=col) + if key == "rf18": + d.text((f18_x0, f18_y + 54), + f"write path A ... path B{' [DAV ok] [DDV ok]' if ft > 0.6 else ''}", + font=font(11), fill=CYAN) + elif after("rf18"): + d.text((f18_x0, f18_y + 54), "written: path A + path B (DAV + DDV)", + font=font(11), fill=(60, 130, 100)) + + # ================= side elements: AGC swap + LCK =================== + # AGC swap (left) + agc_on = key == "agc" + agc_done = after("agc") + ac = AMBER if agc_on else ((60, 70, 40) if agc_done else GRID) + d.rectangle([70, side_y, 190, side_y + 88], outline=ac) + d.text((80, side_y + 6), "AGC table", font=font(11, True), + fill=INK if (agc_on or agc_done) else DIM) + # staircase icon: old (dim) vs new (bright) drawn as steps + swap = ft if agc_on else (1.0 if agc_done else 0.0) + for stp in range(5): + sy = side_y + 70 - stp * 9 + sx = 82 + stp * 18 + newish = stp / 5.0 < swap + d.line([sx, sy, sx + 18, sy], + fill=(OK if newish else (70, 95, 120)) if (agc_on or agc_done) + else GRID, width=2) + d.text((80, side_y + 94), "5G gain staircase" if (agc_done or swap > 0.5) + else "2G gain staircase", font=font(10), fill=ac) + + # RF paths note (middle) + d.rectangle([250, side_y, 470, side_y + 88], outline=GRID) + d.text((260, side_y + 6), "RF paths", font=font(11, True), fill=DIM) + for pi, pname in enumerate(("path A", "path B")): + py = side_y + 32 + pi * 26 + got = after("rf18") or (key == "rf18" and ft > (0.4 + 0.3 * pi)) + d.text((260, py), pname, font=font(11), fill=INK if got else DIM) + d.text((330, py), "RF18 = 0x2C395" if got else "RF18 = 0x24024", + font=font(11), fill=OK if got else (70, 95, 120)) + + # LCK lock indicator (right) + lck_on = key == "lck" + lck_done = si == len(STAGES) - 1 + lc = CYAN if lck_on else (OK if lck_done else GRID) + d.rectangle([530, side_y, 840, side_y + 88], outline=lc) + d.text((542, side_y + 6), "synthesizer PLL", font=font(11, True), + fill=INK if (lck_on or lck_done) else DIM) + cx, cy = 570, side_y + 54 + if lck_on: + # oscillate red/green while the VCO cal hunts, settle at the end + hunt = math.sin(ft * math.pi * 6) * (1 - ft) + locked = ft > 0.8 + col = OK if locked or hunt > 0 else WARN + d.ellipse([cx - 12, cy - 12, cx + 12, cy + 12], fill=col) + d.text((cx + 24, cy - 8), "LOCK" if locked else "cal...", + font=font(12, True), fill=col) + d.text((cx + 130, cy - 8), f"VCO const {int(ft * 8):d}/8", + font=font(11), fill=DIM) + elif lck_done: + d.ellipse([cx - 12, cy - 12, cx + 12, cy + 12], fill=OK) + d.text((cx + 24, cy - 8), "LOCK OK", font=font(12, True), fill=OK) + d.text((542, side_y + 70), "thermal delta will re-arm LCK", + font=font(10), fill=DIM) + else: + d.ellipse([cx - 12, cy - 12, cx + 12, cy + 12], outline=GRID) + d.text((cx + 24, cy - 8), "unlocked", font=font(11), fill=DIM) + + # ---- caption ------------------------------------------------------ + d.text((70, H - 42), cap, font=font(13, True), + fill=OK if key == "done" else CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/chip_anatomy_gif.py b/tools/chip_anatomy_gif.py new file mode 100644 index 0000000..a9f0d11 --- /dev/null +++ b/tools/chip_anatomy_gif.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Animated chip anatomy — 'five machines in one package', in the DEVOURER +live-monitor style (docs/driver-primer.md §1). + + tools/chip_anatomy_gif.py -o docs/img/chip_anatomy.gif + +A USB Wi-Fi dongle is one chip with five machines chained like an assembly +line: USB interface → MAC (with the WCPU firmware core inside) → BB (digital +PHY) → RF (analog PHY) → PA/LNA front end → antenna. The digital blocks +(MAC+BB) and the analog block (RF) are frequently separate silicon dies in one +package — the D-die and the A-die. Phase 1 walks a TX frame left→right through +every machine; phase 2 walks an RX frame back right→left. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# (name, width, tx description, rx description) +BLOCKS = [ + ("USB IF", 108, "frame arrives from the host over a bulk endpoint", + "aggregated into a bulk transfer, back up to the host"), + ("MAC", 148, "queue, TX descriptor, ACK timing — the WCPU runs here", + "RX filter, address match, RX descriptor — a frame again"), + ("BB", 116, "digital PHY: scramble, FEC, QAM, IFFT — the OFDM modem", + "AGC, FFT, equalize, demap, decode — samples back to bits"), + ("RF", 116, "synthesizer + mixers move baseband to channel frequency", + "mixers bring the channel frequency down to baseband"), + ("PA/LNA", 116, "the PA gives the TX signal its final watts", + "the LNA gives the faint signal its first clean boost"), + ("ANT", 88, "…and it radiates", "a faint frame arrives at the antenna"), +] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="chip_anatomy.gif") + ap.add_argument("--per", type=int, default=8) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0xC1) + + W, H = 900, 452 + padL = 34 + n = len(BLOCKS) + row_y, box_h = 168, 84 + gap = 14 + + # block x positions + xs, x = [], padL + for name, bw, _, _ in BLOCKS: + xs.append((x, x + bw)) + x += bw + gap + centers = [(a + b) // 2 for a, b in xs] + + imgs = [] + phases = [ + (0, "PHASE 1 — TX: host frame -> radio wave (left to right)"), + (1, "PHASE 2 — RX: radio wave -> host frame (right to left)"), + ] + for phase, phase_label in phases: + order = list(range(n)) if phase == 0 else list(range(n - 1, -1, -1)) + for si, blk in enumerate(order): + for fi in range(args.per): + gi = (phase * n + si) * args.per + fi + img, d = new_frame(W, H) + chrome(d, W, H, "CHIP ANATOMY", + "five machines in one package: USB IF > MAC(+WCPU) > " + "BB > RF > PA/LNA > antenna", gi) + + # die brackets above the row: D-die = MAC+BB, A-die = RF + for (i0, i1, lbl, col) in ((1, 2, "D-die (digital)", CYAN), + (3, 3, "A-die (analog)", AMBER)): + bx0, bx1 = xs[i0][0], xs[i1][1] + by = row_y - 22 + dc = tuple(c // 2 for c in col) + d.line([bx0, by, bx1, by], fill=dc, width=1) + d.line([bx0, by, bx0, by + 8], fill=dc, width=1) + d.line([bx1, by, bx1, by + 8], fill=dc, width=1) + tw = d.textlength(lbl, font=font(10)) + d.text(((bx0 + bx1) // 2 - tw // 2, by - 15), lbl, + font=font(10), fill=col) + # one-package outline around everything but the antenna + d.rectangle([xs[0][0] - 8, row_y - 44, xs[4][1] + 8, + row_y + box_h + 10], outline=(35, 45, 60)) + d.text((xs[0][0], row_y + box_h + 16), "one chip package", + font=font(10), fill=(70, 90, 110)) + + # blocks + active = blk + for j, (name, bw, _, _) in enumerate(BLOCKS): + x0, x1 = xs[j] + on = j == active + passed = (order.index(j) < si) + oc = CYAN if on else (OK if passed else (55, 70, 88)) + tc = INK if on else (OK if passed else DIM) + if name == "ANT": # antenna is a symbol, not a box + cx = centers[j] + ac = oc + d.line([cx, row_y + 10, cx, row_y + box_h - 6], + fill=ac, width=2) + d.line([cx - 12, row_y + 26, cx, row_y + 10], + fill=ac, width=2) + d.line([cx + 12, row_y + 26, cx, row_y + 10], + fill=ac, width=2) + if on: + for r in (12, 20, 28): + rr = r + (gi % 3) * 2 + d.arc([cx + 4, row_y + 10 - rr, + cx + 4 + 2 * rr, row_y + 10 + rr], + 140, 220, fill=AMBER) + d.text((cx - 20, row_y + box_h - 20), "ANT", + font=font(11, True), fill=tc) + continue + d.rectangle([x0, row_y, x1, row_y + box_h], + outline=oc, width=2 if on else 1) + d.text((x0 + 8, row_y + 6), name, font=font(12, True), + fill=tc) + if name == "MAC": # small WCPU core inside the MAC + wc = AMBER if on else (90, 80, 40) + d.rectangle([x0 + 10, row_y + 42, x0 + 74, + row_y + box_h - 10], outline=wc) + d.text((x0 + 16, row_y + 48), "WCPU", font=font(10), + fill=wc) + if name == "BB": + d.text((x0 + 8, row_y + 24), "FFT/EQ", font=font(10), + fill=(70, 90, 110)) + if name == "RF": + d.text((x0 + 8, row_y + 24), "synth", font=font(10), + fill=(70, 90, 110)) + # arrow to next block + d.line([x1, row_y + box_h // 2, x1 + gap, + row_y + box_h // 2], + fill=OK if passed and not on else (55, 70, 88), + width=2) + + # flowing frame token: sits at the active block, glides to + # the next one during the second half of the stage + cx0 = centers[blk] + nxt = order[si + 1] if si + 1 < n else blk + t = fi / max(1, args.per - 1) + glide = max(0.0, (t - 0.5) * 2) + tx = cx0 + (centers[nxt] - cx0) * glide + ty = row_y + box_h + 36 + pr = 6 + 2 * math.sin(gi * 0.9) + d.ellipse([tx - pr, ty - pr, tx + pr, ty + pr], fill=AMBER) + d.text((tx - 22, ty + 12), "frame", font=font(10), fill=AMBER) + + # captions + cy = row_y + box_h + 78 + d.text((padL, cy), phase_label, font=font(12, True), + fill=WARN if phase else OK) + name, _, txd, rxd = BLOCKS[blk] + desc = txd if phase == 0 else rxd + d.text((padL, cy + 30), f"{si+1}. {name} — {desc}", + font=font(13, True), fill=CYAN) + d.text((padL, cy + 56), + "digital dies (MAC+BB) and analog die (RF) share one " + "package — the D-die / A-die split of the primer", + font=font(11), fill=DIM) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/chip_identity_gif.py b/tools/chip_identity_gif.py new file mode 100644 index 0000000..a1c9e99 --- /dev/null +++ b/tools/chip_identity_gif.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Animated chip identity — 'one driver binary, gated at runtime', in the +DEVOURER live-monitor style (docs/driver-primer.md §3). + + tools/chip_identity_gif.py -o docs/img/chip_identity.gif + +One vendor driver serves a whole family of boards and learns which one it is +standing on at runtime: chip id + cut from identity registers, RFE type and +board options from the efuse. The animation walks four boards — same driver, +different silicon and front-end wiring — filling an identity readout per board +and flipping the config gates (per-RFE table blocks, per-cut fixes, firmware +image, AGC table variant) that this identity drives. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# boards: (marketing name, chip id, cut, rfe, chains, n front-end doodads, +# gate states) — gates: (rfe=1 block, B-cut fix, fw image, agc table) +BOARDS = [ + ("dongle A", "0x08 (8812A)", "B", 0, "2T2R", 2, + (None, "B-cut RF fix", "8812a_fw.bin", "type 0")), + ("dongle B", "0x0A (8822B)", "D", 1, "2T2R", 3, + ("rfe=1 block", None, "8822b_fw.bin", "type 1")), + ("dongle C", "0x09 (8821C)", "A", 2, "1T1R", 1, + (None, None, "8821c_fw.bin", "type 2")), + ("dongle D", "0x51 (8852B)", "C", 5, "2T2R", 4, + (None, None, "8852b Ccut RAM", "type 5")), +] +GATES = ("PHY TABLE BLOCK", "PER-CUT FIX", "FIRMWARE IMAGE", "AGC TABLE") +FIELDS = ("chip id", "cut", "rfe type", "chains") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="chip_identity.gif") + ap.add_argument("--per", type=int, default=22) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x1D) + + W, H = 900, 452 + bx0, bw, bgap = 40, 190, 22 + board_y0, board_y1 = 92, 178 + id_y0, id_y1 = 196, 300 + gate_y0, gate_y1 = 316, 388 + + # deterministic doodad layouts per board (front-end parts differ) + doodads = [] + for _, _, _, _, _, nd, _ in BOARDS: + pts = [(80 + rnd.randint(0, 68), 30 + rnd.randint(0, 34)) + for _ in range(nd)] + doodads.append(pts) + + imgs = [] + for bi, (bname, cid, cut, rfe, chains, nd, gates) in enumerate(BOARDS): + for fi in range(args.per): + gi = bi * args.per + fi + img, d = new_frame(W, H) + chrome(d, W, H, "CHIP IDENTITY", + "one driver, many chips: chip id + cut + RFE type gate " + "everything at runtime", gi) + + # ---- boards row + for j, (nm, _, _, _, _, ndj, _) in enumerate(BOARDS): + x0 = bx0 + j * (bw + bgap) + on = j == bi + oc = CYAN if on else (55, 70, 88) + d.rectangle([x0, board_y0, x0 + bw, board_y1], + outline=oc, width=2 if on else 1) + d.text((x0 + 8, board_y0 + 6), nm, font=font(11, True), + fill=INK if on else DIM) + # the same chip on every board + cc = AMBER if on else (90, 80, 40) + d.rectangle([x0 + 12, board_y0 + 28, x0 + 68, board_y0 + 62], + outline=cc) + d.text((x0 + 20, board_y0 + 38), "chip", font=font(10), + fill=cc) + # different front-end doodads (PA/LNA/switch parts) + for (dx, dy) in doodads[j]: + dc = OK if on else (40, 70, 55) + d.rectangle([x0 + dx, board_y0 + dy, + x0 + dx + 16, board_y0 + dy + 10], + outline=dc) + # antenna stub + ax = x0 + bw - 18 + d.line([ax, board_y0 + 30, ax, board_y0 + 58], + fill=oc, width=1) + d.line([ax - 6, board_y0 + 38, ax, board_y0 + 30], + fill=oc, width=1) + + # ---- identity readout (typed in progressively) + d.rectangle([bx0, id_y0, bx0 + 470, id_y1], + outline=(0, 110, 120)) + d.text((bx0 + 12, id_y0 + 8), "IDENTITY READOUT", + font=font(11, True), fill=CYAN) + vals = (cid, f"{cut}-cut", str(rfe), chains) + reveal = min(1.0, fi / (args.per * 0.5)) # fills in first half + nshow = int(reveal * len(FIELDS) + 0.999) + for k, (fld, v) in enumerate(zip(FIELDS, vals)): + y = id_y0 + 30 + k * 18 + d.text((bx0 + 12, y), f"{fld:<9}:", font=font(11), fill=DIM) + if k < nshow: + d.text((bx0 + 100, y), v, font=font(11, True), fill=INK) + elif k == nshow: + d.text((bx0 + 100, y), "_" if gi % 2 else " ", + font=font(11, True), fill=AMBER) + # where identity comes from + sx = bx0 + 494 + d.rectangle([sx, id_y0, W - 38, id_y1], outline=(35, 45, 60)) + d.text((sx + 12, id_y0 + 8), "READ FROM", font=font(11, True), + fill=DIM) + for k, line in enumerate(("SYS_CFG reg : chip id, cut", + "efuse (§4) : RFE type,", + " board options", + "vendor: IS_C_CUT(), rfe_type==2")): + d.text((sx + 12, id_y0 + 32 + k * 17), line, font=font(10), + fill=DIM) + + # ---- config gates + open_now = fi >= args.per * 0.55 # flip after identity is read + for k, gname in enumerate(GATES): + x0 = bx0 + k * (bw + bgap) + gv = gates[k] + is_open = open_now and gv is not None + oc = OK if is_open else (55, 70, 88) + d.rectangle([x0, gate_y0, x0 + bw, gate_y1], + outline=oc, width=2 if is_open else 1) + d.text((x0 + 8, gate_y0 + 6), gname, font=font(10, True), + fill=INK if is_open else DIM) + if not open_now: + d.text((x0 + 8, gate_y0 + 28), "...", font=font(11), + fill=DIM) + elif is_open: + d.text((x0 + 8, gate_y0 + 26), "OPEN", font=font(10, True), + fill=OK) + d.text((x0 + 8, gate_y0 + 44), gv, font=font(10), + fill=AMBER) + else: + d.text((x0 + 8, gate_y0 + 26), "closed", font=font(10), + fill=(70, 90, 110)) + d.text((x0 + 8, gate_y0 + 44), "skipped", font=font(10), + fill=(70, 90, 110)) + + # ---- caption + if bi == len(BOARDS) - 1 and open_now: + cap = "one driver binary — every board gated at runtime" + elif not open_now: + cap = (f"board {bi+1}/4: reading identity registers + efuse " + f"on {bname}") + else: + cap = (f"board {bi+1}/4: {cid.split()[1]} {cut}-cut rfe={rfe}" + f" — gates flipped for this exact board") + d.text((bx0, 412), cap, font=font(13, True), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/coex_gif.py b/tools/coex_gif.py new file mode 100644 index 0000000..bef365c --- /dev/null +++ b/tools/coex_gif.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Animated coexistence arbitration — 'two radios, one antenna', in the +DEVOURER live-monitor style. + + tools/coex_gif.py -o docs/img/coex.gif + +One antenna feeds a Wi-Fi/BT combo chip; a hardware TDMA arbiter time-slices +it between the two radios, slot by slot — the granted radio's path lights up, +the loser is blocked. The policy lives in firmware, steered over H2C and +reporting back over C2H. Three phases: BT idle (Wi-Fi owns nearly every slot), +a BT voice link appears (the firmware swaps the policy table and BT wins its +periodic slots), then an external LTE modem asserts the coex handshake and +2.4 GHz slots get blanked outright. Keep the firmware's coex state fed, or it +will keep the antenna. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +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) + +NSLOT = 26 +BLUE = (90, 150, 245) # BT +LTEC = (170, 120, 235) # LTE + +# (key, frames, caption) +PHASES = [ + ("a", 26, "(a) BT idle — Wi-Fi owns almost every antenna slot"), + ("b", 32, "(b) BT voice link — firmware swaps the policy table, " + "BT wins its slots"), + ("c", 32, "(c) LTE coex — the modem asserts the handshake, " + "2.4 GHz slots get blanked"), + ("d", 12, "shared spectrum, negotiated time — keep the firmware's coex " + "state fed or it will keep the antenna"), +] + + +def slot_owner(phase, s): + """'W' wifi, 'B' bt, 'X' blanked-for-LTE.""" + if phase == "a": + return "B" if s in (7, 19) else "W" # BT page scan only + if phase == "b": + return "W" if s % 3 == 0 else "B" # voice: BT wins 2 of 3 + # phase c/d: voice pattern + LTE-protected slots + if s % 9 in (4, 5): + return "X" + return "W" if s % 3 == 0 else "B" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="coex.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + + W, H = 900, 512 + padL = 34 + # left half: antenna + combo chip + LTE box + antx, anty = 260, 118 # antenna tip + juncy = 178 # feed junction + chip = [70, 208, 500, 290] # combo chip outline + wifi = [92, 226, 218, 272] + bt = [352, 226, 478, 272] + lte = [44, 108, 164, 152] # external modem (dashed) + # right half: host + fw policy panel + host = [560, 104, 866, 140] + pol = [560, 168, 866, 288] + # arbiter bar + bX, bY, bW, bH = 60, 330, 780, 40 + + imgs = [] + gi = 0 + for (phase, nfr, caption) in PHASES: + for fi in range(nfr): + img, d = new_frame(W, H) + chrome(d, W, H, "COEX ARBITRATION", + "one antenna, two radios: a fw policy machine grants TDMA " + "slots — H2C steers it, C2H reports back", gi) + + cur = (fi * 2) % NSLOT # cursor slot this frame + owner = slot_owner(phase, cur) + has_lte = phase in ("c", "d") + pol_new = phase == "b" and fi < 12 # table just swapped + + # ---- antenna ------------------------------------------------ + d.line([antx, anty + 14, antx, juncy], fill=INK, width=2) + d.line([antx - 14, anty, antx, anty + 14], fill=INK, width=2) + d.line([antx + 14, anty, antx, anty + 14], fill=INK, width=2) + if owner != "X": + r = 8 + (gi % 3) * 5 + d.arc([antx - r, anty - r + 6, antx + r, anty + r + 6], + 200, 340, fill=AMBER) + d.text((antx + 24, anty - 2), "one antenna", font=font(10), + fill=DIM) + if owner == "X": + d.text((antx + 24, anty + 12), "(blanked)", font=font(10), + fill=LTEC) + + # ---- combo chip + radios -------------------------------------- + d.rectangle(chip, outline=(0, 90, 100)) + d.text((chip[0] + 6, chip[3] + 4), "WiFi/BT combo chip", + font=font(10), fill=DIM) + won = {"W": owner == "W", "B": owner == "B"} + for box, key, name, col in ((wifi, "W", "WIFI", CYAN), + (bt, "B", "BT", BLUE)): + lit = won[key] + oc = col if lit else (55, 70, 88) + d.rectangle(box, outline=oc, width=2 if lit else 1) + d.text((box[0] + 12, box[1] + 14), name, + font=font(13, True), fill=col if lit else DIM) + if lit: + d.text((box[0] + 62, box[1] + 16), "TX/RX", + font=font(10), fill=col) + # path to the antenna junction + mx = (box[0] + box[2]) // 2 + pc = col if lit else (40, 52, 66) + d.line([mx, box[1], mx, juncy], fill=pc, + width=3 if lit else 1) + d.line([mx, juncy, antx, juncy], fill=pc, + width=3 if lit else 1) + if not lit and owner != "X": + d.text((mx + 6, juncy + 16), "blocked", font=font(10), + fill=(90, 70, 70)) + d.line([antx, juncy, antx, juncy - 0], fill=INK) + # arbiter tag inside the chip + d.text((252, 234), "TDMA", font=font(10, True), fill=AMBER) + d.text((246, 248), "arbiter", font=font(10), fill=AMBER) + + # ---- external LTE modem --------------------------------------- + if has_lte: + # dashed box + x0, y0, x1, y1 = lte + for x in range(x0, x1, 8): + d.line([x, y0, min(x + 4, x1), y0], fill=LTEC) + d.line([x, y1, min(x + 4, x1), y1], fill=LTEC) + for y in range(y0, y1, 8): + d.line([x0, y, x0, min(y + 4, y1)], fill=LTEC) + d.line([x1, y, x1, min(y + 4, y1)], fill=LTEC) + d.text((x0 + 14, y0 + 6), "LTE modem", font=font(11, True), + fill=LTEC) + d.text((x0 + 14, y0 + 22), "(external)", font=font(10), + fill=DIM) + # handshake line down to the chip edge + hx = x0 + 26 + on = (gi % 4) < 2 + d.line([hx, y1, hx, chip[1]], fill=LTEC, + width=3 if on else 1) + d.text((hx + 8, y1 + 16), "handshake", font=font(10, True), + fill=LTEC) + d.text((hx + 8, y1 + 30), "BUSY", font=font(10, True), + fill=LTEC) + + # ---- host + H2C/C2H + fw policy table ------------------------- + d.rectangle(host, outline=INK) + d.text((host[0] + 12, host[1] + 10), "host driver", + font=font(12, True), fill=INK) + d.rectangle(pol, outline=(0, 90, 100)) + d.text((pol[0] + 12, pol[1] + 8), "FW COEX POLICY", + font=font(11, True), fill=CYAN) + # H2C down / C2H up arrows between host and policy panel + hx1, hx2 = pol[0] + 90, pol[0] + 210 + pulse = (gi % 4) < 2 + h2c_hot = pol_new or pulse + d.line([hx1, host[3], hx1, pol[1]], fill=AMBER if h2c_hot else DIM, + width=2 if h2c_hot else 1) + d.polygon([(hx1, pol[1]), (hx1 - 4, pol[1] - 7), + (hx1 + 4, pol[1] - 7)], + fill=AMBER if h2c_hot else DIM) + d.text((hx1 - 78, host[3] + 6), "H2C policy", font=font(10), + fill=AMBER if h2c_hot else DIM) + c2h_hot = not pulse + d.line([hx2, pol[1], hx2, host[3]], fill=OK if c2h_hot else DIM, + width=2 if c2h_hot else 1) + d.polygon([(hx2, host[3]), (hx2 - 4, host[3] + 7), + (hx2 + 4, host[3] + 7)], fill=OK if c2h_hot else DIM) + d.text((hx2 + 10, host[3] + 6), "C2H telemetry", font=font(10), + fill=OK if c2h_hot else DIM) + + # policy rows (phase-dependent, changed rows flash amber) + if phase == "a": + rows = [("BT profile", "idle", False), + ("policy case", "WLAN only", False), + ("slot ratio", "W 92% : B 8%", False), + ("tdma table", "0x61", False)] + else: + rows = [("BT profile", "HFP voice", pol_new), + ("policy case", "BT-audio priority", pol_new), + ("slot ratio", "W 33% : B 67%", pol_new), + ("tdma table", "0x65", pol_new)] + if has_lte: + rows.append(("LTE", "protect 2.4 GHz", phase == "c" + and fi < 12)) + for j, (k, v, hot) in enumerate(rows): + ry = pol[1] + 30 + j * 18 + d.text((pol[0] + 12, ry), k, font=font(11), fill=DIM) + d.text((pol[0] + 130, ry), v, font=font(11, True), + fill=AMBER if hot else INK) + + # ---- TDMA arbiter bar ------------------------------------------ + d.text((bX, bY - 18), "TDMA slot grants →", font=font(11), + fill=DIM) + sw = bW / NSLOT + for s in range(NSLOT): + o = slot_owner(phase, s) + x0 = bX + s * sw + col = {"W": (0, 120, 130), "B": (55, 85, 150), + "X": (32, 26, 44)}[o] + d.rectangle([x0 + 1, bY, x0 + sw - 1, bY + bH], fill=col, + outline=LTEC if o == "X" else None) + lab = {"W": "W", "B": "B", "X": "·"}[o] + lc = {"W": CYAN, "B": BLUE, "X": (100, 100, 120)}[o] + d.text((x0 + sw / 2 - 4, bY + 13), lab, font=font(12, True), + fill=lc) + # cursor + cx0 = bX + cur * sw + d.rectangle([cx0, bY - 3, cx0 + sw, bY + bH + 3], + outline=INK, width=2) + # legend + ly = bY + bH + 12 + for lx, col, txt in ((bX, CYAN, "Wi-Fi slot"), + (bX + 130, BLUE, "BT slot"), + (bX + 240, LTEC, "blanked for LTE")): + d.rectangle([lx, ly + 2, lx + 10, ly + 12], fill=col) + d.text((lx + 16, ly), txt, font=font(10), fill=DIM) + + # ---- caption ----------------------------------------------------- + d.text((padL, H - 32), caption, font=font(12, True), fill=CYAN) + imgs.append(img) + gi += 1 + + save_gif(imgs, args.out, ms=args.ms, colors=64) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/dig_loop_gif.py b/tools/dig_loop_gif.py new file mode 100644 index 0000000..85e65b6 --- /dev/null +++ b/tools/dig_loop_gif.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Animated DIG tracking loop — 'BB DM, AGC and the initial-gain staircase', +in the DEVOURER live-monitor style. + + tools/dig_loop_gif.py -o docs/img/dig_loop.gif + +A scrolling time series: the noise floor drifts, then a wideband interferer +arrives and leaves. The DIG staircase (the AGC's starting gain, the IGI value) +tracks it — but only at the ~2 s watchdog cadence and with hysteresis, so it +lags. While the floor sits above the threshold the false-alarm counter strip +burns red (the receiver triggers on noise); when the floor drops and DIG is +still parked high, a weak frame slips past undetected. Side gauges show the +live IGI readout and CCA busy %. This is phydm's original job description. +Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +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) + +FS = 10 # samples per second +TICK = 2 * FS # watchdog tick every ~2 s +MARGIN = 6 # DIG aims IGI threshold ~6 dB above the floor + + +def build_timeline(rnd, T): + """Per-sample (floor dBm, igi-threshold dBm, false-alarm rate 0..1).""" + floor = [] + for i in range(T): + base = -62.0 + 1.5 * math.sin(i / 37.0) # slow drift + if 140 <= i < 225: # interferer on + k = min(1.0, (i - 140) / 5.0) # sharp rise + base += 14.0 * k + elif 225 <= i < 231: # sharp fall + base += 14.0 * (1.0 - (i - 225) / 6.0) + floor.append(base + rnd.uniform(-0.7, 0.7)) + + igi, thr = [], 0x2A # IGI in hw units + for i in range(T): + if i % TICK == 0 and i > 0: + target = floor[i] + MARGIN # wanted threshold + cur = -96 + thr # IGI unit -> dBm + if target - cur > 3: # hysteresis band + thr = min(0x3E, thr + 4) # step up fast + elif cur - target > 8: + thr = max(0x1C, thr - 4) # step down slower + elif cur - target > 5: + thr = max(0x1C, thr - 2) + igi.append(thr) + + fa = [] + for i in range(T): + gap = floor[i] - (-96 + igi[i] - 4) # floor vs threshold + v = max(0.0, min(1.0, gap / 8.0 + 0.06)) + fa.append(v + rnd.uniform(0, 0.05)) + return floor, igi, fa + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="dig_loop.gif") + ap.add_argument("--ms", type=int, default=105) + ap.add_argument("--frames", type=int, default=100) + args = ap.parse_args() + rnd = random.Random(0xD16) + + N, STEP = 120, 2 # window samples, samples/frame + T = N + args.frames * STEP + floor, igi, fa = build_timeline(rnd, T) + # weak-frame events: (sample, rssi dBm) — arrive just after the interferer + weak = [(245, -52.0), (305, -52.5)] + + W, H = 900, 500 + padL = 34 + pX, pY, pW, pH = padL, 110, 620, 200 # main dB panel + fX, fY, fW, fH = padL, 368, 620, 56 # false-alarm strip + gX = 690 # gauges column + DBTOP, DBBOT = -38.0, -72.0 + + def ydb(v): + v = max(DBBOT, min(DBTOP, v)) + return pY + (DBTOP - v) / (DBTOP - DBBOT) * pH + + imgs = [] + for fi in range(args.frames): + s0 = fi * STEP # window = samples [s0, s0+N) + img, d = new_frame(W, H) + chrome(d, W, H, "DIG TRACKING LOOP", + "BB dynamic mechanisms: the ~2 s watchdog steps the AGC's " + "initial gain (IGI) after the noise floor", fi) + + # ---- main panel: floor trace + DIG staircase ---------------------- + d.rectangle([pX, pY, pX + pW, pY + pH], outline=(0, 70, 80)) + for db in (-40, -50, -60, -70): + y = ydb(db) + d.line([pX + 1, y, pX + pW - 1, y], fill=GRID) + d.text((pX + pW + 6, y - 6), f"{db}", font=font(10), fill=DIM) + d.text((pX, pY - 16), "noise floor (dBm) vs DIG threshold", + font=font(11), fill=DIM) + + def sx(i): # sample index -> panel x + return pX + (i - s0) / (N - 1) * pW + + fpts = [(sx(i), ydb(floor[i])) for i in range(s0, s0 + N)] + d.line(fpts, fill=INK, width=1) + tpts = [(sx(i), ydb(-96 + igi[i] - 4)) for i in range(s0, s0 + N)] + d.line(tpts, fill=AMBER, width=2) + # legend (top-left of panel, kept clear of traces) + d.line([pX + 10, pY + 12, pX + 30, pY + 12], fill=INK) + d.text((pX + 36, pY + 6), "noise floor", font=font(10), fill=DIM) + d.line([pX + 130, pY + 12, pX + 150, pY + 12], fill=AMBER, width=2) + d.text((pX + 156, pY + 6), "DIG threshold (IGI)", font=font(10), + fill=AMBER) + + # watchdog tick markers on the time axis + for i in range(s0 - s0 % TICK, s0 + N, TICK): + if i < s0: + continue + x = sx(i) + d.polygon([(x, pY + pH + 4), (x - 4, pY + pH + 12), + (x + 4, pY + pH + 12)], fill=CYAN) + d.text((pX, pY + pH + 16), "▲ watchdog tick (~2 s) — DIG only steps " + "here", font=font(10), fill=DIM) + + # weak-frame events: caught if rssi clears the DIG threshold + for wj, (wi, rssi) in enumerate(weak): + if not (s0 <= wi < s0 + N): + continue + x, y = sx(wi), ydb(rssi) + thr_dbm = -96 + igi[wi] - 4 + missed = rssi < thr_dbm + col = WARN if missed else OK + d.line([x, y - 6, x, y + 6], fill=col, width=2) + d.ellipse([x - 3, y - 3, x + 3, y + 3], outline=col, width=2) + lbl = "weak frame MISSED" if missed else "weak frame caught" + lx = max(pX + 6, min(x - 60, pX + pW - 140)) + ly = y - 24 if wj % 2 == 0 else y + 14 + d.text((lx, ly), lbl, font=font(10, True), fill=col) + + # ---- false-alarm strip -------------------------------------------- + d.rectangle([fX, fY, fX + fW, fY + fH], outline=(0, 70, 80)) + d.text((fX, fY - 16), "false-alarm counter (CCA triggers on noise)", + font=font(11), fill=DIM) + for i in range(s0, s0 + N, 2): + v = fa[i] + x = sx(i) + bh = int(v * (fH - 8)) + col = WARN if v > 0.45 else ((200, 140, 60) if v > 0.22 else + (40, 90, 70)) + d.rectangle([x, fY + fH - 4 - bh, x + 3, fY + fH - 4], fill=col) + + # ---- side gauges --------------------------------------------------- + cur = s0 + N - 1 + d.text((gX, pY - 16), "LIVE READOUT", font=font(12), fill=CYAN) + y = pY + 8 + + def gauge(lbl, val, col=INK): + nonlocal y + d.text((gX, y), lbl, font=font(11), fill=DIM) + d.text((gX, y + 14), val, font=font(18, True), fill=col) + y += 52 + + favg = sum(fa[cur - 6:cur + 1]) / 7 + cca = min(96, int(8 + 80 * max(0.0, (floor[cur] + 63) / 16))) + gauge("IGI (initial gain)", f"0x{igi[cur]:02X}", AMBER) + gauge("noise floor", f"{floor[cur]:5.1f} dBm") + gauge("false alarms", f"{int(favg * 800):4d}/s", + WARN if favg > 0.45 else (AMBER if favg > 0.22 else OK)) + gauge("CCA busy", f"{cca:3d} %", + WARN if cca > 60 else (AMBER if cca > 30 else OK)) + # status pill + missed_now = any(s0 <= wi < s0 + N and rs < (-96 + igi[wi] - 4) + for wi, rs in weak) + if favg > 0.45: + st, sc = "FALSE-ALARM STORM", WARN + elif missed_now and floor[cur] < -58 and igi[cur] > 0x30: + st, sc = "DEAF: IGI HIGH", AMBER + else: + st, sc = "TRACKING", OK + d.rectangle([gX, y, gX + 176, y + 28], outline=sc, width=2) + d.text((gX + 10, y + 6), st, font=font(12, True), fill=sc) + + # ---- caption --------------------------------------------------------- + t_now = cur + if t_now < 142: + cap = "quiet band — DIG parks the initial gain just above the floor" + elif t_now < 185: + cap = "interferer raises the floor — false alarms burn until the " \ + "watchdog steps DIG up" + elif t_now < 233: + cap = "DIG stepped up: false alarms settle — bought by giving up " \ + "sensitivity" + elif t_now < 302: + cap = "interferer gone, but DIG lags high — a weak frame slips past" + else: + cap = "DIG watches noise + false alarms, and steps the AGC's " \ + "starting gain" + d.text((padL, H - 34), cap, font=font(13, True), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/efuse_map_gif.py b/tools/efuse_map_gif.py new file mode 100644 index 0000000..0b1cbc2 --- /dev/null +++ b/tools/efuse_map_gif.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Animated efuse decode — physical chain to logical map, in the DEVOURER +live-monitor style (docs/driver-primer.md §4). + + tools/efuse_map_gif.py -o docs/img/efuse_map.gif + +The efuse is a one-time-programmable memory burned at the factory: not a flat +structure but a chain of headers, each saying 'the next few bytes belong at +logical offset N, and only these words are present'. The animation walks the +chain entry by entry — header highlighted and decoded on the left, its data +bytes flying into the fixed-layout logical map on the right (MAC address, +crystal trim, TX power base, RFE type, thermal baseline). Fusing is +0→1-bits-only, so the final entry is an appended *patch* that overrides an +earlier slot — how updates work on write-once silicon. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# region tints for the logical-map cells +T_MAC = (0, 62, 72) +T_XTAL = (72, 60, 18) +T_PWR = (16, 60, 40) +T_MISC = (58, 38, 66) + +# physical chain: (hdr hex, decode text, logical offset, data bytes, patch?) +ENTRIES = [ + ("07", "hdr: offset 0x00, words 1111", + 0x00, ["00", "E0", "4C", "88", "12", "AA", "00", "03"], False), + ("18", "hdr: offset 0x08, words 0001", 0x08, ["20", "00"], False), + ("29", "hdr: offset 0x10, words 1111", + 0x10, ["2D", "2D", "2C", "2A", "28", "26", "24", "22"], False), + ("3C", "hdr: offset 0x18, words 0011", + 0x18, ["02", "00", "1A", "00"], False), + ("18", "hdr: offset 0x08, words 0001 (PATCH)", + 0x08, ["1E", "00"], True), +] +# logical rows shown in the grid (sparse map — most of it is empty) +ROWS = (0x00, 0x08, 0x10, 0x18) +REGION = {} # logical offset -> tint +for o in range(0x00, 0x06): + REGION[o] = T_MAC +REGION[0x08] = T_XTAL +for o in range(0x10, 0x18): + REGION[o] = T_PWR +REGION[0x18] = T_MISC +REGION[0x1A] = T_MISC + +LEGEND = [ # (text, tint, appears after entry idx) + ("MAC ADDR 0x00-0x05", T_MAC, 0), + ("XTAL TRIM 0x08", T_XTAL, 1), + ("TX PWR BASE 0x10-0x17", T_PWR, 2), + ("RFE TYPE 0x18 THERMAL 0x1A", T_MISC, 3), +] + +CAPTIONS = [ + "each header says where the next bytes belong", + "the logical map materializes slot by slot", + "per-rate TX power base — this unit's calibration", + "RFE type + thermal baseline — board options", + "0->1-only fusing: updates are appended patches", +] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="efuse_map.gif") + ap.add_argument("--per", type=int, default=17) + ap.add_argument("--tail", type=int, default=8) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0xEF) + + W, H = 900, 452 + # left: physical chain + lx0 = 40 + ent_y0, ent_dy = 118, 56 + # right: logical grid + gx0, gy0 = 508, 118 + cw, ch = 44, 32 + + def cell_xy(off): + row = ROWS.index(off & ~0x07) + col = off & 0x07 + return gx0 + col * cw, gy0 + row * ch + + filled = {} # logical offset -> hex value (mutates as we replay) + imgs = [] + n_ent = len(ENTRIES) + total_states = [] # (entry idx, frame-in-entry) plus tail + for ei in range(n_ent): + for fi in range(args.per): + total_states.append((ei, fi)) + for fi in range(args.tail): + total_states.append((n_ent, fi)) + + for gi, (ei, fi) in enumerate(total_states): + done = ei >= n_ent + # recompute fill state deterministically for this frame + filled = {} + patched = set() + for k in range(n_ent): + hdr, dec, off, data, patch = ENTRIES[k] + if k < ei: + nby = len(data) + elif k == ei and not done: + # bytes land during frames 5..12 + nby = max(0, min(len(data), int((fi - 5) / 8 * len(data) + 1) + if fi >= 5 else 0)) + else: + nby = 0 + for b in range(nby): + o = off + b + if o in REGION: + if o in filled and patch: + patched.add(o) + filled[o] = data[b] + + img, d = new_frame(W, H) + chrome(d, W, H, "EFUSE", + "the chip's birth certificate: a burned chain of headers -> " + "a fixed logical map", gi) + + # ---- left: physical chain + d.text((lx0, 92), "PHYSICAL EFUSE (burned bytes)", font=font(11, True), + fill=INK) + for k, (hdr, dec, off, data, patch) in enumerate(ENTRIES): + y = ent_y0 + k * ent_dy + active = k == ei and not done + past = k < ei or done + oc = CYAN if active else ((55, 70, 88) if not past else (0, 90, 100)) + d.rectangle([lx0, y, lx0 + 420, y + 24], outline=oc, + width=2 if active else 1) + hc = AMBER if (active or past) else DIM + d.text((lx0 + 8, y + 5), hdr, font=font(12, True), fill=hc) + d.text((lx0 + 34, y + 5), "|", font=font(12), fill=(55, 70, 88)) + for b, hx in enumerate(data): + bc = INK if (active or past) else DIM + d.text((lx0 + 50 + b * 30, y + 5), hx, font=font(12), + fill=bc) + # decode line under the active/past entry + if active and fi >= 2: + d.text((lx0 + 8, y + 28), dec, font=font(11, True), + fill=WARN if patch else AMBER) + elif past: + d.text((lx0 + 8, y + 28), dec, font=font(10), + fill=(90, 75, 40) if not patch else (110, 50, 40)) + + # ---- right: logical map grid + d.text((gx0, 92), "LOGICAL MAP", font=font(11, True), fill=INK) + for r, base in enumerate(ROWS): + d.text((gx0 - 44, gy0 + r * ch + 9), f"0x{base:02X}", + font=font(10), fill=DIM) + for c in range(8): + o = base + c + x, y = cell_xy(o) + tint = REGION.get(o) + if o in filled: + flash = (o in patched and not done and ei == n_ent - 1 + and gi % 2 == 0) + oc = WARN if (o in patched) else (100, 120, 140) + d.rectangle([x, y, x + cw - 4, y + ch - 4], + fill=tint, outline=oc, + width=2 if flash else 1) + d.text((x + 12, y + 8), filled[o], font=font(11, True), + fill=INK) + else: + d.rectangle([x, y, x + cw - 4, y + ch - 4], + outline=(35, 45, 60)) + d.text((x + 12, y + 8), "--", font=font(11), + fill=(50, 62, 78)) + # flying bytes: from the active entry line to their target cells + if not done and 5 <= fi <= 12: + hdr, dec, off, data, patch = ENTRIES[ei] + t = (fi - 5) / 7 + b = min(len(data) - 1, int(t * len(data))) + sx = lx0 + 50 + b * 30 + sy = ent_y0 + ei * ent_dy + 12 + txc, tyc = cell_xy(off + b) if (off + b) in REGION \ + else cell_xy(off) + frac = (fi - 5) / 7 % 1 + mx = sx + (txc + 12 - sx) * frac + my = sy + (tyc + 10 - sy) * frac + d.text((mx, my), data[b], font=font(11, True), fill=AMBER) + + # legend under the grid + ly = gy0 + len(ROWS) * ch + 14 + shown = 0 + for text, tint, after in LEGEND: + if ei > after or done: + d.rectangle([gx0, ly + shown * 20 + 2, + gx0 + 12, ly + shown * 20 + 14], fill=tint) + d.text((gx0 + 20, ly + shown * 20), text, font=font(10), + fill=DIM) + shown += 1 + if done or (ei == n_ent - 1 and fi > 12): + d.text((gx0, ly + shown * 20 + 2), + "XTAL 0x20 -> 0x1E: later entry wins", font=font(10, True), + fill=WARN) + + # ---- caption + cap = CAPTIONS[min(ei, len(CAPTIONS) - 1)] + d.text((lx0, 414), cap, font=font(13, True), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=64) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/fwdl_gif.py b/tools/fwdl_gif.py new file mode 100644 index 0000000..2c57bfb --- /dev/null +++ b/tools/fwdl_gif.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Animated FWDL — 'installing the firmware into the WCPU', in the DEVOURER +live-monitor style. + + tools/fwdl_gif.py -o docs/img/fwdl.gif + +The firmware blob ships inside the driver. FWDL parses it into sections (a +header, then instruction- and data-memory images), streams each section through +the chip's download window into the WCPU's IMEM/DMEM banks, verifies a +checksum, releases the WCPU from reset, and polls a ready latch until the +firmware announces itself alive. The animation walks the happy path — if any +stage failed, the chip would simply be silent. 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, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# (label, bank) — bank None = header, consumed at the window. +SECTIONS = [ + ("HDR", None), + ("IMEM S0", "imem"), + ("IMEM S1", "imem"), + ("DMEM S0", "dmem"), + ("DMEM S1", "dmem"), +] + +W, H = 900, 470 +SEC_X0, SEC_X1 = 40, 250 # left column: blob / section boxes +SEC_H, SEC_GAP = 38, 10 +SEC_Y0 = 116 +WIN_X0, WIN_X1, WIN_Y0, WIN_Y1 = 318, 428, 200, 244 # download window +CPU_X0, CPU_X1, CPU_Y0, CPU_Y1 = 470, 862, 100, 388 # WCPU box +BAR_X0, BAR_X1 = 588, 832 +IMEM_Y0, IMEM_Y1 = 146, 168 +DMEM_Y0, DMEM_Y1 = 186, 208 +CHK_Y0, CHK_Y1 = 232, 260 +CORE = (500, 286, 664, 348) +LATCH = (694, 286, 832, 348) +CAP_Y = 432 + + +def sec_rect(i): + y = SEC_Y0 + i * (SEC_H + SEC_GAP) + return (SEC_X0, y, SEC_X1, y + SEC_H) + + +def lerp(a, b, t): + return a + (b - a) * t + + +def draw_section(d, cx, cy, label, hot=False): + w, h = (SEC_X1 - SEC_X0) / 2, SEC_H / 2 + d.rectangle([cx - w, cy - h, cx + w, cy + h], + outline=AMBER if hot else (90, 110, 135), + fill=(30, 26, 10) if hot else (16, 20, 30), width=2) + d.text((cx - w + 10, cy - 8), label, font=font(12, True), + fill=AMBER if hot else INK) + + +def draw_bank(d, name, y0, y1, frac, active): + d.text((508, y0 + 2), name, font=font(12, True), + fill=CYAN if active else DIM) + d.rectangle([BAR_X0, y0, BAR_X1, y1], outline=(70, 90, 110)) + if frac > 0: + d.rectangle([BAR_X0 + 2, y0 + 2, + BAR_X0 + 2 + (BAR_X1 - BAR_X0 - 4) * min(frac, 1.0), + y1 - 2], fill=(0, 110, 120) if frac < 1 else OK) + d.text((BAR_X1 - 44, y0 + 3), f"{int(frac * 100):3d}%", font=font(11), + fill=INK if frac > 0 else DIM) + + +def draw_tick(d, x, y, col=OK): + d.line([x, y + 5, x + 5, y + 10], fill=col, width=3) + d.line([x + 5, y + 10, x + 14, y - 2], fill=col, width=3) + + +def draw_scene(d, tick, *, split, moving, done_secs, imem, dmem, + chk, core_on, ready, poll_x, caption): + """One frame of the whole scene. moving = (label, cx, cy, hot) or None.""" + # ---- left: blob or the split section stack ------------------------- + d.text((SEC_X0, 92), "firmware blob (driver)", font=font(11), fill=DIM) + if not split: + top, bot = sec_rect(0), sec_rect(len(SECTIONS) - 1) + d.rectangle([SEC_X0, top[1], SEC_X1, bot[3]], + outline=(90, 110, 135), fill=(16, 20, 30), width=2) + d.text((SEC_X0 + 14, (top[1] + bot[3]) // 2 - 8), "RTL FW IMAGE", + font=font(13, True), fill=INK) + else: + for i, (label, _) in enumerate(SECTIONS): + if i in done_secs or (moving and moving[0] == label): + x0, y0, x1, y1 = sec_rect(i) # gone: ghost outline + d.rectangle([x0, y0, x1, y1], outline=(38, 48, 62)) + continue + x0, y0, x1, y1 = sec_rect(i) + draw_section(d, (x0 + x1) / 2, (y0 + y1) / 2, label) + + # ---- middle: download window --------------------------------------- + d.text((WIN_X0, WIN_Y0 - 22), "download window", font=font(11), fill=DIM) + win_hot = moving is not None + d.rectangle([WIN_X0, WIN_Y0, WIN_X1, WIN_Y1], + outline=CYAN if win_hot else (70, 90, 110), width=2) + for k in range(3): # port slats + yy = WIN_Y0 + 10 + k * 12 + d.line([WIN_X0 + 12, yy, WIN_X1 - 12, yy], + fill=(0, 120, 130) if win_hot else (40, 55, 70), width=2) + + # ---- right: WCPU box ------------------------------------------------ + d.rectangle([CPU_X0, CPU_Y0, CPU_X1, CPU_Y1], outline=(0, 110, 120), + width=2) + d.text((CPU_X0 + 14, CPU_Y0 + 10), "WCPU", font=font(14, True), fill=CYAN) + d.text((CPU_X0 + 76, CPU_Y0 + 13), "(MCU inside the MAC)", font=font(10), + fill=DIM) + draw_bank(d, "IMEM", IMEM_Y0, IMEM_Y1, imem, + moving is not None and moving[3] == "imem") + draw_bank(d, "DMEM", DMEM_Y0, DMEM_Y1, dmem, + moving is not None and moving[3] == "dmem") + + # checksum row + ch_col = OK if chk else (70, 90, 110) + d.rectangle([500, CHK_Y0, BAR_X1, CHK_Y1], outline=ch_col, width=2) + d.text((512, CHK_Y0 + 6), "CHECKSUM", font=font(12, True), + fill=OK if chk else DIM) + if chk: + draw_tick(d, 620, CHK_Y0 + 8) + d.text((648, CHK_Y0 + 6), "OK", font=font(12, True), fill=OK) + else: + d.text((620, CHK_Y0 + 6), "pending...", font=font(11), fill=DIM) + + # WCPU core + cc = CYAN if core_on else (55, 70, 88) + d.rectangle(CORE, outline=cc, width=2, + fill=(0, 44, 50) if core_on else (14, 18, 26)) + d.text((CORE[0] + 12, CORE[1] + 8), "WCPU CORE", font=font(12, True), + fill=CYAN if core_on else DIM) + d.text((CORE[0] + 12, CORE[1] + 30), + "RUNNING" if core_on else "IN RESET", font=font(11), + fill=CYAN if core_on else WARN) + + # FW READY latch + if ready == 2: + lc, led, txt = OK, OK, "SET" + elif ready == 1: + blink = tick % 4 < 2 + lc = (70, 90, 110) + led = AMBER if blink else (60, 55, 25) + txt = "poll..." + else: + lc, led, txt = (70, 90, 110), (40, 50, 62), "clear" + d.rectangle(LATCH, outline=lc, width=2) + d.text((LATCH[0] + 12, LATCH[1] + 8), "FW READY", font=font(12, True), + fill=OK if ready == 2 else DIM) + d.ellipse([LATCH[0] + 14, LATCH[1] + 32, LATCH[0] + 28, LATCH[1] + 46], + fill=led) + d.text((LATCH[0] + 38, LATCH[1] + 31), txt, font=font(11), + fill=OK if ready == 2 else DIM) + if poll_x is not None: # host poll pulse (dashed) + py = (LATCH[1] + LATCH[3]) // 2 + 62 + for xx in range(SEC_X0, CPU_X0 - 16, 14): + d.line([xx, py, xx + 7, py], fill=(0, 80, 90)) + d.ellipse([poll_x - 4, py - 4, poll_x + 4, py + 4], fill=AMBER) + d.text((SEC_X0, py + 10), "host polls the ready bit over USB", + font=font(10), fill=DIM) + + # flying section + if moving: + label, cx, cy, _bank = moving + draw_section(d, cx, cy, label, hot=True) + + # caption + d.text((SEC_X0, CAP_Y), caption, font=font(13, True), fill=CYAN) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="fwdl.gif") + ap.add_argument("--ms", type=int, default=100) + args = ap.parse_args() + + imgs = [] + tick = [0] + + def frame(**kw): + img, d = new_frame(W, H) + chrome(d, W, H, "FWDL — FIRMWARE DOWNLOAD", + "blob -> sections -> download window -> IMEM/DMEM -> checksum " + "-> release from reset -> ready", tick[0]) + draw_scene(d, tick[0], **kw) + imgs.append(img) + tick[0] += 1 + + base = dict(split=False, moving=None, done_secs=set(), imem=0.0, dmem=0.0, + chk=False, core_on=False, ready=0, poll_x=None) + + # stage 1 — the blob, whole + for _ in range(8): + frame(**base, caption="1. the firmware image ships inside the driver " + "(a C array in the vendor tree)") + # stage 2 — parse into sections + base["split"] = True + for _ in range(10): + frame(**base, caption="2. parse the blob: header + sections bound for " + "IMEM (code) and DMEM (data)") + + # stage 3 — stream each section through the download window + per = 9 + fills = {"imem": 0.0, "dmem": 0.0} + done = set() + for i, (label, bank) in enumerate(SECTIONS): + sx0, sy0, sx1, sy1 = sec_rect(i) + scx, scy = (sx0 + sx1) / 2, (sy0 + sy1) / 2 + wcx, wcy = (WIN_X0 + WIN_X1) / 2, (WIN_Y0 + WIN_Y1) / 2 + if bank == "imem": + tcx, tcy = (BAR_X0 + BAR_X1) / 2, (IMEM_Y0 + IMEM_Y1) / 2 + elif bank == "dmem": + tcx, tcy = (BAR_X0 + BAR_X1) / 2, (DMEM_Y0 + DMEM_Y1) / 2 + else: + tcx, tcy = wcx, wcy + for f in range(per): + t = f / (per - 1) + if t <= 0.5 or bank is None: + u = min(t / 0.5, 1.0) + cx, cy = lerp(scx, wcx, u), lerp(scy, wcy, u) + else: + u = (t - 0.5) / 0.5 + cx, cy = lerp(wcx, tcx, u), lerp(wcy, tcy, u) + fills[bank] = min(fills[bank] + 0.5 / (per / 2), (0.5 * sum( + 1 for l, b in SECTIONS if b == bank and + (l in done or l == label)))) + cap = ("3. stream section %s through the download window" + % label) if bank else \ + "3. header first — it tells the loader where each section goes" + frame(**{**base, "moving": (label, cx, cy, bank), + "done_secs": done, "imem": fills["imem"], + "dmem": fills["dmem"]}, caption=cap) + done = done | {i} + base.update(done_secs=done, imem=fills["imem"], dmem=fills["dmem"]) + + base.update(imem=1.0, dmem=1.0) + # stage 4 — checksum + for f in range(10): + base["chk"] = f >= 3 + frame(**base, caption="4. verify the checksum over what landed in " + "IMEM/DMEM") + # stage 5 — release from reset + for f in range(10): + base["core_on"] = f >= 3 + frame(**base, caption="5. release the WCPU from reset — the core " + "starts executing IMEM") + # stage 6 — poll ready + for f in range(16): + base["ready"] = 1 if f < 10 else 2 + px = SEC_X0 + (f % 10) * ((CPU_X0 - 30 - SEC_X0) / 9) + base["poll_x"] = px if f < 10 else CPU_X0 - 30 + cap = ("6. poll the FW READY latch..." if f < 10 else + "6. firmware announces itself alive — the chip is now a " + "running computer") + frame(**base, caption=cap) + # hold the finale + for _ in range(6): + frame(**base, caption="6. firmware announces itself alive — the chip " + "is now a running computer") + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/h2c_c2h_gif.py b/tools/h2c_c2h_gif.py new file mode 100644 index 0000000..7d61f58 --- /dev/null +++ b/tools/h2c_c2h_gif.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Animated H2C / C2H mailboxes — 'talking to a running firmware', in the +DEVOURER live-monitor style. + + tools/h2c_c2h_gif.py -o docs/img/h2c_c2h.gif + +H2C (host-to-CPU) is the command mailbox: small fixed-format boxes the host +fills and the firmware consumes — power-save mode, rate mask, coex slots. +C2H (CPU-to-host) is the return path: the firmware injects event frames into +the ordinary RX stream, so they ride the same pipe as received 802.11 frames. +The finale is firmware IO-offload: one big H2C write-list crosses the bus and +unpacks inside the chip into a burst of local register writes — one transfer, +a thousand writes. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +W, H = 900, 470 +HOST = (40, 96, 258, 400) # host box +CHIP = (642, 96, 862, 400) # WCPU / chip box +LANE_X0, LANE_X1 = HOST[2], CHIP[0] # the bus between them +H2C_Y = 150 # H2C lane centreline +C2H_Y = 268 # C2H lane centreline +REG_Y0, REG_Y1 = 330, 358 # register strip inside the chip +CAP_Y = 432 + +H2C_CMDS = ["PWR MODE", "RATE MASK", "COEX SLOT"] +C2H_EVENTS = {2: "TX REPORT", 6: "CAL DONE", 10: "COEX TLM"} +N_STREAM = 13 # items circulating in the C2H stream + + +def cmd_box(d, cx, cy, label, w=104, h=26, hot=True): + col = AMBER if hot else (90, 110, 135) + d.rectangle([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], + outline=col, fill=(30, 26, 10) if hot else (16, 20, 30), + width=2) + tw = d.textlength(label, font=font(11, True)) + d.text((cx - tw / 2, cy - 7), label, font=font(11, True), + fill=AMBER if hot else INK) + + +def draw_static(d, tick, chip_flash, phase): + # host box + d.rectangle(HOST, outline=(0, 110, 120), width=2) + d.text((HOST[0] + 14, HOST[1] + 10), "HOST", font=font(14, True), + fill=CYAN) + d.text((HOST[0] + 14, HOST[1] + 32), "(devourer / driver)", font=font(10), + fill=DIM) + # chip box (flash outline when the fw consumes a command) + cc = AMBER if chip_flash else (0, 110, 120) + d.rectangle(CHIP, outline=cc, width=3 if chip_flash else 2) + d.text((CHIP[0] + 14, CHIP[1] + 10), "WCPU", font=font(14, True), + fill=CYAN) + d.text((CHIP[0] + 14, CHIP[1] + 32), "(running firmware)", font=font(10), + fill=DIM) + + # H2C lane + d.text((LANE_X0 + 12, H2C_Y - 44), "H2C — command mailbox", + font=font(12, True), fill=INK) + d.line([LANE_X0, H2C_Y + 22, LANE_X1, H2C_Y + 22], fill=(0, 70, 80)) + ax = LANE_X1 - 14 + d.line([LANE_X0, H2C_Y - 26, LANE_X1, H2C_Y - 26], fill=(0, 70, 80)) + d.polygon([(ax, H2C_Y + 17), (ax, H2C_Y + 27), (ax + 10, H2C_Y + 22)], + fill=(0, 120, 130)) + + # C2H lane + d.text((LANE_X0 + 12, C2H_Y - 44), "C2H — events inside the RX stream", + font=font(12, True), fill=INK) + d.line([LANE_X0, C2H_Y - 26, LANE_X1, C2H_Y - 26], fill=(0, 70, 80)) + d.line([LANE_X0, C2H_Y + 22, LANE_X1, C2H_Y + 22], fill=(0, 70, 80)) + d.polygon([(LANE_X0 + 4, C2H_Y + 17), (LANE_X0 + 4, C2H_Y + 27), + (LANE_X0 - 6, C2H_Y + 22)], fill=(0, 120, 130)) + + # register strip inside the chip + d.text((CHIP[0] + 14, REG_Y1 + 8), "registers", font=font(10), fill=DIM) + d.rectangle([CHIP[0] + 14, REG_Y0, CHIP[2] - 14, REG_Y1], + outline=(70, 90, 110)) + + +def draw_reg_cells(d, lit): + """The register strip's cells; `lit` = how many are written already.""" + x0, x1 = CHIP[0] + 16, CHIP[2] - 16 + n = 16 + cw = (x1 - x0) / n + for k in range(n): + cx = x0 + k * cw + if k < lit: + d.rectangle([cx + 1, REG_Y0 + 3, cx + cw - 2, REG_Y1 - 3], + fill=OK) + else: + d.rectangle([cx + 1, REG_Y0 + 3, cx + cw - 2, REG_Y1 - 3], + outline=(45, 60, 75)) + + +def draw_c2h_stream(d, f, rnd_marks): + """Small RX frames flowing chip → host; a few are C2H events (amber).""" + span = LANE_X1 - LANE_X0 + 140 + xs = [] + for k in range(N_STREAM): + off = k * (span / N_STREAM) + x = LANE_X1 - ((f * 11 + off) % span) + 40 + xs.append(x) + events = [] # (x, half-width) — drawn last, on top + for k, x in enumerate(xs): + if k not in C2H_EVENTS: + continue + label = C2H_EVENTS[k] + w = 12 + 8 * len(label) + if LANE_X0 - w / 2 < x < LANE_X1 + 4: + events.append((x, w / 2, label)) + for k, x in enumerate(xs): # plain RX frames, skip near events + if k in C2H_EVENTS or x < LANE_X0 - 30 or x > LANE_X1 + 4: + continue + if any(abs(x - ex) < ew + 22 for ex, ew, _ in events): + continue + d.rectangle([x - 13, C2H_Y - 9, x + 13, C2H_Y + 9], + outline=(70, 95, 120), fill=(14, 20, 30)) + d.text((x - 8, C2H_Y - 6), "RX", font=font(10), + fill=(110, 135, 160)) + for ex, ew, label in events: + xx = max(min(ex, LANE_X1 - ew - 2), LANE_X0 + ew + 2) + cmd_box(d, xx, C2H_Y, label, w=ew * 2, h=22, hot=True) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="h2c_c2h.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0xC2) + sparks = [(rnd.random(), rnd.random()) for _ in range(80)] + + PER = 10 # frames per H2C command crossing + PH_A = len(H2C_CMDS) * PER # 30 — commands cross + PH_B = 28 # events ride the RX stream + PH_C_CROSS = 13 # write-list crosses + PH_C_BURST = 26 # unpack into register writes + total = PH_A + PH_B + PH_C_CROSS + PH_C_BURST + 5 + + imgs = [] + for f in range(total): + img, d = new_frame(W, H) + chrome(d, W, H, "H2C / C2H — TALKING TO THE FIRMWARE", + "H2C: host -> fw commands C2H: fw events ride the RX stream " + " finale: fw IO-offload", f) + + chip_flash = False + caption = "" + pending = list(H2C_CMDS) + big_cross = None + lit = 0 + + if f < PH_A: # --- phase A: H2C + ci, cf = divmod(f, PER) + t = cf / (PER - 1) + pending = H2C_CMDS[ci + 1:] + chip_flash = t > 0.85 + caption = ("1. H2C: the host fills a small command box — the " + "firmware consumes it (%s)" % H2C_CMDS[ci]) + elif f < PH_A + PH_B: # --- phase B: C2H + caption = ("2. C2H: firmware events (TX reports, cal-done, coex " + "telemetry) ride among normal RX frames") + pending = [] + elif f < PH_A + PH_B + PH_C_CROSS: # --- phase C: cross + t = (f - PH_A - PH_B) / (PH_C_CROSS - 1) + big_cross = t + pending = [] + caption = ("3. fw IO-offload: pack a whole register write-list " + "into one H2C payload") + else: # --- phase C: burst + bf = f - PH_A - PH_B - PH_C_CROSS + lit = min(16, 1 + bf * 16 // (PH_C_BURST - 6)) + pending = [] + chip_flash = bf < 3 + caption = ("4. one transfer, a thousand writes: the firmware " + "replays the list locally — fw IO-offload") + + draw_static(d, f, chip_flash, 0) + draw_reg_cells(d, lit) + + # host-side pending command queue (phase A only) + if f < PH_A: + d.text((HOST[0] + 14, H2C_Y - 44 + 118), "queued:", + font=font(10), fill=DIM) + for q, name in enumerate(pending): + cmd_box(d, HOST[0] + 78, 290 + q * 36, name, hot=False) + + # the crossing H2C command (phase A) + if f < PH_A: + ci, cf = divmod(f, PER) + t = cf / (PER - 1) + x = LANE_X0 + 60 + t * (LANE_X1 - LANE_X0 - 120) + cmd_box(d, x, H2C_Y, H2C_CMDS[ci]) + + # the C2H stream runs through phases B..end (dim during A) + if f >= PH_A: + draw_c2h_stream(d, f, sparks) + + # phase C: the big write-list box + if big_cross is not None: + x = LANE_X0 + 90 + big_cross * (LANE_X1 - LANE_X0 - 180) + cmd_box(d, x, H2C_Y, "WRITE-LIST (N=1500)", w=182, h=36) + + # phase C: spark burst from the WCPU core toward the register strip + if lit: + bf = f - PH_A - PH_B - PH_C_CROSS + cx0, cy0 = (CHIP[0] + CHIP[2]) / 2, CHIP[1] + 96 + d.text((CHIP[0] + 14, CHIP[1] + 58), "unpacking...", + font=font(11), fill=AMBER) + for si in range(min(len(sparks), 6 + bf * 3)): + u, v = sparks[si] + ph = (bf * 0.31 + v) % 1.0 + sx = cx0 + (u - 0.5) * 170 + sy = cy0 + ph * (REG_Y0 - 8 - cy0) + r = 2 if v < 0.7 else 3 + d.ellipse([sx - r, sy - r, sx + r, sy + r], fill=AMBER) + + d.text((40, CAP_Y), caption, font=font(13, True), fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/lssi_rf_windows_gif.py b/tools/lssi_rf_windows_gif.py new file mode 100644 index 0000000..0eea948 --- /dev/null +++ b/tools/lssi_rf_windows_gif.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Animated 'two doors to the radio' — LSSI vs the Wi-Fi 6 RF windows, in the +DEVOURER live-monitor style. + + tools/lssi_rf_windows_gif.py -o docs/img/lssi_rf_windows.gif + +The RF dies keep a private register space (8-bit addresses, 20-bit data, one +copy per path) that is not memory-mapped. Left panel: the 11ac path — the host +writes one BB register (0xc90 path A / 0xe90 path B) and LSSI clocks the packed +addr+data across a 3-wire serial bus, bit by bit. Right panel: the Wi-Fi 6 +path — the d-die registers appear through the DDV window apertures at +0xe000/0xf000 (plain writes), while a-die registers go through the DAV serial +command register 0x370; an address BIT(16) picks the door. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import random +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 = 28 # 8-bit addr + 20-bit data + +# stages: (frames, key, caption) +STAGES = [ + (12, "pack", "LSSI 1/2 — host packs 8-bit addr + 20-bit data into one BB write (0xc90)"), + (30, "shift", "LSSI 2/2 — the BB clocks all 28 bits serially into the RF die: slow, write-mostly"), + (6, "land", "LSSI 2/2 — ...and the RF register finally latches"), + (18, "ddv", "Wi-Fi 6 DDV — the d-die window at 0xe000/0xf000: RF regs become plain writes"), + (18, "dav", "Wi-Fi 6 DAV — BIT(16) routes the write through serial cmd 0x370 into the a-die"), + (12, "final", "one radio, two dies, two doors — DDV is a window, DAV is LSSI's descendant"), +] + +D_REGS = ["0x00", "0x18", "0x1F", "0x56", "0x8F", "0xEF"] +A_REGS = ["0x00", "0x18", "0x55", "0xDF"] + + +def dimmed(c, on): + return c if on else tuple(v // 3 for v in c) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="lssi_rf_windows.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0xC90) + bits = [rnd.randint(0, 1) for _ in range(NB)] + word_hex = "0x18/0x08C40" # addr 0x18, data 0x08C40 (illustrative) + + W, H = 900, 520 + mid = 452 + imgs = [] + + total = sum(s[0] for s in STAGES) + for gi in range(total): + # locate stage + acc, si, ft = 0, 0, 0.0 + for k, (nf, key, cap) in enumerate(STAGES): + if gi < acc + nf: + si, ft = k, (gi - acc) / nf + break + acc += nf + key, cap = STAGES[si][1], STAGES[si][2] + left_on = key in ("pack", "shift", "land", "final") + right_on = key in ("ddv", "dav", "final") + + img, d = new_frame(W, H) + chrome(d, W, H, "TWO DOORS TO THE RADIO", + "RF registers: 8-bit addr, 20-bit data, per path — never " + "memory-mapped", gi) + d.line([mid, 78, mid, H - 54], fill=(0, 60, 70), width=1) + + # ================= LEFT: LSSI ===================================== + lc = lambda c: dimmed(c, left_on) + d.text((40, 84), "LSSI - 11ac parts", font=font(13, True), fill=lc(CYAN)) + d.text((40, 104), "(8812A .. 8822C)", font=font(10), fill=lc(DIM)) + + # host box + d.rectangle([40, 126, 428, 158], outline=lc((0, 90, 100))) + d.text((50, 134), "host: one BB write 0xc90 (path A) / 0xe90 (B)", + font=font(11), fill=lc(INK)) + + # shift register cells + cw, cy = 13, 200 + cx0 = 40 + # how many bits have been packed / shifted out + packed = NB if si > 0 else int(ft * NB) + 1 + shifted = 0 + if key == "shift": + shifted = int(ft * NB) + elif si > 1: + shifted = NB + d.text((cx0, cy - 22), "8-bit addr", font=font(10), fill=lc(AMBER)) + d.text((cx0 + 8 * cw + 12, cy - 22), "20-bit data", font=font(10), + fill=lc(CYAN)) + for k in range(NB): + x = cx0 + k * cw + (12 if k >= 8 else 0) + oc = lc(AMBER if k < 8 else CYAN) + d.rectangle([x, cy, x + cw - 2, cy + 18], outline=oc) + # cell content: filled during pack, drains from the right as shifted + has_bit = k < packed and k < NB - shifted + if has_bit: + d.text((x + 3, cy + 3), str(bits[k]), font=font(11), + fill=lc(INK)) + if key == "pack": + d.text((cx0, cy + 30), f"packing {word_hex} ({packed}/28 bits)", + font=font(11), fill=lc(AMBER)) + elif key == "shift": + d.text((cx0, cy + 30), f"bit {shifted}/28", font=font(11), + fill=lc(AMBER)) + + # 3-wire bus: CLK / DATA / EN going down to the RF die + wire_x = cx0 + NB * cw + 12 + 6 # exit at the right end of the cells + die_y0 = 380 + for wi, wname in enumerate(("CLK", "DATA", "EN")): + wx = 150 + wi * 90 + d.text((wx - 14, 252), wname, font=font(10), fill=lc(DIM)) + d.line([wx + 14, 250 + 9, wx + 14, die_y0], fill=lc((50, 70, 90)), + width=1) + # clock square wave beside CLK wire (ticks while shifting) + if key == "shift": + px, py = 120, 292 + pts = [] + for k in range(20): + lvl = ((k + shifted) // 2) % 2 + pts.append((px + k * 4, py + (0 if lvl else 12))) + pts.append((px + (k + 1) * 4, py + (0 if lvl else 12))) + d.line(pts, fill=lc(OK), width=1) + # data pulse travelling down the DATA wire + pulse_y = 262 + int((die_y0 - 276) * (ft * NB - shifted)) + cur_bit = bits[NB - 1 - shifted] if shifted < NB else 0 + d.ellipse([240 + 14 - 4, pulse_y - 4, 240 + 14 + 4, pulse_y + 4], + fill=lc(CYAN if cur_bit else (70, 95, 120))) + d.text((262, pulse_y - 7), str(cur_bit), font=font(11, True), + fill=lc(CYAN)) + + # RF die box + d.rectangle([110, die_y0, 400, die_y0 + 72], outline=lc(CYAN), + width=2 if key == "land" else 1) + d.text((122, die_y0 + 8), "RF die - path A", font=font(12, True), + fill=lc(INK)) + got = key == "land" or si > 2 + d.text((122, die_y0 + 34), + "RF[0x18] = 0x08C40" if got else "RF[0x18] = -----", + font=font(12), fill=lc(OK if got else DIM)) + if key == "land" and ft < 0.6: + d.rectangle([112, die_y0 + 30, 398, die_y0 + 52], + outline=lc(OK)) + + # ================= RIGHT: RF WINDOWS ============================== + rc = lambda c: dimmed(c, right_on) + d.text((468, 84), "RF WINDOWS - Wi-Fi 6", font=font(13, True), + fill=rc(CYAN)) + d.text((468, 104), "(8852B/8852C: two dies)", font=font(10), fill=rc(DIM)) + + # address token with BIT(16) + tok_y = 128 + d.text((468, tok_y + 4), "write", font=font(10), fill=rc(DIM)) + bit16 = key == "dav" + d.rectangle([510, tok_y, 592, tok_y + 22], outline=rc((0, 90, 100))) + d.text((518, tok_y + 4), "reg 0x18", font=font(11), fill=rc(INK)) + # the BIT(16) routing field gets its own highlighted box + d.rectangle([600, tok_y, 700, tok_y + 22], + outline=rc(AMBER if right_on else DIM)) + d.text((608, tok_y + 4), f"BIT(16)={1 if bit16 else 0}", + font=font(11), fill=rc(INK)) + d.text((708, tok_y + 4), "-> DAV" if bit16 else "-> DDV", + font=font(11, True), fill=rc(AMBER if bit16 else OK)) + + # DAV serial-cmd box (route into the a-die) + dav_y = 168 + d.rectangle([470, dav_y, 640, dav_y + 24], + outline=rc(AMBER if key == "dav" else (0, 90, 100))) + d.text((478, dav_y + 5), "serial cmd BB 0x370", font=font(11), + fill=rc(INK if key == "dav" else DIM)) + d.text((690, dav_y + 5), "direct (window)", font=font(10), + fill=rc(OK if key == "ddv" else DIM)) + + # the two dies + die_top, die_h = 212, 196 + row_h = die_h // (len(D_REGS) + 1) + # A-die + ax0, ax1 = 470, 640 + d.rectangle([ax0, die_top, ax1, die_top + die_h], + outline=rc((50, 70, 90))) + d.text((ax0 + 8, die_top + 4), "A-die (analog)", font=font(11, True), + fill=rc(INK)) + for k, r in enumerate(A_REGS): + ry = die_top + 26 + k * row_h + hot = key == "dav" and r == "0x18" and ft > 0.55 + d.rectangle([ax0 + 8, ry, ax1 - 8, ry + row_h - 6], + outline=rc(AMBER if hot else GRID), + fill=(60, 50, 12) if hot and right_on else None) + d.text((ax0 + 16, ry + 4), f"RF {r}", font=font(10), + fill=rc(INK if hot else DIM)) + # D-die + dx0, dx1 = 692, 862 + d.rectangle([dx0, die_top, dx1, die_top + die_h], + outline=rc((50, 70, 90))) + d.text((dx0 + 8, die_top + 4), "D-die (digital)", font=font(11, True), + fill=rc(INK)) + hot_i = int(ft * len(D_REGS)) if key == "ddv" else 1 + for k, r in enumerate(D_REGS): + ry = die_top + 26 + k * row_h + hot = key == "ddv" and k == hot_i + d.rectangle([dx0 + 8, ry, dx1 - 8, ry + row_h - 6], + outline=rc(OK if hot else GRID), + fill=(10, 50, 30) if hot and right_on else None) + d.text((dx0 + 16, ry + 4), f"RF {r}", font=font(10), + fill=rc(INK if hot else DIM)) + # DDV sliding window frame over the d-die column + if right_on: + wy = die_top + 26 + (hot_i if key == "ddv" else 1) * row_h - 3 + d.rectangle([dx0 + 3, wy, dx1 - 3, wy + row_h], + outline=rc(OK), width=2) + d.text((dx0 + 8, die_top + die_h + 6), + "DDV window 0xe000/0xf000", font=font(10), + fill=rc(OK)) + # DAV pulse: token -> 0x370 box -> a-die (mini serial echo) + if key == "dav": + # ticks inside the 0x370 box while transferring + for k in range(6): + tx = 478 + k * 26 + lvl = (k + gi) % 2 + d.line([tx, dav_y + 28 + (0 if lvl else 5), + tx + 26, dav_y + 28 + (0 if lvl else 5)], + fill=rc(AMBER), width=1) + if ft < 0.55: + py = tok_y + 24 + int((dav_y - tok_y - 24) * (ft / 0.55)) + d.ellipse([550 - 4, py - 4, 550 + 4, py + 4], fill=rc(AMBER)) + d.text((470, die_top + die_h + 6), + "DAV: a-die regs, via serial cmd", font=font(10), + fill=rc(AMBER)) + + # ---- caption ------------------------------------------------------ + d.text((40, H - 42), cap, font=font(13, True), + fill=CYAN if key != "final" else OK) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/mac_journey_gif.py b/tools/mac_journey_gif.py new file mode 100644 index 0000000..89c9574 --- /dev/null +++ b/tools/mac_journey_gif.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Animated MAC journey — 'a packet's trip through DMAC and CMAC', in the +DEVOURER live-monitor style. + + tools/mac_journey_gif.py -o docs/img/mac_journey.gif + +The MAC has two halves: the DMAC (data MAC — the plumbing: DLE page allocator, +DMA engines, HFC flow-control credits) and the CMAC (control MAC — the protocol +brain: queue selection, ACK machinery, RX filters). The animation follows one +TX frame down (host → USB bulk → TX descriptor → DLE pages + HFC credits → +CMAC → antenna) and then the RX direction up (antenna → CMAC filter, which +drops one frame → RX-DMA aggregation buffer batching frames into one bulk URB +→ host). 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, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +W, H = 900, 505 +# horizontal bands (y0, y1, name, note) +BANDS = [ + (88, 126, "HOST", "driver / devourer"), + (132, 162, "USB", "bulk endpoints"), + (168, 292, "DMAC", "plumbing: DLE pages,"), + (298, 368, "CMAC", "protocol brain:"), + (374, 438, "RF", "antenna"), +] +BAND_NOTE2 = {"DMAC": "DMA, HFC credits", "CMAC": "queues, ACK, filters"} +TXL, RXL = 330, 770 # TX lane (down) / RX lane (up) centres +# DMAC internals +HFC_X = 210 +PAGE_X0, PAGE_Y0 = 255, 190 # DLE page grid +PCOLS, PROWS, PSZ = 6, 3, 26 +AGG_X0, AGG_X1 = 560, 800 # RX-DMA aggregation buffer +AGG_Y0, AGG_Y1 = 226, 262 +CAP_Y = 464 + +BAND_MID = {n: (y0 + y1) // 2 for y0, y1, n, _ in BANDS} + + +def draw_bands(d, tx_phase): + for y0, y1, name, note in BANDS: + d.line([34, y0, W - 34, y0], fill=(22, 34, 46)) + d.line([34, y1, W - 34, y1], fill=(22, 34, 46)) + col = CYAN if name in ("DMAC", "CMAC") else INK + d.text((44, y0 + 4), name, font=font(13, True), fill=col) + d.text((44, y0 + 24), note, font=font(10), fill=DIM) + if name in BAND_NOTE2: + d.text((44, y0 + 38), BAND_NOTE2[name], font=font(10), fill=DIM) + # lane direction hints (kept clear of the tokens' travel corridor) + d.text((TXL - 96, 92), "TX", font=font(12, True), fill=AMBER) + d.polygon([(TXL - 74, 96), (TXL - 66, 96), (TXL - 70, 106)], fill=AMBER) + d.text((RXL - 116, 92), "RX", font=font(12, True), fill=OK) + d.polygon([(RXL - 94, 106), (RXL - 86, 106), (RXL - 90, 96)], fill=OK) + + +def draw_hfc(d, credits, total=8): + x0, x1 = HFC_X, HFC_X + 26 + y1, y0 = 282, 186 + d.text((x0 - 2, y0 - 16), "HFC", font=font(10), fill=DIM) + seg = (y1 - y0) / total + for k in range(total): + yy = y1 - (k + 1) * seg + if k < credits: + d.rectangle([x0, yy + 2, x1, yy + seg - 2], fill=(0, 110, 120)) + else: + d.rectangle([x0, yy + 2, x1, yy + seg - 2], + outline=(45, 60, 75)) + d.text((x0 + 2, y1 + 4), f"{credits}/{total}", font=font(10), + fill=INK if credits > total // 2 else AMBER) + + +def draw_pages(d, used): + d.text((PAGE_X0, PAGE_Y0 - 16), "DLE page pool", font=font(10), fill=DIM) + for k in range(PCOLS * PROWS): + r, c = divmod(k, PCOLS) + x = PAGE_X0 + c * (PSZ + 4) + y = PAGE_Y0 + r * (PSZ + 4) + if k in used: + d.rectangle([x, y, x + PSZ, y + PSZ], fill=(120, 90, 10), + outline=AMBER) + else: + d.rectangle([x, y, x + PSZ, y + PSZ], outline=(45, 60, 75)) + + +def draw_agg(d, filled, hot=False): + d.text((AGG_X0, AGG_Y0 - 16), "RX-DMA aggregation buffer", font=font(10), + fill=DIM) + d.rectangle([AGG_X0, AGG_Y0, AGG_X1, AGG_Y1], + outline=OK if hot else (70, 90, 110)) + n = 4 + sw = (AGG_X1 - AGG_X0) / n + for k in range(n): + x = AGG_X0 + k * sw + if k < filled: + d.rectangle([x + 4, AGG_Y0 + 5, x + sw - 4, AGG_Y1 - 5], + fill=(0, 90, 60), outline=OK) + d.text((x + 12, AGG_Y0 + 9), f"frm{k}", font=font(10), fill=OK) + else: + d.rectangle([x + 4, AGG_Y0 + 5, x + sw - 4, AGG_Y1 - 5], + outline=(45, 60, 75)) + + +def draw_cmac_boxes(d, tx_hot, rx_hot): + d.rectangle([255, 308, 470, 358], outline=AMBER if tx_hot else (70, 90, 110), + width=2 if tx_hot else 1) + d.text((266, 314), "queue select / ACK", font=font(11, True), + fill=AMBER if tx_hot else DIM) + d.text((266, 334), "EDCA access, retries", font=font(10), fill=DIM) + d.rectangle([560, 308, 800, 358], outline=OK if rx_hot else (70, 90, 110), + width=2 if rx_hot else 1) + d.text((572, 314), "RX filter / addr match", font=font(11, True), + fill=OK if rx_hot else DIM) + d.text((572, 334), "monitor mode opens this up", font=font(10), fill=DIM) + + +def draw_frame_token(d, cx, cy, label="FRAME", desc=True, col=AMBER, w=88): + d.rectangle([cx - w / 2, cy - 11, cx + w / 2, cy + 11], + outline=col, fill=(16, 20, 30), width=2) + if desc: # TX-descriptor header segment + d.rectangle([cx - w / 2 + 2, cy - 9, cx - w / 2 + 22, cy + 9], + fill=(120, 90, 10)) + d.text((cx - w / 2 + 3, cy - 6), "TD", font=font(10, True), fill=INK) + d.text((cx - w / 2 + 28, cy - 7), label, font=font(11, True), fill=col) + else: + tw = d.textlength(label, font=font(11, True)) + d.text((cx - tw / 2, cy - 7), label, font=font(11, True), fill=col) + + +def draw_antenna(d, x, radiate, tick): + y = 430 + d.line([x, y, x, y - 34], fill=INK, width=2) + d.line([x - 10, y - 22, x, y - 34], fill=INK, width=2) + d.line([x + 10, y - 22, x, y - 34], fill=INK, width=2) + if radiate: + for r in (10, 18, 26): + rr = r + (tick % 3) * 2 + d.arc([x - rr, y - 40 - rr, x + rr, y - 40 + rr], 200, 340, + fill=AMBER) + + +def lerp(a, b, t): + return a + (b - a) * t + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="mac_journey.gif") + ap.add_argument("--ms", type=int, default=105) + args = ap.parse_args() + + imgs = [] + + # ---------------- TX phase (frames 0..55) ---------------------------- + TX_STEPS = [ # (frames, y-from, y-to, caption) + (7, BAND_MID["HOST"], BAND_MID["HOST"], + "1. TX: the host prepends a TX descriptor (TD): rate, power, queue"), + (7, BAND_MID["HOST"], BAND_MID["USB"], + "2. one bulk URB carries descriptor + frame across USB"), + (14, BAND_MID["USB"], 230, + "3. DMAC: the DLE allocates pages for the frame — HFC credits must " + "cover it first"), + (10, 230, 332, + "4. CMAC: queue selection, EDCA channel access, ACK machinery"), + (9, 332, 408, + "5. on air — pages free, HFC credits refill"), + (5, 408, 408, + "5. on air — pages free, HFC credits refill"), + ] + tick = 0 + for si, (nf, yf, yt, cap) in enumerate(TX_STEPS): + for f in range(nf): + t = f / max(nf - 1, 1) + img, d = new_frame(W, H) + chrome(d, W, H, "MAC JOURNEY — DMAC / CMAC", + "TX: host -> USB -> DMAC (DLE pages, HFC) -> CMAC -> air " + "then RX back up through filter + RX-DMA agg", tick) + draw_bands(d, True) + + in_dmac = si == 2 + after_dmac = si >= 3 + done_dmac = si >= 4 + pages = set() + credits = 8 + if in_dmac: + npg = min(4, 1 + int(t * 4)) + pages = set(range(npg)) + credits = 8 - npg + elif si == 3: + pages = set(range(4)) + credits = 4 + elif done_dmac: + back = min(4, int(t * 5)) if si == 4 else 4 + pages = set(range(4 - back)) + credits = 4 + back + draw_hfc(d, credits) + draw_pages(d, pages) + draw_agg(d, 0) + draw_cmac_boxes(d, tx_hot=si == 3, rx_hot=False) + radiate = si >= 4 and (si == 5 or t > 0.4) + draw_antenna(d, TXL, radiate, tick) + draw_antenna(d, RXL, False, tick) + + if not radiate: + cy = lerp(yf, yt, t) + draw_frame_token(d, TXL, cy) + if si == 2: + d.text((368, 174), "host may only push while credits " + "cover the frame", font=font(10), fill=AMBER) + d.text((40, CAP_Y), cap, font=font(13, True), fill=CYAN) + imgs.append(img) + tick += 1 + + # ---------------- RX phase (frames ..110) ----------------------------- + # four frames rise from the antenna; #1 is dropped by the CMAC filter; + # survivors collect in the aggregation buffer; one URB goes up. + RX_RISE, RX_AGG, RX_URB = 26, 10, 16 + y_ant, y_flt, y_agg = 408, 332, (AGG_Y0 + AGG_Y1) // 2 + starts = [0, 5, 10, 15] # per-frame launch offsets + for f in range(RX_RISE + RX_AGG + RX_URB): + img, d = new_frame(W, H) + chrome(d, W, H, "MAC JOURNEY — DMAC / CMAC", + "TX: host -> USB -> DMAC (DLE pages, HFC) -> CMAC -> air " + "then RX back up through filter + RX-DMA agg", tick) + draw_bands(d, False) + draw_hfc(d, 8) + draw_pages(d, set()) + + filled = 0 + drop_flash = False + for k, s in enumerate(starts): + lf = f - s # local frame index + if lf < 0: + continue + rise = 8 # antenna -> filter + hop = 6 # filter -> agg buffer + if lf < rise: + cy = lerp(y_ant, y_flt, lf / (rise - 1)) + draw_frame_token(d, RXL, cy, f"rx{k}", desc=False, col=OK, + w=52) + elif k == 1 and lf < rise + 4: # the filtered-out frame + drop_flash = True + d.line([RXL - 12, y_flt - 12, RXL + 12, y_flt + 12], + fill=WARN, width=3) + d.line([RXL - 12, y_flt + 12, RXL + 12, y_flt - 12], + fill=WARN, width=3) + d.text((RXL - 130, 361), "dropped by filter", + font=font(10), fill=WARN) + elif k != 1: + if lf < rise + hop: + u = (lf - rise) / (hop - 1) + cy = lerp(y_flt, y_agg, u) + draw_frame_token(d, RXL, cy, f"rx{k}", desc=False, + col=OK, w=52) + else: + filled += 1 + filled = min(filled, 3) + + urb_f = f - (RX_RISE + RX_AGG) + agg_hot = filled == 3 and urb_f < 0 + draw_agg(d, 0 if urb_f >= 3 else filled, hot=agg_hot) + draw_cmac_boxes(d, tx_hot=False, rx_hot=f < RX_RISE) + draw_antenna(d, TXL, False, tick) + draw_antenna(d, RXL, f < 16, tick) + + if urb_f >= 0: # one URB up to the host + t = min(urb_f / (RX_URB - 4), 1.0) + cy = lerp(y_agg - 30, BAND_MID["HOST"], t) + draw_frame_token(d, RXL, cy, "URB: 3 frames", desc=False, + col=CYAN, w=120) + cap = ("8. one bulk URB completion carries many frames up to " + "the host") + elif f < RX_RISE: + cap = ("6. RX: the CMAC filter judges each frame — one fails " + "address match and is dropped") + else: + cap = ("7. RX-DMA batches survivors into the aggregation buffer") + d.text((40, CAP_Y), cap, font=font(13, True), fill=CYAN) + imgs.append(img) + tick += 1 + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/phy_tables_gif.py b/tools/phy_tables_gif.py new file mode 100644 index 0000000..7e529ea --- /dev/null +++ b/tools/phy_tables_gif.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Animated PHY register-table walker — 'one generated table serves every +board', in the DEVOURER live-monitor style. + + tools/phy_tables_gif.py -o docs/img/phy_tables.gif + +The vendor ships the whole BB/RF bring-up as long generated tables of +(address, value) pairs, interleaved with check_positive condition rows +("the next block applies only if cut == C && rfe == 2"). The walker holds the +board identity (cut / rfe_type / interface) and evaluates each gate: rows in a +matching block fly into the chip's register map, rows in a failed block bounce +off the closed gate. The animation scrolls a table tape past that gate so you +can watch one table serve two different boards. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import os +import random +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) + +# Register-map regions on the chip strip (label, address hint). +REGIONS = [ + ("BB PHY_REG", "0x800.."), + ("AGC / GAIN", "0x81C.."), + ("RADIO A", "RF path A"), + ("RADIO B", "RF path B"), + ("MAC", "0x000.."), +] + +# The table tape: ("row", addr, value, region_idx) is a plain write, +# ("cond", text, verdict) opens/closes the gate, verdict in {open, closed, +# neutral}. Values are illustrative but formatted like the real tables. +TAPE = [ + ("row", "0x800", "0x8020D410", 0), + ("row", "0x804", "0x080112E0", 0), + ("row", "0x808", "0x0E028233", 0), + ("row", "0x818", "0x03204390", 0), + ("row", "0x82C", "0x002083DD", 0), + ("cond", "IF cut==C && rfe==2", "closed"), + ("row", "0x840", "0x1F0E0F0F", None), + ("row", "0x844", "0x00000000", None), + ("row", "0x848", "0x2C028233", None), + ("cond", "ELSE", "open"), + ("row", "0x840", "0x0F0E0F0F", 0), + ("row", "0x848", "0x0E028233", 0), + ("cond", "END", "neutral"), + ("row", "0x81C", "0xFF000003", 1), + ("row", "0x81C", "0xFE020003", 1), + ("cond", "IF rfe==1 && usb", "open"), + ("row", "RF_A 0x18", "0x08400", 2), + ("row", "RF_A 0x56", "0x51CF3", 2), + ("row", "RF_A 0xEF", "0x00200", 2), + ("cond", "END", "neutral"), + ("row", "RF_B 0x18", "0x08400", 3), + ("row", "0x520", "0x2E7A2E7A", 4), +] + +# caption per tape index range (start, text) +CAPTIONS = [ + (0, "1. plain rows stream through the gate — (addr, value) register writes"), + (5, "2. IF cut==C && rfe==2 — this board is cut B / rfe 1: gate CLOSED, rows skipped"), + (9, "3. ELSE — the other branch is the one that matches: gate re-opens"), + (13, "4. unconditional AGC rows — the receiver's gain staircase"), + (15, "5. IF rfe==1 && usb — MATCHES this identity: a different block, gate stays OPEN"), + (20, "6. radio B + MAC rows land — the bring-up quartet completes"), +] +FINAL = "one generated table serves every board — check_positive gating" + + +def caption_for(idx): + cur = CAPTIONS[0][1] + for start, text in CAPTIONS: + if idx >= start: + cur = text + return cur + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="phy_tables.gif") + ap.add_argument("--per", type=int, default=4, help="frames per tape row") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x812A) # jitter for bounce debris only + + W, H = 900, 520 + tape_x, tape_w = 40, 268 # scrolling tape column + gate_x0, gate_x1 = 330, 396 # the check_positive gate + gate_y = 268 # tape row being evaluated sits here + row_dy = 27 + strip_x0, strip_x1 = 566, 866 # chip register-map strip + strip_y0, strip_y1 = 96, 436 + region_h = (strip_y1 - strip_y0) // len(REGIONS) + id_x0, id_y0 = 418, 96 # identity panel + + P = args.per + n_rows = len(TAPE) + hold = 12 + total = n_rows * P + hold + + # pre-walk the tape to know gate state *while each row is processed* + gate_state = [] # per tape index: open? (rows only) + applied_after = [] # cumulative counters after row i + skipped_after = [] + g, ap_c, sk_c = True, 0, 0 + for kind, *rest in TAPE: + if kind == "cond": + g = rest[1] != "closed" + gate_state.append(g) + else: + gate_state.append(g) + if g: + ap_c += 1 + else: + sk_c += 1 + applied_after.append(ap_c) + skipped_after.append(sk_c) + + imgs = [] + for gi in range(total): + cur = min(gi // P, n_rows - 1) + ft = (gi % P) / P if gi < n_rows * P else 1.0 + done = gi >= n_rows * P + img, d = new_frame(W, H) + chrome(d, W, H, "PHY TABLE WALKER", + "generated (addr,value) tape >> check_positive gate >> " + "BB/RF register map", gi) + + # ---- chip register-map strip ------------------------------------- + d.text((strip_x0, strip_y0 - 16), "chip register map", font=font(11), + fill=DIM) + flash_region = None + row = TAPE[cur] + if not done and row[0] == "row" and gate_state[cur] and ft > 0.72: + flash_region = row[3] + for ri, (name, hint) in enumerate(REGIONS): + ry = strip_y0 + ri * region_h + hot = ri == flash_region + # regions already touched glow faintly + touched = any(t[0] == "row" and t[3] == ri and gate_state[k] + for k, t in enumerate(TAPE[:cur])) + fillc = (0, 66, 72) if hot else ((14, 26, 34) if touched else None) + if fillc: + d.rectangle([strip_x0 + 1, ry + 3, strip_x1 - 1, + ry + region_h - 3], fill=fillc) + d.rectangle([strip_x0, ry + 2, strip_x1, ry + region_h - 2], + outline=CYAN if hot else (50, 66, 84), + width=2 if hot else 1) + d.text((strip_x0 + 10, ry + 8), name, font=font(12, True), + fill=INK if (hot or touched) else DIM) + d.text((strip_x0 + 10, ry + 26), hint, font=font(10), fill=DIM) + + # ---- identity panel ---------------------------------------------- + d.rectangle([id_x0, id_y0, id_x0 + 128, id_y0 + 96], + outline=(0, 90, 100)) + d.text((id_x0 + 8, id_y0 + 6), "IDENTITY", font=font(11, True), + fill=CYAN) + for k, line in enumerate(("cut = B", "rfe = 1", "intf = usb")): + d.text((id_x0 + 8, id_y0 + 28 + 20 * k), line, font=font(12), + fill=INK) + + # ---- gate ---------------------------------------------------------- + open_now = gate_state[cur] + gc = OK if open_now else WARN + d.text((gate_x0 - 4, gate_y - 66), "check_positive", font=font(10), + fill=DIM) + d.line([gate_x0, gate_y - 46, gate_x0, gate_y + 46], fill=gc, width=3) + d.line([gate_x1, gate_y - 46, gate_x1, gate_y + 46], fill=gc, width=3) + if open_now: + d.text((gate_x0 + 14, gate_y + 52), "OPEN", font=font(11, True), + fill=OK) + else: # a bar across the gap + d.rectangle([gate_x0, gate_y - 9, gate_x1, gate_y + 9], fill=WARN) + d.text((gate_x0 + 6, gate_y - 7), "CLOSED", font=font(11, True), + fill=(20, 8, 8)) + + # ---- counters ------------------------------------------------------ + idx = cur if done else max(cur - 1, 0) + d.text((gate_x0 - 4, gate_y + 78), + f"applied {applied_after[cur] if done else applied_after[idx] if cur else 0:2d}", + font=font(12, True), fill=OK) + d.text((gate_x0 - 4, gate_y + 98), + f"skipped {skipped_after[cur] if done else skipped_after[idx] if cur else 0:2d}", + font=font(12, True), fill=WARN) + + # ---- tape (upcoming rows scroll up toward the gate) ----------------- + d.text((tape_x, strip_y0 - 16), "generated table (tape)", font=font(11), + fill=DIM) + d.rectangle([tape_x - 6, strip_y0, tape_x + tape_w, strip_y1], + outline=(0, 70, 80)) + for k in range(cur + 1, min(cur + 7, n_rows)): + ry = gate_y + (k - cur) * row_dy - int(ft * row_dy) - 8 + if ry > strip_y1 - 20: + continue + t = TAPE[k] + if t[0] == "cond": + d.text((tape_x + 4, ry), t[1], font=font(12, True), fill=AMBER) + else: + d.text((tape_x + 4, ry), f"{t[1]:<10s} {t[2]}", font=font(12), + fill=(90, 110, 135)) + + # ---- the row being evaluated ---------------------------------------- + if not done: + if row[0] == "cond": + # condition row parks at the gate in amber, gate reacts + d.text((tape_x + 4, gate_y - 8), row[1], font=font(13, True), + fill=AMBER) + d.line([tape_x + tape_w, gate_y, gate_x0 - 4, gate_y], + fill=AMBER, width=1) + else: + label = f"{row[1]:<10s} {row[2]}" + if gate_state[cur]: + # fly right through the gate into its region + tx0, ty0 = tape_x + 4, gate_y - 8 + reg_cy = strip_y0 + row[3] * region_h + region_h // 2 - 8 + fx = tx0 + (strip_x0 + 12 - tx0) * ft + fy = ty0 + (reg_cy - ty0) * ft + d.text((fx, fy), label, font=font(12, True), + fill=CYAN if ft < 0.72 else OK) + else: + # approach the closed gate, bounce back and fade + if ft < 0.45: + fx = tape_x + 4 + (gate_x0 - 60 - tape_x) * (ft / 0.45) + col = INK + else: + bt = (ft - 0.45) / 0.55 + fx = (gate_x0 - 60) - 90 * bt + f = max(0.15, 1 - bt) + col = (int(240 * f), int(90 * f), int(70 * f)) + for _ in range(3): # debris sparks off the bar + sx = gate_x0 - 4 - rnd.randint(0, 26) + sy = gate_y + rnd.randint(-22, 22) + d.ellipse([sx - 1, sy - 1, sx + 1, sy + 1], + fill=col) + d.text((fx, gate_y - 8 - (12 * ft if ft > 0.45 else 0)), + label, font=font(12, True), fill=col) + + # ---- caption -------------------------------------------------------- + cap = FINAL if done else caption_for(cur) + d.text((tape_x - 6, H - 42), cap, font=font(13, True), + fill=CYAN if not done else OK) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/register_access_gif.py b/tools/register_access_gif.py new file mode 100644 index 0000000..fc7c174 --- /dev/null +++ b/tools/register_access_gif.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Animated register access — 'two planes, one cable', in the DEVOURER +live-monitor style (docs/driver-primer.md §2). + + tools/register_access_gif.py -o docs/img/register_access.gif + +Strip away every abstraction and a driver does two things: it reads/writes +registers via USB vendor CONTROL transfers (a URB with the address in its +setup fields), and it moves frames over BULK endpoints. The animation shows a +host on the left, the chip's register map on the right (MAC low space, BB +above 0x800, and the second 64 KiB page the Wi-Fi 6 BB spills into — RF is +not in this map at all), with small URBs crossing the control lane while +larger frames stream continuously on the bulk lane below. Needs Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from monitor_style import (AMBER, CYAN, DIM, INK, OK, WARN, chrome, font, + new_frame, save_gif) + +# register-map regions: (label, addr text, y0, y1) +REG_MAC = ("MAC", "0x0000-0x07FF", 100, 158) +REG_BB = ("BB", "0x0800-0xFFFF", 158, 262) +REG_BB2 = ("BB window", "+0x10000 (wIndex=1)", 288, 356) + +# access sequence: (op, region, addr, note, value) +ACCESSES = [ + ("WR", 0, "0x0608", "RX filter: open for monitor mode", None), + ("RD", 0, "0x00FC", "chip id", "0x13"), + ("WR", 1, "0x08AC", "BB: ADC clock divider", None), + ("RD", 1, "0x0C50", "BB: AGC gain index", "0x40"), + ("WR", 2, "0x1_0100", "Wi-Fi 6 BB: second 64 KiB page", None), + ("RD", 2, "0x1_1704", "Wi-Fi 6 BB readback", "0xEA5A"), +] + +CAPTIONS = [ + "registers ride control transfers — a URB per read/write", + "frames ride bulk endpoints — the data plane never stops", + "two planes, one cable", +] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="register_access.gif") + ap.add_argument("--per", type=int, default=15) + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x52) + + W, H = 900, 452 + host_x0, host_x1 = 40, 196 + host_y0, host_y1 = 100, 356 + map_x0, map_x1 = 656, 862 + lane_x0, lane_x1 = host_x1 + 6, map_x0 - 6 + ctrl_y = 150 + bulk_y = 322 + + imgs = [] + total = len(ACCESSES) * args.per + for ai, (op, reg, addr, note, val) in enumerate(ACCESSES): + for fi in range(args.per): + gi = ai * args.per + fi + img, d = new_frame(W, H) + chrome(d, W, H, "REGISTER ACCESS", + "the only lever the host has: control transfers for " + "registers, bulk transfers for frames", gi) + + # ---- host box + d.rectangle([host_x0, host_y0, host_x1, host_y1], + outline=(0, 110, 120), width=2) + d.text((host_x0 + 14, host_y0 + 10), "HOST", font=font(14, True), + fill=INK) + for k, line in enumerate(("driver", "(RtlAdapter)", "", + "libusb /", "kernel USB", "stack")): + d.text((host_x0 + 14, host_y0 + 44 + k * 18), line, + font=font(11), fill=DIM) + d.text((host_x0 + 14, host_y1 - 26), "one USB cable", + font=font(10), fill=(70, 90, 110)) + + # ---- register map strip + d.text((map_x0, 82), "REGISTER MAP", font=font(11, True), + fill=INK) + regions = (REG_MAC, REG_BB, REG_BB2) + for ri, (lbl, at, y0, y1) in enumerate(regions): + hit = ri == reg and 5 <= fi <= 9 + oc = AMBER if hit else (0, 110, 120) + d.rectangle([map_x0, y0, map_x1, y1], outline=oc, + width=2 if hit else 1) + d.text((map_x0 + 10, y0 + 6), lbl, font=font(12, True), + fill=INK if hit else DIM) + d.text((map_x0 + 10, y0 + 24), at, font=font(10), + fill=AMBER if hit else (70, 90, 110)) + # the second page hangs off the first map + d.text((map_x0, 268), "page 2 —", font=font(10), + fill=(70, 90, 110)) + d.line([map_x1 - 20, 262, map_x1 - 20, 288], + fill=(55, 70, 88), width=1) + d.text((map_x0, 366), "RF dies: NOT in this map", font=font(11), + fill=WARN) + d.text((map_x0, 384), "(reached indirectly, §9)", font=font(10), + fill=DIM) + + # ---- CONTROL lane + d.text((lane_x0 + 8, ctrl_y - 52), "CONTROL (vendor requests)", + font=font(11, True), fill=CYAN) + d.line([lane_x0, ctrl_y, lane_x1, ctrl_y], + fill=(30, 45, 58), width=1) + # URB out (frames 0..5), region flash (5..9), read return (9..14) + reg_yc = (regions[reg][2] + regions[reg][3]) // 2 + if fi <= 5: + t = fi / 5 + px = lane_x0 + (lane_x1 - lane_x0 - 30) * t + d.rectangle([px, ctrl_y - 8, px + 30, ctrl_y + 8], + fill=(0, 70, 80), outline=CYAN) + d.text((px + 4, ctrl_y - 6), "URB", font=font(10), fill=INK) + d.text((px - 10, ctrl_y - 28), f"{op} {addr}", + font=font(11, True), fill=AMBER) + elif fi <= 9: + # connector from lane into the flashing region + d.line([lane_x1, ctrl_y, map_x0, reg_yc], fill=AMBER, + width=2) + d.text((lane_x1 - 150, ctrl_y - 28), f"{op} {addr}", + font=font(11, True), fill=AMBER) + elif op == "RD": + t = (fi - 9) / 5 + px = lane_x1 - 30 - (lane_x1 - lane_x0 - 30) * t + d.rectangle([px, ctrl_y - 8, px + 30, ctrl_y + 8], + fill=(10, 55, 35), outline=OK) + d.text((px + 8, ctrl_y - 6), "<-", font=font(10), fill=INK) + d.text((px - 10, ctrl_y - 28), f"data {val}", + font=font(11, True), fill=OK) + else: + d.text((lane_x0 + 8, ctrl_y - 28), "write done — no reply", + font=font(11), fill=DIM) + # current access annotation under the control lane + d.text((lane_x0 + 8, ctrl_y + 16), + f"{'write' if op == 'WR' else 'read '} {addr} — {note}", + font=font(10), fill=DIM) + + # ---- BULK lane: frames stream continuously + d.text((lane_x0 + 8, bulk_y - 34), "BULK (frame endpoints)", + font=font(11, True), fill=OK) + d.line([lane_x0, bulk_y, lane_x1, bulk_y], + fill=(30, 45, 58), width=1) + span = lane_x1 - lane_x0 - 70 + for k in range(3): + px = lane_x0 + ((gi * 21 + k * (span // 3)) % span) + d.rectangle([px, bulk_y - 11, px + 64, bulk_y + 11], + fill=(8, 40, 26), outline=OK) + d.text((px + 8, bulk_y - 7), "FRAME", font=font(10), + fill=INK) + d.text((lane_x0 + 8, bulk_y + 16), + "TX/RX 802.11 frames — many per URB (RX aggregation)", + font=font(10), fill=DIM) + + # ---- caption + d.text((40, 410), CAPTIONS[ai % 3], font=font(13, True), + fill=CYAN) + imgs.append(img) + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rf_cal_gif.py b/tools/rf_cal_gif.py new file mode 100644 index 0000000..004a885 --- /dev/null +++ b/tools/rf_cal_gif.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Animated RF calibration — 'halrf's per-boot rituals', in the DEVOURER +live-monitor style. + + tools/rf_cal_gif.py -o docs/img/rf_cal.gif + +Split view: an IQ constellation (16-QAM) on the left, a spectrum strip on the +right. The chip starts uncalibrated — the lattice is smeared and skewed by IQ +gain/phase imbalance, biased off-center by DAC offsets, and the RX mixers leak +a tall DC spike mid-spectrum. Then the per-boot rituals run in bring-up order, +each looping TX into RX on a tiny chip inset: DACK trims the DAC offsets (the +cloud de-biases), RX-DCK collapses the DC spike, IQK unwinds the gain/phase +skew (the lattice snaps square, the image tone shrinks). It ends on the loop +that never ends: thermal tracking re-trims TX gain as the chip heats. Needs +Pillow. +""" +from __future__ import annotations + +import argparse +import math +import os +import random +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) + +# (name, frames, loopback-active, caption) +STAGES = [ + ("UNCAL", 12, False, + "power-on: raw analog — IQ mismatch smears the lattice, the mixers leak DC"), + ("DACK", 16, True, + "1. DACK — trim the TX DACs' offset and gain: the cloud de-biases"), + ("RX-DCK", 16, True, + "2. RX-DCK — null the RX mixer DC leak: the mid-spectrum spike collapses"), + ("IQK", 20, True, + "3. IQK — TX looped into RX, image tone measured: the lattice snaps square"), + ("THERMAL", 26, False, + "not a one-shot: thermal tracking re-trims as the chip heats"), +] + + +def smooth(t): + t = max(0.0, min(1.0, t)) + return t * t * (3 - 2 * t) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("-o", "--out", default="rf_cal.gif") + ap.add_argument("--ms", type=int, default=110) + args = ap.parse_args() + rnd = random.Random(0x8852) + + W, H = 900, 500 + padL = 34 + # constellation panel (left, square) + cG = 320 + cX, cY = padL, 104 + ccx, ccy = cX + cG // 2, cY + cG // 2 + scale = cG / 2 / 1.85 + # spectrum strip (right, top) + sX, sY, sW, sH = 430, 104, 436, 130 + # chip inset with loopback (right, middle) + iX, iY, iW, iH = 430, 258, 250, 122 + # checklist (right of inset) + kX, kY = 706, 258 + + # ideal 16-QAM (unit-ish coordinates) + ideal = [((2 * i - 3) / 3.0, (2 * q - 3) / 3.0) + for i in range(4) for q in range(4)] + # fixed noise per dot so only the impairment transform changes per frame + dots = [(ix, iy, rnd.gauss(0, 1), rnd.gauss(0, 1)) + for (ix, iy) in ideal for _ in range(9)] + # fixed spectrum noise floor shape + floor = [rnd.uniform(0.0, 1.0) for _ in range(sW)] + + imgs = [] + gi = 0 + for si, (sname, nfr, loop_on, caption) in enumerate(STAGES): + for fi in range(nfr): + t = smooth((fi + 1) / nfr) # progress within this stage + # impairment levels: 1 = full error, 0 = calibrated out + dc = 1.0 if si < 1 else (1.0 - t if si == 1 else 0.0) + spike = 1.0 if si < 2 else (1.0 - t if si == 2 else 0.0) + skew = 1.0 if si < 3 else (1.0 - t if si == 3 else 0.0) + heat = t if si == 4 else 0.0 # thermal ticker 0..1 + + img, d = new_frame(W, H) + chrome(d, W, H, "RF CALIBRATION", + "halrf per-boot rituals: DACK → RX-DCK → IQK, then thermal " + "tracking forever after", gi) + + # ---- constellation panel ------------------------------------- + d.rectangle([cX, cY, cX + cG, cY + cG], outline=(0, 70, 80)) + d.line([ccx, cY, ccx, cY + cG], fill=GRID) + d.line([cX, ccy, cX + cG, ccy], fill=GRID) + d.text((cX + cG - 14, ccy + 4), "I", font=font(11), fill=DIM) + d.text((ccx + 5, cY + 3), "Q", font=font(11), fill=DIM) + d.text((cX + 6, cY - 16), "16-QAM · RX'd through own loopback", + font=font(11), fill=DIM) + # ideal lattice crosses + for (px, py) in ideal: + x, y = ccx + px * scale, ccy - py * scale + d.line([x - 3, y, x + 3, y], fill=(60, 80, 100)) + d.line([x, y - 3, x, y + 3], fill=(60, 80, 100)) + # received dots: gain imbalance + phase skew + DC bias + noise + g_i = 1.0 + 0.24 * skew + eps = 0.30 * skew + dcx, dcy = 0.22 * dc, 0.15 * dc + sig = 0.030 + 0.028 * skew + wob = 0.010 * heat * math.sin(gi * 0.7) # tiny thermal breathing + for (ix, iy, nx, ny) in dots: + x0 = ix * (g_i + wob) + eps * iy + dcx + nx * sig + y0 = iy + dcy + ny * sig + x = max(cX + 4, min(cX + cG - 4, ccx + x0 * scale)) + y = max(cY + 4, min(cY + cG - 4, ccy - y0 * scale)) + bad = skew > 0.45 or dc > 0.45 + col = WARN if bad else (AMBER if skew + dc > 0.05 else OK) + d.ellipse([x - 2, y - 2, x + 2, y + 2], fill=col) + if si == 0: + d.text((cX + 8, cY + cG - 20), "smeared + off-center", + font=font(11, True), fill=WARN) + elif si == 4: + d.text((cX + 8, cY + cG - 20), "clean lattice", + font=font(11, True), fill=OK) + + # ---- spectrum strip ------------------------------------------ + d.rectangle([sX, sY, sX + sW, sY + sH], outline=(0, 70, 80)) + d.text((sX + 6, sY - 16), "loopback spectrum", font=font(11), + fill=DIM) + base = sY + sH - 12 + sig_x = int(sW * 0.72) # wanted tone + img_x = int(sW * 0.28) # IQ image (mirror) + dc_x = sW // 2 # DC bin + pts = [] + for x in range(sW): + h = 8 + 5 * floor[x] + for (cx0, amp, wd) in ((sig_x, 88, 7.0), + (img_x, 62 * skew, 7.0), + (dc_x, 96 * spike, 3.5)): + h += amp * math.exp(-((x - cx0) / wd) ** 2) + pts.append((sX + x, base - min(sH - 20, h))) + d.line(pts, fill=CYAN, width=1) + d.line([sX + dc_x, sY + 6, sX + dc_x, base], fill=(30, 40, 54)) + d.text((sX + sig_x - 22, base - 112), "signal", font=font(10), + fill=OK) + if spike > 0.12: + d.text((sX + dc_x + 6, sY + 8), "DC spike", font=font(10), + fill=WARN) + if skew > 0.12: + d.text((sX + img_x - 20, base - 96), "image", font=font(10), + fill=AMBER) + if spike < 0.12 and skew < 0.12: + d.text((sX + 8, sY + 8), "flat — image + DC gone", + font=font(10), fill=OK) + + # ---- chip inset: TX -> RX loopback path ---------------------- + d.rectangle([iX, iY, iX + iW, iY + iH], outline=(0, 70, 80)) + d.text((iX + 8, iY + 6), "chip (RF loopback)", font=font(10), + fill=DIM) + txb = [iX + 16, iY + 34, iX + 76, iY + 60] + rxb = [iX + 16, iY + 82, iX + 76, iY + 108] + d.rectangle(txb, outline=INK) + d.text((txb[0] + 16, txb[1] + 5), "TX", font=font(11, True), + fill=INK) + d.rectangle(rxb, outline=INK) + d.text((rxb[0] + 16, rxb[1] + 5), "RX", font=font(11, True), + fill=INK) + lc = AMBER if loop_on else (45, 58, 74) + lx = iX + 120 + d.line([txb[2], iY + 47, lx, iY + 47], fill=lc, width=2) + d.line([lx, iY + 47, lx, iY + 95], fill=lc, width=2) + d.line([lx, iY + 95, rxb[2], iY + 95], fill=lc, width=2) + d.polygon([(rxb[2] + 8, iY + 95), (rxb[2] + 16, iY + 90), + (rxb[2] + 16, iY + 100)], fill=lc) + if loop_on: + # a pulse dot running around the loop + path = [(txb[2] + k, iY + 47) for k in range(lx - txb[2])] + \ + [(lx, iY + 47 + k) for k in range(48)] + \ + [(lx - k, iY + 95) for k in range(lx - rxb[2])] + px, py = path[(gi * 9) % len(path)] + d.ellipse([px - 3, py - 3, px + 3, py + 3], fill=AMBER) + d.text((iX + 138, iY + 62), "TX→RX", font=font(10, True), + fill=AMBER) + d.text((iX + 138, iY + 76), "loopback", font=font(10), + fill=AMBER) + else: + d.text((iX + 138, iY + 62), "loop idle", font=font(10), + fill=DIM) + + # ---- checklist ------------------------------------------------ + d.text((kX, kY), "CAL SEQUENCE", font=font(11, True), fill=CYAN) + for j, nm in enumerate(("DACK", "RX-DCK", "IQK", "THERMAL")): + ry = kY + 22 + j * 24 + run = (j + 1) == si or (j == 3 and si == 4) + done = (j + 1) < si + col = CYAN if run else (OK if done else DIM) + mark = "▶" if run else ("✓" if done else "·") + d.text((kX, ry), f"{mark} {nm}", font=font(12, run or done), + fill=col) + + # ---- thermal ticker (finale) --------------------------------- + ty = iY + iH + 22 + d.text((iX, ty), "thermal meter", font=font(10), fill=DIM) + bx0, bx1 = iX + 118, iX + 356 + d.rectangle([bx0, ty, bx1, ty + 12], outline=(0, 70, 80)) + fillw = int((bx1 - bx0 - 2) * (0.25 + 0.6 * heat)) + hc = OK if heat < 0.5 else AMBER + d.rectangle([bx0 + 1, ty + 1, bx0 + 1 + fillw, ty + 11], fill=hc) + d.text((bx1 + 10, ty), f"Δ{int(heat * 8):d}", font=font(11, True), + fill=hc) + if si == 4 and heat > 0.55: + d.text((iX + 118, ty + 18), + "Δ crossed a step — TX gain re-trimmed (−1)", + font=font(10, True), fill=AMBER) + + # ---- caption -------------------------------------------------- + d.text((padL, H - 34), caption, font=font(13, True), fill=CYAN) + imgs.append(img) + gi += 1 + + save_gif(imgs, args.out, ms=args.ms, colors=56) + return 0 + + +if __name__ == "__main__": + sys.exit(main())