UM-NATOS-034 — The Second Receiver
UM-NATOS-034 — The Second Receiver
Used Medias LLC — Embedded Systems Division Revision 3.2 · 2026-08-19 · Status: CLOSED as a negative — §30 corrects three overstatements about the i2c traces; §31 corrects a load-bearing one about the transmit path — §29: a MediaTek adapter sees the control transmitter beaconing 30 cm away and does not see nat-os. Every comparable layer measured and matching; nothing radiates — §22 fixes a real 9.5 dB TX-power defect that is not the cause; §24 verifies the descriptor layout; §25 finds the last candidate was never in the archive this report named, transcribes it in 251 bytes with no new blob, and eliminates it too
1. Abstract
WiFi transmit has been the project's largest open item since UM-NATOS-028. The
MAC accepts frames and reports them complete — 178 of 178, forced=0 — and
nothing in the world responds. Twenty probe requests drew zero replies from two
access points that are received continuously.
That evidence was stuck, and this report is about why and what unstuck it.
"An access point did not answer" is equally consistent with two entirely different faults:
- nothing left the antenna at all, or
- something left but was malformed.
An AP that ignores a malformed frame looks exactly like a radio that never transmitted. Eleven leads were investigated without that distinction ever being settled, because every test asked a device we do not control to react, and got back one bit with no detail.
A second nat-os board settles it. Nothing is reaching the air. The transmit fault is in the RF/PHY path, not in frame construction, and an entire branch of the search is now closed.
2. Why a second board is a different instrument
The receiver in this kernel already works: promiscuous mode, beacon decode, continuous scanning, verified across two reports. Half a peer-to-peer link has existed the whole time. What was missing was a receiver we control and can instrument.
Three properties make it decisive where an AP was not:
An unforgeable signature. Every board reports its factory MAC with a verified CRC. A beacon from board A carries A's MAC, which board B cannot manufacture and no neighbouring router can forge. This is the same principle as UM-NATOS-028's probe-response test: a radio cannot put someone else's identity into your receiver.
COM5 5c:01:3b:50:3f:64
COM6 5c:01:3b:51:2b:40
A control that runs first. Board B is measured before board A transmits. If B hears nothing in that window it has proved nothing about A, only about itself — a negative result is only informative if the experiment demonstrably ran (Rule 6). So B must hear a real access point before its silence about A counts as evidence.
A raw frame counter, not just a parser. scan lists distinct beacon
sources, but frames= counts every descriptor the hardware filled. That
distinction is the whole point: a beacon too corrupt to parse would still raise
the frame count. Reading only the parsed list would have confused "malformed"
with "absent" — the exact confusion this test exists to remove.
3. Results
Both directions, ~30 cm apart, channel 6, one real access point in range.
| | run 1 | run 2 |
|---|---|---|
| Transmitter | COM5 | COM6 |
| Receiver | COM6 | COM5 |
| Receiver heard real AP | yes, "TC7NR" ×1 | yes, "TC7NR" ×9 |
| Receiver raw frames | 2 | 20 → 26 while watching |
| Receiver networks seen | 1 (the AP) | 1 (the AP) |
| Transmitter handed to hardware | 303 | 220 |
| Transmitter completions reaped | 302 | 219 |
| Transmitter chain acks | 39 | 10 |
| Peer's MAC ever seen | never | never |
The transmitter beacons every 100 ms — ten frames a second. The receiver in run 2 is capturing the distant access point at roughly two frames a second and recycling descriptors while it does. A transmitter 30 cm away emitting at five times that rate would dominate the capture completely.
It contributes nothing.
4. What this eliminates
Framing and content are exonerated. A malformed beacon is still radio frequency energy. A receiver actively filling descriptors from an access point across the house would register something from a board on the same desk. The frame counter does not move.
The remaining fault is that the radio never keys up. The MAC's completion counter is not evidence of radiation; it is evidence that the MAC believes it handed the frame onward. This is the project's third standing rule arriving in a new place: a successful register write is not evidence, extended to a successful completion count.
The leads in next_moves/01 survive and are now the whole of the search:
esp_wifi_power_domain_on, lmacInit, coex_bt_high_prio. All require naming
symbols that are not currently linked, and the second standing rule applies —
naming a symbol changes the link and has broken working code before.
4.1 The caveat, stated rather than buried
This proves no demodulable signal arrives. A transmission mangled badly enough that an identical receiver cannot demodulate it would look the same as silence.
That does not weaken the conclusion, it only renames it: such a fault is still in the RF/PHY path and still not in frame construction. Either way the branch that was closed stays closed.
5. Two observations for whoever picks this up
chain acks and completions disagree by an order of magnitude. 39 acks
against 302 completions; 10 against 219. Nobody has explained what that ratio
means, and it is the only counter in the transmit path that does not claim
everything is fine. If any instrument on this board is telling the truth about
transmit, it is probably that one.
A quiet receiver is not automatically a trustworthy one. In run 1 the
receiver caught 2 frames while the transmitter's own receiver caught 49, in the
same room on the same channel. That is almost certainly how often
wifimac_rx_service() gets called rather than sensitivity — but it made run 1's
silence much weaker evidence than it appeared.
Run 1 alone would have supported the same conclusion for the wrong reason. The roles were swapped specifically because the instrument looked weak, and run 2 is what the conclusion actually rests on.
6. Found on the way: the renderer and the radio cannot coexist
macrx failed with "out of DRAM for rx buffers" on both boards, which stopped
the first two runs dead.
Measured:
| state | heap free | largest block |
|---|---|---|
| 3D framebuffer allocated | 832 B | 832 B |
| fb off | 108,352 B | 107,520 B |
The framebuffer is 80,640 bytes of a roughly 110 kB heap. The receiver needs 6,592 (four 1,600-byte buffers plus descriptors) and cannot have it.
On this board you can have the 3D view or the radio, not both. That is worth
recording as an architectural fact rather than a bug: it is one more argument for
the split in docs/conceptual/the-ark-and-fiendnet.md, where a relay node is
headless and never allocates a framebuffer at all.
7. Process, recorded because two runs were invalid
The first run reported "board B heard nothing" — from a receiver that had never
armed, because the fb off step had silently failed to apply to the script.
The test's own control caught it and refused to conclude anything, which is the
only reason it did not become a finding.
The second run had the weak receiver of §5.
Two invalid runs before a valid one, on a test built specifically to avoid invalid runs. The control is what made both harmless.
8. Status
| Claim | Evidence |
|---|---|
| Receiver is live and instrumented | real AP decoded in every run, both boards |
| Transmit reports success | 302 and 219 completions, forced=0 |
| Nothing demodulable reaches the air | peer frame count flat in both directions |
| Fault is RF/PHY, not framing | §4 |
| Renderer and radio cannot share the heap | §6, measured |
Open, and unchanged: transmit itself. What has changed is that the search is now half the size, and the next attempt has a rig that answers in seconds instead of a question nobody could answer at all.
tools/serial/wifi_link.py runs the whole thing.
9. Postscript: CCA and EDCA, eliminated (revision 1.1)
The rig above made a cheap follow-up possible, and it is recorded because a plausible hypothesis that dies is worth as much as one that lives.
9.1 The hypothesis
hal_mac.o is already in the image — --gc-sections kept the nine functions
nat-os calls and discarded the other thirty-eight as unreferenced. Two of the
discarded ones are leaf functions, pure read-modify-writes with no calls, so
they can be replicated as direct pokes with no link change whatsoever:
hal_mac_tx_set_cca(mode): 0x3FF73C58 bits 31:30 = mode
hal_mac_tx_config_edca(p): 0x3FF73D1C - qid*8 AIFS 27:24, CW 21:12
Read on a live board after phyinit, macinit, chan 6, both were entirely
zero — the first time anyone had looked. A MAC that believes the channel is
permanently busy would behave exactly as §3 describes: accept the frame, arm the
queue, retire the descriptor, key nothing.
9.2 The result
Swept against the two-board rig, one register at a time, transmitter beaconing continuously throughout:
| swept | receiver saw the transmitter's MAC | |---|---| | CCA mode 0, 1, 2, 3 | no | | AIFS 2, 3, 7 | no | | CW 15, 31 | no |
scan on the receiver ended every run the same way: networks=1, the real
access point, and nothing else. Eliminated.
9.3 What was learned anyway
Those registers are live. Setting AIFS=7 and CW=31 drove forced from 0 to
699 out of 947 frames — the driver's own guard for a completion that never
arrives. The transmit state machine demonstrably responds to them. It simply
does not radiate either way.
And a misreading, corrected. wifimac_tx() already writes |= 0x02000000,
which is bit 25 — inside the AIFS field — and |= 0x00003000, which is CW
bits 1:0. So AIFS was never 0 during a transmit; it was 2, and CW was 3. The
existing driver had been setting sane-ish EDCA values by accident, through bits
nobody had decoded. The "degenerate arbitration" half of the hypothesis was
wrong before it was tested.
9.4 Two instrument failures, both mine
The first sweep keyed on the receiver's raw frame-count delta, and flagged every single step as a hit. A real access point in the room delivers about 1.5 frames a second, so any eight-second window shows a dozen new frames whatever the transmitter does. The instrument was measuring the neighbourhood. The detector must key on the transmitter's source MAC, which is the entire reason this rig exists (§2).
The second was worse in kind: the patch that fixed the detector silently failed
to apply, and the run went ahead with the broken one still in place. The
conclusion survived only because the raw scan output at the end is ground
truth and needs no detector at all.
Recorded because §7 already had two invalid runs of three, and this is two more. A rig built to stop bad measurements does not stop bad measurements; it only makes them cheap to notice.
9.5 Where that leaves it
The zero-link-risk surface is now exhausted. Everything remaining —
esp_wifi_power_domain_on, lmacInit, coex_bt_high_prio — requires naming a
symbol, and the measured blast radius of the largest is 305,888 bytes of
vendor object pulled in by one reference, against a 142,016-byte image, with
tx_pwctrl_background among the cascade — the same object as the calibration
that broke last time.
wifitx and tools/serial/wifi_sweep.py remain. Both are one command away from
re-testing any future guess in about a minute.
10. lmacInit: the risk taken, and the model that was wrong (revision 1.2)
The link change everyone had been avoiding since UM-NATOS-028 was made
deliberately, with the safety procedure from next_moves/01 followed step by
step. It did not fix transmit. It corrected three things that were believed
about the problem, and two of them mattered more than the attempt.
10.1 The blast-radius model was badly wrong
§9.5 predicted 305,888 bytes of vendor object pulled in by one reference, against a 142,016-byte image. Measured:
| | predicted | actual | |---|---|---| | image growth | ~306 kB | +1,200 bytes | | new text symbols | hundreds | 10 |
The model forgot --gc-sections. The archive members are pulled in to resolve
the 47 undefined symbols, and then everything not reachable from lmacInit
itself is discarded. What survived was lmacInit, lmacInitAc, lmacConfMib,
rcAttach, rc_cal and wDev_reset_bcnSendTick.
The thing this project has feared for three reports cost 1,200 bytes. The
fear was well-founded when it was formed — register_chipv7_phy really did
break — but it was never re-measured, and it had grown into a reason not to try.
10.2 The canary held, and so did everything else
Flashed to one board only, leaving the second on the previous image as an unchanged receiver, so the control could not drift with the experiment.
| check | result |
|---|---|
| phyinit returns 0 | yes — the exact canary that failed last time |
| receive after the link change | yes, AP decoded, descriptors recycling |
| lmacInit through the windowed bridge | returned cleanly |
| system after | apps running, DMA 109,336 transfers 0 timeouts, heap check=0 |
10.3 It changed the transmit path and still did not radiate
chain acks — the one transmit counter that has never claimed success (§5) —
moved, and moved twice:
| configuration | chain acks / completions |
|---|---|
| before | 39/302, 10/219 — roughly 5–13% |
| lmacInit | 82/165 — about 50% |
| lmacInit + lmacInitAc(0..3) | 8/172 — back down to 5% |
So the lower MAC is genuinely wired to that counter, and arming all four access
categories made it worse. The receiver's answer never changed: networks=1,
the real access point, and never the transmitter's MAC.
10.4 The correction that matters most: two of the three leads do not exist
next_moves/01 has listed three remaining leads since UM-NATOS-028. Searching
the archives nat-os actually has:
| lead | where it is |
|---|---|
| lmacInit / lmacInitAc | lmac.o — present, now tried, does not fix it |
| coex_bt_high_prio | not in libpp or libphy at all |
| esp_wifi_power_domain_on | not in libpp or libphy at all |
Both missing ones are ESP-IDF functions, not vendor-blob functions. hal_coex.o
exists in the archive and exports nothing.
So the lead list has been two-thirds fictional. Not wrong about what open-mac calls — wrong about what is reachable from here. Any future attempt at those two has to replicate them from documentation as direct register writes, in the way §9 replicated the CCA and EDCA pokes, and nobody has established what they do on this silicon.
10.5 What is kept
lmacInit stays, behind the lmacinit shell command, called from nowhere at
boot. It costs 1,200 bytes, breaks nothing, and is the only way to reproduce the
chain acks behaviour for whoever looks next.
10.6 lmacTxFrame, examined and deliberately not called
Revision 1.2 first claimed lmac.o had brought lmacTxFrame — the vendor's own
transmit entry point — into reach for the same price, and that it was the
obvious next thing. That was wrong on both counts, and the correction is
worth more than the claim was.
Of lmac.o's 50 functions, --gc-sections kept exactly two:
40090ee4 T lmacInit
40090e98 T lmacInitAc
lmacTxFrame was discarded, and so was .bss.our_instances, the per-AC state
table it works from. It is not linked and not free.
More importantly it should not be called even if it were. Disassembled, its first act is to index that table and check a state byte:
l8ui a6, a5, 18 ; per-AC state, stride 36
addi a5, a6, -3
bltui a5, 2, ok ; proceed only when the state is 3 or 4
...
movi a2, 0x6f8 ; assert, line 1784
callx8 a8
j 99 ; jumps to ITSELF -- forever
Four reasons, any one sufficient:
- The failure mode is an unrecoverable hang, not a fault. That loop runs
inside
phy_stack_call, which masks interrupts, so the watchdog may never fire. It needs the power pulled. - The precondition cannot be checked first.
our_instancesis a static that is not in the image, so there is no way to read the state byte and learn whether the assert would pass. - Its first argument is a packet structure that would have to be fabricated, and it dereferences it immediately. A wrong layout is vendor code walking garbage.
- It is not linked, so trying costs the object plus its dependency chain anyway.
This is precisely the situation the project's discipline exists for. A vendor function whose assert path is an infinite loop, whose precondition is unreadable, and whose argument must be guessed, is not a lead — it is a way to lose an afternoon and a working receiver at the same time.
Left undone, on purpose, and recorded so nobody re-derives it as an obvious next step.
11. The power domain: not a blob problem after all (revision 1.3)
§10.4 concluded that esp_wifi_power_domain_on was unreachable because the
symbol is not in libpp or libphy. True, and the wrong conclusion.
It is not a blob function. It is ESP-IDF, it is open source, and it is six register operations on documented registers:
void IRAM_ATTR esp_wifi_bt_power_domain_on(void)
{
CLEAR_PERI_REG_MASK(RTC_CNTL_DIG_PWC_REG, RTC_CNTL_WIFI_FORCE_PD);
esp_rom_delay_us(10);
wifi_bt_common_module_enable();
DPORT_SET_PERI_REG_MASK(DPORT_CORE_RST_EN_REG, MODEM_RESET_FIELD_WHEN_PU);
DPORT_CLEAR_PERI_REG_MASK(DPORT_CORE_RST_EN_REG, MODEM_RESET_FIELD_WHEN_PU);
CLEAR_PERI_REG_MASK(RTC_CNTL_DIG_ISO_REG, RTC_CNTL_WIFI_FORCE_ISO);
wifi_bt_common_module_disable();
}
That is a general lesson worth more than the experiment: "the symbol is not in the archive" is not the same as "the behaviour is out of reach." Two of the three leads this project has carried since UM-NATOS-028 were dismissed on that confusion.
11.1 The isolation hypothesis, dead on arrival
WIFI_FORCE_ISO clamps signals leaving the WiFi power domain. Asserted, the
digital side would work perfectly while nothing reached the analog side — §3's
symptom stated as a register.
Read on a fresh boot, before anything touched them:
DIG_PWC = 0x00155550 WIFI_FORCE_PD(17) = clear
DIG_ISO = 0xaaaa5000 WIFI_FORCE_ISO(28) = clear
Both already clear. The ROM or the second-stage bootloader sets this up, and the domain has been powered and un-isolated the entire time. Hypothesis eliminated in one read, which is the cheapest any of them has been.
11.2 One thing was genuinely unset
WIFI_CLK = 0xfffce030, and DPORT_WIFI_CLK_WIFI_BT_COMMON_M is 0x3C9.
Those bits AND to zero — the WiFi/BT common module clock was never enabled.
Applying the sequence set them (0xe030 → 0xe3f9).
11.3 Result: another behaviour change, still no radiation
Full bring-up after the sequence: phyinit 0, MAC running, channel tuned,
receiver armed, lmacInit clean. Then beaconing at 10 Hz with the second board
listening 30 cm away.
chain acks moved again, and this time strangely:
| configuration | chain acks / frames handed over |
|---|---|
| baseline | 39/302, 10/219 — about 5–13% |
| lmacInit | 82/165 |
| lmacInit + lmacInitAc(0..3) | 8/172 |
| + power domain sequence | 236/124 — nearly twice the frames sent |
Chain acknowledgements now exceed the number of frames queued, which has never happened. Something in the DMA chain is being processed more than once per frame. That is a specific, new, unexplained observation and the best remaining thread.
The receiver's answer did not change: networks=1, the real access point, never
the transmitter's MAC. System healthy throughout — heap check=0, DMA 1,070,824
transfers 0 timeouts, receive decoding normally.
11.4 Where this leaves transmit
Three attempts in one session, each cheap, each eliminating something, none radiating:
| attempt | cost | result |
|---|---|---|
| CCA / EDCA pokes | none | registers live, no radiation |
| lmacInit / lmacInitAc | 1,200 bytes | chain acks 5%→50%→5%, no radiation |
| power domain sequence | none | common clock was off, now on; chain acks > frames, no radiation |
What has not been tried, and is now the honest next step, is differential tracing: run ESP-IDF's own stack on identical hardware, capture every register write it makes during a transmit, and diff it against nat-os. That converts the problem from guessing which register matters into reading which one differs. It needs a second toolchain and a bare devkit — the CYD's JTAG pins are the display's — and it is a different kind of work from anything in this project so far.
Until someone does that, transmit is a research problem rather than a task with a next step.
12. The receiver, proved against a known-good transmitter (revision 1.4)
Every conclusion in this report rests on a receiver whose only proven reference was a router across the house. That left one gap: "our transmitter is silent" and "our receiver is deaf in some way we have not noticed" were never fully separated. §5 already showed the receiver's frame count varies a lot with what else is running, which made the gap real rather than theoretical.
Closing it needed a transmitter we control that definitely works. So: ESP-IDF
5.4.1, unmodified, as a SoftAP on the second board — tools/idf_ref.
REF-AP ssid=natref channel=6 mac=5c:01:3b:51:2b:41
nat-os, promiscuous on channel 6, 30 cm away:
frames=235 recycled=235 networks=2
5c:01:3b:51:2b:41 x170 "natref"
44:25:38:19:0d:1a x53 "TC7NR"
170 beacons in 15 seconds, MAC and SSID matching the reference exactly.
12.1 What this settles
| | beacons heard | |---|---| | ESP-IDF SoftAP, ~10 Hz, 30 cm | 170 | | nat-os transmitter, ~10 Hz, 30 cm | 0 |
Same receiver, same channel, same room, same session. The receiver is not the problem, and now it is not the problem by measurement against a working transmitter rather than by inference from a distant router.
Stated precisely: the two rows are not simultaneous, because a radio cannot hear itself and there are only two boards. The comparison is across runs. What is shared is everything else.
12.2 And a correction about what this needed
§11.4 said differential tracing needed "a second toolchain and a bare devkit — the CYD's JTAG pins are the display's". The JTAG part is true and was irrelevant: it reached for the heaviest method and then let that method's constraint rule out the whole approach. The CYD is an ordinary ESP32-WROOM-32 and runs ESP-IDF perfectly well. Nothing here needed JTAG, a devkit, or anything but the two boards already on the desk and a UART.
The second toolchain part is fair — this is the first thing in the project that depends on Espressif's tooling rather than replacing it, and the first build took 379 seconds.
13. Phase B: the differential, and the address that was never set
Three attempts at guessing which register mattered each eliminated something and none found it. This stops guessing.
Both stacks call the same PHY blob, so whatever it does internally is identical. Any difference between a radio that transmits and one that does not has to be visible in the registers the surrounding code touches — and both firmwares can dump those.
The point of a differential is that you do not need to know what a register means to notice that it differs.
13.1 The filter that makes it readable
Most of that address space is counters — the TSF timer, statistics, free-running clocks — and a naive diff is almost entirely those.
So each board dumps twice, a second apart. Anything that moves between a board's own two dumps is volatile by definition and discarded.
addresses dumped by both : 1408
volatile (discarded) : 35
stable, comparable : 1373
STABLE AND DIFFERENT : 342
Of the 342, 245 are in 0x3FF74000+ and hold random-looking values on both
sides — buffer RAM, not control registers. That leaves 97 real candidates,
which is a list a person can read.
13.2 What it found immediately
0x3ff73008 ESP-IDF: 513b015c nat-os: 00000000
0x3ff7300c ESP-IDF: 0000412b nat-os: 00000000
Little-endian, that is 5c:01:3b:51:2b:41 — the reference AP's own MAC
address, exactly as its firmware printed it. The same at 0x3FF73040/44
(ending 2b:40, the station interface) and 0x3FF73048/4C. The match masks at
0x3FF73028 and 0x3FF73068 hold ffffffff / 0001ffff in the reference and
zero in nat-os.
This driver has been running with a hardware MAC address of
00:00:00:00:00:00 since it was written. hal_mac_set_bssid is even in the
image — it survives --gc-sections because vendor code references it — and
nat-os has never called it.
That is a real defect found in about a minute by a method that had not been tried, after three sessions of informed guessing found nothing.
13.3 Fixed, and it did not fix transmit
machw programs all three address slots and both masks from the chip's own
eFuse MAC:
before: 0x00000000 0x00000000
after : 0x503b015c 0x0000643f = 5c:01:3b:50:3f:64
Then, with the reference AP 30 cm away — an active peer, so a probe request that arrives earns a probe response addressed back:
sent 10 probe requests frames addressed to us=0
sent 10 probe requests frames addressed to us=0
sent 10 probe requests frames addressed to us=0
Nothing. Receive was re-checked afterwards and is unaffected — 757 frames, hearing both the reference (556) and the router (171) — so the masks did not break the working path.
13.4 The remaining candidates, which are now specific
The diff hands over a shortlist instead of a hunch. The ones that look like state rather than noise:
| address | ESP-IDF | nat-os | reads like |
|---|---|---|---|
| 0x3ff73118 / 311c | 400a0000 / 3ffae000 | ffffffff / 00000000 | a DRAM pointer — 3ffae000 is data RAM |
| 0x3ff73120 / 3124 | 400a0000 / 3ffae000 | ffffffff / 00000000 | the same pair again |
| 0x3ff73c40 | 01e839e0 | 00000000 | another pointer-shaped value |
| 0x3ff730f8–3104 | 05000000 | 87800000 | a mode or rate field |
| 0x3ff73400–3430 | rate-table-looking bytes | different bytes | per-rate configuration |
| 0x3ff73148 / 314c | 00000900 / 00003000 | 00000000 | unset in nat-os |
Two independent pointer pairs into DRAM that nat-os leaves at zero and
ffffffff are the most interesting thing on the list, because a transmit path
handed no buffer is a transmit path that completes and radiates nothing —
which is the symptom, stated as a register, for the fourth time this report.
13.5 What actually changed today
Transmit still does not work. What changed is the method:
- three sessions of guessing produced three eliminations and no defects
- one differential produced a real defect in a minute and a shortlist of six more, each with an address and a value to try
tools/idf_ref and tools/serial/reg_diff.py make that repeatable. Anyone
picking this up starts from a list, not a theory.
14. The limit of the differential, found by exceeding it
§13.4 left about ninety candidates. One at a time is right for three and is an afternoon for ninety, so the next move was to apply them all: if transmit started, bisect; if nothing changed, the answer is not in this register set at all — which is worth more than ninety individual negatives.
Fifty MAC registers were written to the reference's values. The board stopped
responding. It recovered completely on reset — apps running, corrupt=0,
nothing permanent — but the run produced no measurement.
14.1 Why that is the useful result
The obvious reading is "be more careful." The correct reading is that the premise was wrong.
The two firmwares are not in the same configuration and were never going to be:
| | reference | nat-os | |---|---|---| | mode | SoftAP, full stack | promiscuous receive, minimal | | beaconing | via the vendor's own scheduler | via a hand-written register poke | | queues, rate control, timers | all initialised by the stack | mostly untouched |
So a great many of those 342 differences are legitimate configuration differences, not defects. They say "these two radios are doing different jobs", which is true and not a bug. Copying them wholesale writes the settings of a fully-configured AP into a minimally-configured sniffer, and the crash is the honest consequence.
A differential is a pointer at candidates, not a set of values to copy. The MAC address in §13.2 was a genuine find precisely because zero is not a legitimate configuration of anything — it is wrong in any mode. That is the shape of difference worth acting on, and most of the ninety do not have it.
14.2 What would make the next differential sharper
Match the reference to nat-os instead of matching nat-os to the reference. An
ESP-IDF build in promiscuous/sniffer mode that transmits a raw frame —
esp_wifi_80211_tx() — is doing very nearly what nat-os does, and the diff
against it would be small enough that every remaining difference is suspicious
rather than merely different.
That is a change to tools/idf_ref, not to nat-os, and it is the obvious next
step for whoever continues.
14.3 Standing rule earned
A difference is not a defect until you know the two sides were doing the same job. This report spent its first eleven sections comparing a transmitter against a receiver and being careful about it; §13 compared two transmitters in different modes and briefly forgot to be.
15. A reference doing the same job, and a sharper list
§14.2 said the next step was to match the reference to nat-os rather than the
reverse. Done: tools/idf_ref is now promiscuous, unassociated, on a fixed
channel, pushing a hand-built raw 802.11 probe request out every 100 ms via
esp_wifi_80211_tx(). That is nat-os's job, described exactly.
15.1 The reference was verified before it was trusted
esp_wifi_80211_tx() can fail silently, and a reference that is not really
transmitting would poison every comparison drawn from it. nat-os was used as the
detector:
802.11 frame: probe-req len=72
bssid ff:ff:ff:ff:ff:ff
bytes: ... 40 00 00 00 ff ff ff ff ff ff 5c 01 3b 51 2b 40 ff ff ff ff ff ff
^^^^^^^^^^^^^^^^^ the reference's MAC
Frame rate at the receiver went from ~1.5/s (the house router alone) to about 20/s. The reference radiates, and says who it is while doing it.
15.2 The diff did not shrink; it got sharper
| | SoftAP reference | raw-injection reference |
|---|---|---|
| stable and different | 342 | 335 |
| buffer RAM (0x3FF74000+) | 245 | 245 |
| MAC control registers | — | 58 |
| of those, "reference has a value, nat-os has zero" | — | 19 |
Total barely moved, which is worth stating plainly because the expectation in §14.2 was that it would. What changed is the quality: "reference has something, nat-os has nothing" is the shape that cannot be a legitimate mode difference, and there are nineteen of them.
15.3 The same register block, caught a second time
0x3ff73000 ref=513b015c nat=00000000
0x3ff73004 ref=0000402b nat=00000000
The MAC address again — at 0x3FF73000, not 0x3FF73008 where the SoftAP
reference kept it. There are two address slots and the live one depends on the
interface: the AP build used 0x3FF73008, the STA build uses 0x3FF73000.
machw had programmed only 0x3FF73008 — the slot belonging to the mode nat-os
is not in. §13 found the address was never written; §15 found it was then
written to the wrong place. Both were visible only against a reference doing the
same job.
Fixed, both slots now written. Receive unaffected. Transmit unchanged:
frames addressed to us=0, three times, with an active peer 30 cm away.
15.4 The handoff, and what has since been struck off it
Nineteen registers, each with an address and a value, where a working transmitter holds something and this one holds nothing:
3ff73000 513b015c 3ff73004 0000402b 3ff73040 513b015c 3ff73044 0000402b
3ff73048 513b015c 3ff7304c 0000412b 3ff73068 ffffffff 3ff7306c 0000ffff
3ff7311c 3ffae000 3ff73124 3ffae000 3ff73148 00000900 3ff7314c 00003000
3ff73164 0000000c 3ff73800 00030000 3ff73804 00030000 3ff73c40 01e839e0
3ff73c54 00000404 3ff73c78 00000003 3ff73d40 00000010
Applied together with the address slots and the masks, transmit is still silent. Applied individually, no single one has changed the outcome.
That is where this stops. Not because the list is exhausted — 0x3FF73C40
holding 01e839e0 against zero is a pointer-shaped value nobody has chased —
but because the pattern of this session is now clear: each register poke costs
a few minutes and eliminates one thing, and there is no evidence the answer is
in the register set at all. The difference may be in ordering, in timing, in
the PHY block the dump does not reach, or in a ROM call neither side exposes.
tools/idf_ref, reg_diff.py and wifireg make any future attempt cheap. The
next person should start from §15.4 and should not start from a theory.
16. The 0x3FF73Cxx block, eliminated
0x3FF73C40 was singled out in §15.4 as the one pointer-shaped value nobody had
chased: 01e839e0 in a working transmitter, zero here.
Written, and it held — reading back 0x01e839e0, so the write was real and not
silently rejected. Receive unaffected at 359 frames. Transmit unchanged:
frames addressed to us=0, three times, active peer 30 cm away.
Then the rest of the block, since 0x3FF73C40–C78 reads like one functional
unit and a bisect was available if it worked:
| register | was | set to |
|---|---|---|
| 3ff73c44 | 078482bf | 0404001f |
| 3ff73c54 | 00000000 | 00000404 |
| 3ff73c5c | 0fff0fff | ffff0fff |
| 3ff73c6c | a5000c24 | a5802d24 |
| 3ff73c78 | 00000000 | 00000003 |
Receive fine at 950 frames. Transmit still silent. The block is eliminated.
16.1 One register refused the write
0x3FF73C68 was written with 00000000 and read back 40110011. It is
hardware-driven or read-only, which is worth knowing: a difference at a register
software cannot set is not a difference software failed to set. At least one
entry on the §15.4 list was never actionable, and there may be others — a check
worth running before anyone spends time on the remainder.
16.2 The count
Across this report: the framing hypothesis, CCA, EDCA, lmacInit, lmacInitAc,
the power domain, the MAC address (twice, two different slots), the DMA pointer
pairs, and now the whole 0x3FF73Cxx block. Every one cheap, every one
eliminating something, none radiating.
That is the evidence for §15.4's conclusion rather than an argument against it: there is still no sign the answer is in the register set. The next person should read §15.4 before poking anything, and should probably not poke anything.
17. Is this the same as open-mac, and is it possible at all?
Two questions asked at the end of the session. The second deserves a straight answer rather than a hedge.
17.1 The same job, one layer apart
Yes, substantially. wifimac_tx() was transcribed line by line from open-mac's
transmit_80211_frame and verified against it register by register
(UM-NATOS-028 section 3.2). Both projects replace Espressif's closed MAC
software with their own register writes while still calling the closed PHY blob.
Both read the same undocumented block at 0x3FF73xxx.
But open-mac runs on top of ESP-IDF, and that is the whole difference.
open-mac replaces the MAC protocol layer only. Before a line of its code runs, ESP-IDF has already done the power domain, the clocks, the coexistence arbiter, the PHY init and lmacInit -- the entire undocumented bring-up. It inherits a radio that is already keyed up, and answers the question "how do I frame and queue a packet on it".
nat-os declined all of that, so it must perform the bring-up itself, and the
bring-up is the part nobody wrote down. That is exactly why the three things
open-mac calls and nat-os does not turned out to be esp_wifi_power_domain_on,
coex_bt_high_prio and lmacInit -- all initialisation, none of it protocol.
So nat-os is attempting something strictly harder than open-mac. open-mac
stands on Espressif's initialisation and rewrites what sits above it; nat-os
stands on nothing and must rediscover what sits below. And the layer open-mac
actually contributes -- the protocol -- is the part that already works here.
wifimac_tx() has been verified twice and is not the problem.
17.2 Is it possible?
Yes. Four reasons, in order of weight.
Receive already works, from scratch. This kernel brings up the PHY, tunes a channel, arms DMA, recycles descriptors and decodes 802.11 frames with no ESP-IDF underneath it. That is not a small piece of the same problem, it is most of it, already done.
Nothing is sealed. ESP-IDF is open source and on this machine. The blob is a binary we possess and can disassemble. The hardware is on the desk and answers in seconds. There is no cryptographic gate, no signed firmware, no missing key -- only undocumented ordering.
The OS shim already exists. wifi_osi_impl.c implements the interface the
vendor stack expects and ositest exercises it end to end. The usual blocker
for running vendor networking code outside its RTOS is already solved here.
A method exists that cannot fail in principle. Below.
17.3 Why the diff failed, and what would not
The differential compared destinations, not routes.
A snapshot shows state. Hardware bring-up depends on sequence, and on transient values that do not survive to be photographed. A register written to 1 and then back to 0 appears as 0 in both dumps and vanishes from the diff. So the nineteen candidates of section 15.4 may not contain the answer at all -- not because the method is bad, but because the answer may be an order rather than a value.
What would work is a trace: every register write ESP-IDF makes from reset through its first successful transmission, in order, with values, then replayed.
That is obtainable. ESP-IDF's register accesses go through macros, and instrumenting them logs every C-level write with address, value and order -- which covers all of esp_wifi's init, the part nat-os is missing. The blob's internal writes are not caught that way, but both stacks call the same blob identically, so replaying the C-level sequence around the same blob calls should converge. Espressif also maintains a QEMU fork with ESP32 support that can trace memory accesses including the blob's.
None of that is exotic. It is tedious, mechanical, and not blocked.
17.4 The honest estimate, and the real question
Weeks to months of focused work. Not an afternoon, and not a hundred sessions of poking registers -- poking is the thing that does not scale. Twenty eliminations in one session and no defect found, because each poke tests one hypothesis and there are thousands of them. A trace tests all of them at once.
But possibility is not priority, and the strategic question is separate from the technical one.
nat-os's founding constraint is no ESP-IDF. WiFi on this chip appears to require an initialisation sequence that exists only inside ESP-IDF and the blob. Reproducing it is possible, and is also, precisely, reimplementing the thing the project exists to avoid. That tension is real, and it is a decision about what nat-os is rather than a bug to be fixed.
An SX1262 has no equivalent problem. It is a documented SPI peripheral with a published register map: write what the datasheet names, in the order it gives, and the radio keys up. There is no "part Espressif never wrote down", which is the thing that has cost four sessions here.
So: possible, yes. Worth it, only if owning this particular radio matters more than the months. That is not a question this report can answer.
18. The trace, and the first new evidence in four sessions
Added revision 1.9, 2026-08-19, same day as UM-NATOS-035.
§13 ended on a sentence that turned out to be the whole problem:
the snapshot diff compares destinations, not routes.
Thirty stable register differences were applied wholesale and nothing changed. That eliminated the shortlist and left two shapes of cause that a snapshot cannot reach by construction:
- order — both firmwares arrive at the same state by different paths, and the hardware cares which;
- transients — a write to a self-clearing bit. A "go" bit reads back zero a microsecond later, so no number of snapshots, however carefully filtered, can ever contain it.
The second would explain every observation in this project at once: completions counted, canary intact, receive unaffected, nothing on the air.
18.1 The instrument
tools/idf_ref now traces instead of only dumping. Core 1 reads a 16-register
window into DRAM as fast as the bus allows, with no comparison and no branching
beyond the loop; core 0 transmits into the middle of that; the buffer is
differenced afterwards and printed as an ordered list of changes.
tools/serial/tx_trace.py drives the sweep and reduces the output.
Measured, not estimated: 1.92 µs per sample. The design predicted 0.55 µs from 8 cycles per word; a read from the MAC peripheral bus costs about 28 cycles, not 8. So the blind spot is three times wider than designed — anything faster than ~2 µs can still be missed — and it is recorded here rather than quietly corrected, because the width of this instrument's blind spot is the one number a reader needs in order to judge a negative result from it.
Two design errors were found and fixed by running it:
-
One frame per window was not enough.
esp_wifi_80211_tx()queues to the WiFi task and returns; when the hardware is actually touched is decided by a scheduler this code does not control. The first sweep's only active window came back empty on the second pass — not noise, a broken experiment. Eight frames spread across the window fixed it, and changed what silence means: with one frame, an empty window was ambiguous; with eight, it is evidence. -
The first sweep covered the wrong 1 KB.
0x3FF73000–0x3FF73400was chosen because every register this project had found interesting fell inside it. Fifteen of those sixteen windows were completely silent, and the two addresses §12 spent a session on —0x3FF73C40,0x3FF73C68— were outside the swept range entirely. The sweep is now the full 4 KB, 64 windows.
A third correction was to tx_trace.py itself, which printed "nothing but
clocks (both)" for windows where nothing had changed at all. Thirty-two
thousand samples of a register that never moved is not "only counters moved". It
was the instrument claiming to have seen something it had not, and it is now
labelled SILENT.
18.2 What it found
Forty-four of sixty-four windows are silent across 32,768 samples each. The transmit path is live in 41 addresses, and they are concentrated:
| region | changes | shape |
|---|---|---|
| 0x3FF73C00 | 4092 | free-running — changes every sample |
| 0x3FF73D80, DB4 | 4183 | the busiest structured block |
| 0x3FF732F0–FC | 1347 | four registers moving together |
| 0x3FF73834 | 382 | monotonically counting down |
| 0x3FF73428, 73424 | 172 | paired |
| 0x3FF73CB8 | 98 | toggles 0x2000/0x4000/0 |
| 0x3FF73D84–D8C, DAC, DB0, DB8 | ~400 | counters and a state machine |
0x3FF73DB8 is the one worth staring at. It cycles, repeatedly, and each cycle
is accompanied by 0x3FF73D8C, 73D88 and 73D84 each incrementing once:
0x000 -> 0x210 -> 0x230 -> 0x020 -> 0x000
0x210 is bits 4 and 9. 0x230 adds bit 5, and bit 5 is gone again one or
two samples later. Post, trigger, in flight, done — the shape of a transmit slot
being used, with a self-clearing bit in the middle of it.
That is a candidate, not a conclusion. It is named here so that the next session does not have to re-derive it, and so that if it turns out to be wrong there is a record of why it looked right.
18.3 The comparison
nat-os's wifimac.c names 25 MAC registers. Of those, 21 are silent
during ESP-IDF's entire transmit. The overlap is four:
0x3FF73C48 0x3FF73CC8 0x3FF73D1C 0x3FF73D20
And wifimac_tx() itself — verified line by line against hal_mac_txq_enable,
and this project's own code rather than a blob call — writes exactly two
addresses inside the traced range: 0x3FF73D1C and 0x3FF73D20, slot 0. The
rest of its writes go to 0x3FF742xx, which is outside it.
So nat-os's transmit touches 2 of the 41 registers ESP-IDF's transmit
touches, and none of the busy structured block at 0x3FF73D80–0x3FF73DB8.
18.4 What this does and does not establish
It has to be said plainly, because the number above is the most suggestive thing this project has produced on WiFi and suggestive is exactly where it has gone wrong before.
Three reasons the comparison is weaker than it looks:
-
A transmit-time trace cannot see initialisation. A register written once at setup and never again is silent in every window. nat-os calls
ic_mac_initandlmacInit, which are blob code and write registerswifimac.cnever names. "Not in the source" is not "not written". -
Not every change is a write.
0x3FF73C00changing at every one of 2,047 possible transitions is a free-running counter, not something ESP-IDF wrote. Several of the 41 will be hardware-updated status and statistics, which nat-os would be wrong to write. -
The two firmwares are not doing the same job. §13 already paid for forgetting this: the reference was a SoftAP and nat-os a minimal sniffer, and most of the differences were legitimate. It is a promiscuous raw injector now, which is much closer, but "closer" is not "identical".
What it does establish is narrower and still worth the session: the transmit path's live register set is now known, it is 41 addresses rather than a 4 KB block, and 44 windows of it are provably not involved. Every previous attempt searched a space nobody had bounded.
18.5 The next step, stated exactly
Run the same tracer on nat-os and diff the two traces.
This is the comparison the instrument was built for, and it is immune to all three caveats above — because a blind spot present in both instruments cannot manufacture a difference, only hide one. It is the same argument the second receiver rests on, and that is the argument that settled a question three months of single-board tests could not.
One obstacle, and it is real: nat-os is single-core. The tracer works by
capturing on one core while the other transmits. nat-os would have to start the
APP CPU — unstall it through DPORT_APPCPU_CTRL and point it at a capture loop
that touches nothing else. That is a bounded piece of work and squarely this
project's kind, but it is not free, and it should be recorded as the cost before
anyone starts.
The lead most worth testing first, and it is cheap: have nat-os write the
0x3FF73DB8 sequence — 0x210, then 0x230, then watch bit 5 clear — around
its existing wifimac_tx(). If the radiated-signal test from §5 stays silent,
that is one more clean negative. If it does not, this is over.
19. The lead in §18 was wrong, and the instrument that replaced it
Added revision 2.0, 2026-08-19.
§18.5 proposed writing 0x3FF73DB8's cycle — 0x210, then 0x230, watching
bit 5 self-clear — into wifimac_tx(), on the reading that it was a control
register with a self-clearing "go" bit that nat-os never writes.
19.1 It is read-only
Probed with the MAC running and the receiver armed — the exact state in which ESP-IDF's trace shows it cycling:
register wrote read-back verdict
0x3FF73DB8 ffffffff 00000000 READ-ONLY
0x3FF73DB8 55555555 00000000
0x3FF73DB8 00000230 00000000
0x3FF73DAC ffffffff 00000000 READ-ONLY
0x3FF73DB0 ffffffff 00000000 READ-ONLY
0x3FF73DB4 ffffffff 0ff00a06 READ-ONLY, and free-running
0x3FF73D1C ffffffff 0f3fffff WRITABLE <- positive control
0x3FF73D20 ffffffff 3fffffff WRITABLE <- positive control
The positive controls matter: 0x3FF73D1C and 0x3FF73D20 are the two
registers wifimac_tx() actually writes, and they accept every pattern. So the
probe works, and the rejection is real.
Bit 5 self-clears because the hardware clears it. nat-os does not write these registers because it cannot. The proposed fix would have been code that provably does nothing.
The error is worth naming, because §18.4 listed it as a caveat and then reasoned past it: "not every change is a write." A polling trace cannot tell a software store from a hardware update, and §18 picked the busiest structured block and called it a control path.
19.2 What they are instead: an instrument
Read-only status is worth more than the lead it replaced. Every transmit counter
nat-os owns is its own bookkeeping — hardware=, completions reaped=,
chain acks= — and this project has a rule about that:
a successful completion count is not evidence.
These are inside the MAC. txwatch samples them around one posted frame.
And it needed two corrections before it said anything true.
The first window was 64 samples ≈ 100 µs, and showed nat-os reaching 0x258 and
stopping. That would have been reported as "nat-os never completes a transmit".
It is wrong: ESP-IDF's own completion comes ~320 µs after entering 0x258. At
768 samples / 728 µs, nat-os completes the full cycle. A measurement that ends
before the event cannot be told apart from an event that never happens.
The second was the absence of a control. With one, the MAC turned out to run
0x210 → 0x230 → 0x020 — the exact pattern §18 identified as the transmit
signature — while nothing was being transmitted at all.
19.3 The result
Ten trials each, one posted probe request versus nothing posted, same session, same channel, receiver armed throughout:
| | control | transmit |
|---|---|---|
| 0x058 seen | 0/10 | 5/10 |
| 0x258 seen | 0/10 | 8/10 |
| 0x210 → 0x230 seen | 3/10 | 2/10 |
| 0x3FF73D84 steps | 8 | 2 |
| 0x3FF73D88 steps | 9 | 2 |
| 0x3FF73D8C steps | 12 | 10 |
Three things follow.
nat-os's transmit does reach the MAC. 0x058 and 0x258 never occur idle
and occur in most transmit trials. The state path
0x000 → 0x058 → 0x258 → 0x220 → 0x020 → 0x000 is caused by wifimac_tx() and
by nothing else. This kernel is not failing to arm the hardware.
0x210 → 0x230 is background. It appears in both columns at the same rate,
so it is not a transmit signature — it is the receiver, the TSF timer, or beacon
timing. §18's central identification is retracted.
The counters are background too. D84 and D88 step less during transmit
than idle, which is what receive-side counters do when the radio is busy
elsewhere.
19.4 Why this is progress
The evidence about the transmit fault has, until now, been entirely negative: §5's second receiver hears nothing. "Nothing on the air" is consistent with a fault anywhere from frame construction to the antenna.
This is the first positive, hardware-sourced statement about where the boundary is. The MAC runs a complete transmit state cycle, distinguishable from its idle behaviour, in response to nat-os posting a frame. Combined with §5:
the MAC completes a transmit cycle and nothing reaches the air
which puts the fault after the MAC's state machine and before the antenna — independently corroborating §5's RF/PHY conclusion from the MAC's own registers rather than from an absent signal. Two unrelated instruments now agree, and neither is nat-os's own bookkeeping.
19.5 Next
Run the idle control on tools/idf_ref. §18's trace was taken during
transmit bursts, so its 0x210 → 0x230 activity is a mix of transmit and
background, and nothing there separates them. The same two-column experiment on
the reference board answers the question this whole line of work has been
circling: what does ESP-IDF's MAC do that nat-os's does not, when both are
posting a frame and everything else is subtracted?
That is a small change to idf_ref — a pass with no esp_wifi_80211_tx() —
and it is the first version of this comparison with a control on both sides.
20. A control on both sides, and what is left
Added revision 2.1, 2026-08-19.
§19 ended by naming the one experiment this line of work had never run: the same
idle control on tools/idf_ref. §18's trace was taken during transmit bursts,
so its activity was a mix of transmit and background with nothing separating
them.
idf_ref now captures each window twice, back to back: once with no
esp_wifi_80211_tx() at all, once with a burst of eight. Back-to-back because
ambient traffic varies over minutes, so a control taken later would be a weak
one. tools/serial/tx_control.py pairs them.
20.1 The result
0x3FF73DB8 transitions on the reference board, by mode:
--- idle (no frame sent) --- --- transmitting ---
000 -> 210 x10 000 -> 210 x8 <- also idle
210 -> 230 x10 210 -> 230 x8 <- also idle
230 -> 020 x10 230 -> 020 x7 <- also idle
020 -> 000 x10 000 -> 258 x10 <- NEVER idle
(nothing else) 258 -> 220 x9 <- NEVER idle
220 -> 020 x11
258 <-> 259 x98 (parked, toggling)
0x210 -> 0x230 is background on ESP-IDF too. It appears at the same rate
with and without a transmit, on the reference board, against its own control.
§18's central identification is now refuted twice by independent experiments —
once on nat-os (§19), once here.
ESP-IDF's real transmit signature is -> 0x258 -> 0x220 -> 0x020. Those
transitions occur ten and nine times with a burst and zero times without.
20.2 The two MACs do the same thing
Set the two transmit-caused paths side by side:
ESP-IDF 000 -> (058) -> 258 -> 220 -> 020 -> 000
nat-os 000 -> 058 -> 258 -> 220 -> 020 -> 000
They are the same path. nat-os's MAC runs the transmit state machine ESP-IDF's
MAC runs, in the same order, through the same states, including the 258 <-> 259
toggle while parked.
Two boards, two firmwares, two independently-derived controls, agreeing.
20.3 What this settles, and what it costs
Settled: the fault is not in the MAC layer. That is where nat-os's own code
lives — wifimac.c, wifimac_tx(), the register writes this project
reconstructed from disassembly — and at the MAC's own status registers the two
firmwares are now indistinguishable. Combined with §5:
nat-os's MAC executes the same transmit sequence as a working stack's MAC, and nothing reaches the air.
The fault is below the MAC and above the antenna: the PHY/RF path.
This is the third instrument to reach that conclusion and the first to reach it positively. §5 inferred it from an absent radio signal, §19 from nat-os's own MAC completing a cycle; this one from a working stack and a failing one being identical where they can be compared.
The cost is that it points at the blob. The MAC is nat-os's code and could be
fixed. The PHY is 847 KB of Espressif's binary that this project calls but does
not control, and §16 already established it cannot be replaced from public
information. The remaining difference is in how nat-os calls libphy — the
calibration sequence, its arguments, or the state it expects — not in any
register nat-os writes directly.
That is a smaller search space than the project has had at any previous point, and it is a less tractable one.
20.4 What is worth doing next
Two things, and the second is the honest one.
A phyinit argument and sequence differential. nat-os calls
register_chipv7_phy with an init-data blob and a calibration mode; ESP-IDF
calls it with data derived from NVS-stored calibration and a mode chosen by
whether calibration data is valid. Those arguments, and what ESP-IDF does to the
PHY after that call, are the remaining candidates. Unlike the register hunt,
this is a bounded list.
And the strategic answer §17 already gave. An SX1262 over SPI3 has no PHY
blob, no undocumented calibration, and a published register map. The hardware is
ordered. Every session spent here buys function on one radio; the LoRa path buys
the same capability on a radio this project can actually own — which is what
docs/conceptual/the-ark-and-fiendnet.md needs it for.
The transmit investigation is not abandoned. It is now correctly bounded, which is what four sessions of register work was actually for.
21. The two-board test, measured rather than inherited
Added revision 2.2, 2026-08-19.
§20 concluded that the transmit fault is below the MAC, by pairing a measurement made that day — the two MACs execute the same transmit sequence — with §5's "nothing reaches the air", which is from an earlier session and was not re-run.
Worse, §5 had a gap of its own. Its receiver was validated against a real
access point. It was never pointed at tools/idf_ref. So one assumption was
carrying the weight of the whole investigation without ever being measured:
that a receiver which hears an access point would also hear a nearby ESP32.
Both boards were run together, both directions, each with a control on its own receiver.
21.1 The wrong answer on the way
The first attempt put idf_ref in AP mode — so it would beacon for direction A —
and left promiscuous receive on for direction B. In that combination it heard
four frames in forty seconds, and could not hear the access point nat-os
hears continuously. ESP-IDF's promiscuous receive is all but disabled in AP mode.
The script reported "CONTROL PASSES, TEST FAILS — the expected result" anyway,
from a deaf receiver. It also compared every received address against None,
because its regex looked for a colon-separated MAC that machw does not print:
a detector that could not have succeeded.
Two failures, both producing the expected answer. That is the worst way for a
test to be wrong, and it is the third time in this investigation that an
instrument has agreed with the hypothesis by not working —
wifi_sweep.py's frame-count detector, §18's missing control, and now this.
A negative from an instrument that has not been shown to work is not a negative. It is nothing at all.
The two directions need two separate builds, and REF_LINK_AP now selects one.
21.2 Direction A — can nat-os hear idf_ref?
idf_ref as an AP beaconing NATOS-CTRL-6 on channel 6. nat-os's scan:
frames=366 recycled=366 networks=2
5c:01:3b:51:2b:41 x175 "NATOS-CTRL-6" <- idf_ref
44:25:38:19:0d:1a x10 "TC7NR" <- ambient AP, the control
175 beacons, by BSSID and by name. nat-os's receiver hears this transmitter, easily, and hears an unrelated one at the same time.
21.3 Direction B — can idf_ref hear nat-os?
idf_ref back in STA + promiscuous, receive only. nat-os beaconing and sending
probe requests continuously for 45 seconds, 30 cm away, same channel:
idf_ref heard 315 frames from 3 sources
44:25:38:19:0d:1a x291 <- ambient AP, the control
6e:a6:d8:b0:78:b1 x21
e2:49:d2:8f:5e:6d x3
nat-os MAC = 5c:01:3b:50:3f:64 (decoded from machw's 0x503b015c 0x0000643f)
Zero. A receiver taking 315 frames from three transmitters over the same 45 seconds records nothing at all from the board sitting next to it.
21.4 What is now on measured ground
| | control | result |
|---|---|---|
| idf_ref → nat-os | nat-os also hears an ambient AP | 175 frames |
| nat-os → idf_ref | idf_ref hears an ambient AP ×291, plus two more | 0 frames |
Two boards, 30 cm apart, one channel, one session. One of them transmits and is heard 175 times. The other transmits and is heard zero times. Both receivers are proven working, in the same run, by transmitters neither of them controls.
§5's foundation is confirmed. It is no longer inherited, and the receiver in it is no longer assumed to be able to hear an ESP32 — it demonstrably can.
So §20's conclusion stands on two measurements from the same session rather than one plus a memory:
nat-os's MAC executes the same transmit sequence as a working stack's MAC (§20), and a proven receiver 30 cm away hears nothing from it (§21).
The fault is below the MAC and above the antenna. Nothing in the record now rests on an unmeasured assumption about either instrument.
22. The arguments, compared
Added revision 2.3, 2026-08-19.
§20 and §21 bounded the fault to the RF/PHY path and named what was left: how
nat-os calls libphy. register_chipv7_phy takes three arguments. Here they
are, against esp_phy/src/phy_init.c.
22.1 Argument 1 — the init data. Six bytes were wrong.
A byte-for-byte diff of nat-os's g_phy_init_data[128] against ESP-IDF's
phy_init_data.h, with LIMIT() evaluated and CONFIG_ESP_PHY_MAX_TX_POWER
at its default 20:
idx ESP-IDF nat-os delta note
44 78 40 +9.5dB TX power 11b 1/2 Mbps
45 72 40 +8.0dB TX power 11b 5.5/11
46 66 40 +6.5dB TX power 11g 6-24
47 60 40 +5.0dB TX power 11g 36/48
48 56 40 +4.0dB TX power 11g 54
49 52 40 +3.0dB TX power 11n MCS7
Six of 128 bytes differ, and all six are the transmit power table. The other 122 match exactly.
ESP-IDF writes them as LIMIT(CONFIG_ESP_PHY_MAX_TX_POWER * 4, 40, 78) and so
on, with
#define LIMIT(val, low, high) ((val < low) ? low : (val > high) ? high : val)
so the values actually passed are 80 clamped to each ceiling: 78, 72, 66, 60,
56, 52. nat-os's table held 40, 40, 40, 40, 40, 40 — the macro's LOW bound,
copied six times instead of evaluated. Units are 0.25 dBm, so nat-os was
asking for 10.0 dBm where the reference asks for 19.5.
Fixed.
22.2 Argument 2 — the calibration buffer's MAC field
ESP_ERROR_CHECK(esp_efuse_mac_get_default(sta_mac));
memcpy(cal_data->mac, sta_mac, 6);
register_chipv7_phy(init_data, cal_data, calibration_mode);
esp_phy_calibration_data_t is version[4], mac[6], then the opaque
remainder. nat-os passed a zeroed .bss buffer, so a MAC of all zeros.
Its role is to tie stored calibration to the chip that produced it, and with
PHY_RF_CAL_FULL — which recalibrates regardless — it may well not matter.
Filled anyway. Leaving a known difference in place while hunting an unknown one
is how this investigation has previously lost sessions.
22.3 Argument 3 — the calibration mode. Matches.
nat-os passes PHY_RF_CAL_FULL. ESP-IDF passes PHY_RF_CAL_FULL on the
no-NVS path, and on the NVS path falls back to it whenever stored data fails to
load — which is every first boot. No difference.
22.4 The result: both fixed, and it still does not transmit
Verified in the linked image (4e 48 42 3c 38 34 at g_phy_init_data + 44),
phyinit returns 0, and the two-board rig from §21:
idf_ref heard 52 frames from 2 sources over 45 s
44:25:38:19:0d:1a x37 <- ambient AP, control passes
6e:a6:d8:b0:78:b1 x15
5c:01:3b:50:3f:64 ABSENT <- nat-os
Still nothing. Which was the expectation rather than a surprise: 10 dBm is 10 mW, audible across a room, so a 9.5 dB shortfall was never going to explain total silence at 30 cm. The defect is real, it is in the RF domain, it is fixed, and it is not the cause.
Recorded as a clean negative. Two concrete differences in the PHY arguments have been found and eliminated, and the argument list is now exhausted.
22.5 What is left in the surrounding sequence
The arguments match. What does not is what ESP-IDF does around the call, in
esp_phy_enable():
| ESP-IDF | nat-os | verdict |
|---|---|---|
| esp_phy_common_clock_enable() → DPORT_WIFI_CLK_WIFI_BT_COMMON_M = 0x3c9 | ungates 0x3c9 | identical |
| phy_update_wifi_mac_time(false, ts) before the clock enable | not called | candidate |
| coex_bt_high_prio() after init, unconditional on ESP32 | not called | not available |
| esp_phy_release_init_data() | n/a, static table | not applicable |
coex_bt_high_prio() is the one previously listed as a lead and then recorded
as absent from the archives. That stands, and the reason is now clear: it lives
in libcoexist.a, which nat-os does not link and which is not among the two
archives this project has. It is called unconditionally on ESP32 by every
working stack.
That is a short list, and it is the last of the bounded ones.
23. phy_update_wifi_mac_time — eliminated by reading it
Added revision 2.4, 2026-08-19.
§22.5 left two candidates in the sequence around register_chipv7_phy. This is
the first, and it is eliminated without running anything.
It is not a blob symbol. It is static inline in ESP-IDF's own
esp_phy/src/phy_init.c, so the source is simply there to read:
static inline void phy_update_wifi_mac_time(bool en_clock_stopped, int64_t now)
{
static uint32_t s_common_clock_disable_time = 0;
if (en_clock_stopped) {
s_common_clock_disable_time = (uint32_t)now;
} else {
if (s_common_clock_disable_time) {
uint32_t diff = (uint64_t)now - s_common_clock_disable_time;
if (s_wifi_mac_time_update_cb) {
s_wifi_mac_time_update_cb(diff);
}
s_common_clock_disable_time = 0;
}
}
}
On the enable path en_clock_stopped is false, so it takes the else. The
guard is s_common_clock_disable_time, a static initialised to zero, and the
only assignment to it is from the en_clock_stopped == true branch — which is
reached only from esp_phy_disable().
nat-os enables the PHY once at boot and never disables it. So on nat-os's path
that static is permanently zero, the body never executes, and the call is a
no-op. Were it somehow to execute, it would invoke
s_wifi_mac_time_update_cb, a callback nat-os has never registered. A double
no-op.
What it is actually for: compensating the WiFi MAC's notion of time across a
clock-disable/enable cycle — light sleep, or an explicit
esp_phy_disable()/esp_phy_enable() pair. It exists to close a gap that only
opens when the radio has been powered down and brought back. nat-os has never
powered it down, so there is no gap.
Eliminated by proof rather than by experiment, which is the cheaper and the stronger of the two. Nothing was implemented, because implementing it would have meant writing a function that provably does nothing.
23.1 The list is now one item long
| candidate | status |
|---|---|
| the three arguments | exhausted (§22) — one real defect found, fixed, not the cause |
| clock ungate 0x3c9 | identical |
| phy_update_wifi_mac_time | no-op on this path (§23) |
| coex_bt_high_prio() | called unconditionally on ESP32 by every working stack; lives in libcoexist.a, which this project does not have |
Everything bounded and available has been checked. What remains needs a third Espressif archive, added to a project whose direction has been removing them, to pursue a capability the SX1262 provides with no blob at all.
That is not a technical dead end — libcoexist.a is obtainable. It is a
decision about what nat-os is, and §17 and docs/blob-free.md have both already
argued which way it goes.
24. Open-MAC community intelligence, checked
Added revision 2.5, 2026-08-19. Findings from the ESP32-MAC-Reversing
community, supplied by the project owner, checked against this codebase.
24.1 The ebuf / lldesc layout — already correct, and now proven
The headline item: Espressif published internal buffer headers in
esp-extconn/priv_include/target/esp32/if_ebuf.h, removing the need for blind
structure-padding analysis.
This mattered because nat-os's TX descriptor was reconstructed by guesswork, and a wrong descriptor would explain every observation in this investigation — a MAC that runs its full transmit state machine (§20) while nothing demodulable reaches the air (§21), because the DMA engine fetched from a garbage pointer.
It is not wrong. Compiled with the target toolchain and read out of .rodata,
one field at a time:
| nat-os field | resulting word | ESP-IDF LLDESC_*_MASK |
|---|---|---|
| owner | 0x80000000 | OWNER 0x80000000 |
| has_data | 0x40000000 | EOF 0x40000000 |
| _unknown | 0x3F000000 | offset:5 + SOSF 0x20000000 |
| length | 0x00FFF000 | LENGTH 0x00fff000 |
| size | 0x00000FFF | bits 0–11 |
sizeof 12, packet at offset 4, next at offset 8 — identical to lldesc_t
on all three. The __attribute__((packed)) collapses the two uint16_t
bitfields into a single 32-bit word, which is exactly what the hardware expects.
A negative, but a useful one: this was the strongest remaining hypothesis and it is now eliminated by measurement rather than left standing as an assumption.
Method note: the first pass at this reasoned that uint16_t length : 12
could not begin at bit 12 of a 16-bit storage unit and therefore had to spill,
making every subsequent field wrong. That reasoning was plausible and false.
Compiling it settled the question in one command.
24.2 What the report made visible: §21 had a blind spot
Not one of the community's findings, but found while chasing them — and it undermines a conclusion this report published three sections ago.
tools/idf_ref used ESP-IDF promiscuous mode with default filtering.
WIFI_PROMIS_FILTER_MASK_FCSFAIL is documented "do not open it in general"
and is off by default — so the receiver in §21 silently discarded every
frame that arrived corrupt.
That is precisely the ambiguity §1 says this whole line of work exists to remove:
An AP that ignores a malformed frame looks exactly like a radio that never transmitted.
A promiscuous receiver with default filtering has the same blindness. §21's "nat-os heard 0 times" was therefore consistent with two entirely different faults: nothing radiating, or something malformed radiating.
The filter is now open — WIFI_PROMIS_FILTER_MASK_ALL plus
WIFI_PROMIS_CTRL_FILTER_MASK_ALL — and corrupt frames are counted separately
through rx_ctrl.rx_state.
24.3 A broken detector, caught — the third this session
The first run with the filter open reported bad-FCS frames rising from 625 to 832 while nat-os transmitted, and concluded "something IS radiating and is malformed."
That was wrong. Those are cumulative counters read at two different times;
the second window is simply later. It is the frame-count-delta detector
wifi_sweep.py was retired for, rebuilt from scratch.
Re-run as alternating windows, differencing the counter per window and comparing rates:
window all frames/s bad-FCS/s
silent 1 0.59 0.59
transmit1 0.66 0.66
silent 2 2.22 1.78
transmit2 2.43 1.89
silent 3 29.15 24.05
transmit3 27.40 22.94
mean bad-FCS/s silent=8.81 transmitting=8.50 delta=-0.31
spread (pstdev) across all windows = 10.51
The ambient corrupt-frame rate varies by a factor of forty on its own. The transmitting mean is lower than the silent mean, and both are buried inside the spread. No separation.
24.4 What this leaves
§21's conclusion stands, and is now stronger than when it was written:
A working receiver 30 cm away, explicitly configured to report frames that fail their checksum, records no rise in corrupt frames while nat-os transmits continuously.
Not merely "nothing demodulable" — nothing at all, including malformed frames the receiver was configured to catch.
Stated limit: the ambient bad-FCS rate here swings between 0.6/s and 24/s, so a very low rate of corrupt transmissions could hide inside it. Six windows excludes a signal comparable to ambient, not a weak one.
24.5 Community findings not yet used
Recorded so the next session does not re-derive them:
- Ghidra function discovery — search for the byte pattern
36 x1 01, then keydthenf. That is the Xtensaentryprologue, and it finds function boundaries Ghidra's auto-analysis misses in the blobs. Applicable iflibppis ever disassembled further. - Analyse the
.aarchives, not the linked golden binary — the archives retain inline strings and compiler annotations that the link discards. - OS adapter table hooking for ISR-context callbacks. The technique stands; the defect it was attached to does not — see §24.6.
PLCP0=tx_configin the community's naming. nat-os treats these as two registers four bytes apart (0x3FF73D1Cand0x3FF73D20), both writable and both written. Worth settling which convention is right if the register map is revisited.HT-SIGand the gap at0x3FF74260/0x3FF74264. nat-os writes PLCP1, PLCP2 and DURATION but nothing between PLCP2 and DURATION. Those two words are HT-only in the community's map and nat-os transmits at non-HT rates, so this is expected — but it has not been confirmed.
24.6 The OSI table is not incomplete. Correcting §24.5.
§24.5 listed a standing defect: ositest reporting osi vtable checks = 0x2e, INCOMPLETE (want 0x3F), two of six checks failing. The bitmask decodes as
0x01 malloc/free, 0x02 semaphore, 0x04 queue, 0x08 event group, 0x10
free-heap-size, 0x20 random — so 0x2e means both heap checks failed while
the pool-backed ones passed. A blob unable to allocate memory through the OS
adapter would have been an excellent candidate for a MAC that runs but does not
radiate.
It does not reproduce. 0x3F ALL PASS, every time:
| condition | result |
|---|---|
| framebuffer on, 89 KB free | 0x3f ALL PASS |
| framebuffer off | 0x3f ALL PASS |
| after wifipd on / phyinit / macinit — the state that matters | 0x3f ALL PASS |
| the exact earlier sequence: wintest 12, vendorcall 10, romcall, then ositest | 0x3f ALL PASS |
| twice in a row, to check for stickiness | 0x3f ALL PASS |
The cause of the single 0x2e is unknown and is not being guessed at here. What
is certain is that it is not a standing defect, and that §24.5 recorded it as one
on the strength of a single observation, made in passing, while checking
something else.
That is the process error worth keeping. This report has spent four sections on detectors that agreed with a hypothesis by being broken; this is the mirror image — one unverified reading promoted into the record as a known fault, where it would have cost the next session a day. A number seen once is an observation. It becomes a defect when it reproduces.
25. coex_bt_high_prio — no third blob was needed, and it is not the cause
Added revision 2.6, 2026-08-19.
§22.5 named this as the last bounded candidate and said it "lives in
libcoexist.a, which this project does not have", framing the next step as a
decision about adding a third vendor archive.
That was wrong, and it was asserted rather than checked.
25.1 It is not in libcoexist.a
libcoexist.a contains coex_bt_request and coex_bt_release and no
coex_bt_high_prio at all. The symbol is defined in
esp_phy/lib/esp32/librtc.a, object bt_bb.o:
librtc.a 180K 0000003c T coex_bt_high_prio
librftest.a 1.2M U
libbttestmode.a 1.4M U
libnet80211.a 1.4M U
libpp.a 576K none
libphy.a 840K none
Three archives merely reference it. One defines it, and it is not the one this report named.
25.2 It did not need linking. It needed transcribing.
251 bytes, and every instruction is a load, a mask, and a store. No calls, no
loops, no branches, no data structures. Its literal pool is eight peripheral
addresses in the 0x60000000 alias, which on ESP32 mirrors 0x3FF40000:
| literal | actual | block |
|---|---|---|
| 0x3FF5C080 | — | BT baseband |
| 0x600310D0 | 0x3FF710D0 | BT/PHY |
| 0x60031300 | 0x3FF71300 | BT/PHY |
| 0x60033D30 | 0x3FF73D30 | WiFi MAC |
| 0x60033D38 | 0x3FF73D38 | WiFi MAC |
| 0x60033D40 | 0x3FF73D40 | WiFi MAC |
| 0x600041C4 | 0x3FF441C4 | GPIO |
| 0x3FF5D040 | — | BT baseband |
Three of the eight are WiFi MAC registers in the transmit block — the same
block as this kernel's 0x3FF73D1C/0x3FF73D20 transmit config and the status
registers traced in §19–§20. A function named for Bluetooth coexistence
configures the region this investigation has spent four sessions inside. Which
is exactly why it was worth transcribing rather than dismissing by its name.
Reimplemented as the coexprio shell command, order preserved exactly as
disassembled.
25.3 It applies, it persists, and it changes nothing
before: D=0x00003202 E=0x0c800000 F=0x00000000
after : D=0x01403203 E=0x0c80000a F=0x00000010
The writes take. And re-reading after macinit shows the values unchanged —
before equals after on the second invocation — so the MAC bring-up does not
clobber them.
Tested in both orderings, because order has mattered in this project before:
| sequence | result |
|---|---|
| phyinit → macinit → chan → macrx → coexprio | not heard |
| phyinit → coexprio → macinit → chan → macrx (ESP-IDF's order) | not heard |
Receiver 30 cm away with the FCS-fail filter open, hearing 1,400+ frames from
two dozen other transmitters throughout. nat-os's MAC address appears zero
times. txstat reports hardware=382 completions reaped=295 chain acks=75,
unchanged.
25.4 What this settles
No third vendor archive was ever required. The decision §22.5 framed as
strategic — add libcoexist.a to a project whose direction is removing blobs —
turned out not to exist. The function was transcribable, and transcribing it
cost less than the paragraph arguing about whether to link it.
And the last bounded candidate is eliminated. Every item on §23.1's list has now been checked:
| candidate | status |
|---|---|
| the three PHY arguments | exhausted (§22); one real 9.5 dB defect found and fixed |
| clock ungate 0x3c9 | identical |
| phy_update_wifi_mac_time | no-op on this path (§23) |
| coex_bt_high_prio | applied, persists, changes nothing (§25) |
| ebuf/lldesc descriptor layout | verified bit-for-bit correct (§24.1) |
| OS adapter table | complete, 0x3F ALL PASS (§24.6) |
The MAC executes the same transmit sequence as a working stack (§20). A proven receiver hears nothing, including malformed frames (§21, §24.2). Every argument, every adjacent call, and every data structure has been checked against the reference and matches.
Whatever is missing is not in a list this project can enumerate any more.
25.5 A note on the two wrong assertions
§22.5 stated the symbol's location as fact without looking, and that error
would have led to adding an unnecessary vendor binary — the precise thing the
project's direction is against. §24.5 recorded a one-off ositest reading as a
standing defect.
Both were written in summary sections, at the end of long sessions, about things adjacent to the work rather than in it. The measured findings in this report have held up; the asides have not. That is worth knowing about how this record is produced: the confidence of a sentence in a report should track how it was arrived at, and in this document that has not always been true.
26. The region nobody looked at
Added revision 2.7, 2026-08-19.
§20 concluded the fault was below the MAC and above the antenna. §22 through §25 then searched PHY arguments, an adjacent call, a descriptor layout and an OS table.
Every instrument in this investigation — tools/idf_ref's dump_all, nat-os's
regdump, and the tracer sweep — covered exactly three regions:
0x3FF00000 DPORT 64 words
0x3FF48000 RTC 64 words
0x3FF73000 MAC 1280 words
grep for the PHY and baseband blocks across every instrument returns zero.
The search was narrowed to the PHY and then continued everywhere except the PHY.
26.1 Closed
Three ranges added to both firmwares, extents taken from the addresses
coex_bt_high_prio itself writes:
0x3FF5C000 bb0 512 words
0x3FF5D000 bb1 512 words
0x3FF71000 phy 1024 words
Restoring idf_ref's second dump_all() — lost when the link test replaced the
sweep — and widening reg_diff.py's timeouts for a dump that grew from 1,408 to
3,456 registers.
26.2 The result: one register out of 2,048
| region | stable differences |
|---|---|
| buffer RAM 0x3FF74000+ | 245 (random on both sides, always excluded) |
| MAC | 59 |
| DPORT | 17 |
| RTC | 15 |
| PHY + baseband (2,048 registers) | 1 |
0x3FF5D040 ESP-IDF 40000000 nat-os 80000000 xor c0000000
0x3FF5D040 is one of the eight addresses coex_bt_high_prio writes — its last
operation is [0x3FF5D040] &= 0x7FFFFFFF, clearing bit 31. This differential ran
without coexprio, which is why nat-os still had bit 31 set. But ESP-IDF also
has bit 30 set, and nothing in the transcribed function sets it.
Written directly: 0x80000000 → 0x40000000, matching ESP-IDF exactly, and it
persisted through 45 seconds of transmitting. Still not heard.
26.3 What this actually establishes
This is a negative, and it is the most informative one in the report.
The entire memory-mapped state of the MAC, the PHY and the baseband is now verified equivalent between a stack that transmits and one that does not.
2,048 PHY and baseband registers, one difference, matched, no change. Add the MAC executing the same transmit sequence (§20), the same arguments (§22), the same descriptor layout (§24.1), and a working receiver hearing nothing including malformed frames (§24.2).
Every register the CPU can address is the same. Nothing comes out.
26.4 So the difference is somewhere a register dump cannot reach
That is not a shrug; it names a specific place.
The ESP32's radio is not configured entirely through memory-mapped
registers. The analog front end — VCO, PLL, filters, I/Q, the transmit chain —
is programmed over an internal I²C bus, reached through rom_i2c_writeReg. This
project already uses that mechanism: kernel/clock.c programs the BBPLL through
it, because there is no register-level path and the analog I²C master is not in
the TRM.
Those writes are invisible to every instrument in this report. libphy can
issue hundreds of them inside register_chipv7_phy and no register dump, no
snapshot diff and no tracer will show a single one.
Which is consistent with everything observed: identical memory-mapped state, identical MAC behaviour, a PHY that returns 0, and no RF.
26.5 The next instrument, and why it is different in kind
The regi2c host interface is memory-mapped even though the analog registers
behind it are not. ANA_CONFIG_REG is at 0x6000E044, which through the
peripheral alias is 0x3FF4E044.
So the analog programming can be traced, by watching the I²C host block
rather than the analog registers — the same core-1 tracer already built for §18,
pointed at 0x3FF4E000 during register_chipv7_phy on both boards.
That is a different kind of evidence, not another candidate off a list. It is also the first proposal in this investigation that would show what the blob does rather than what it leaves behind.
Cost: real. register_chipv7_phy runs for milliseconds and issues an unknown
number of transactions; capturing them needs a wider buffer than the 3.9 ms the
tracer currently holds, and the two boards must be doing genuinely the same job
for the comparison to mean anything — a condition §13 already paid for once.
26.6 Honest status
Not stuck for want of ideas. Stuck in the sense that everything cheap is done, and what remains is either that trace, or an SDR to answer the one question no instrument here has ever answered directly: does any RF energy leave this board at all?
Every negative in this report has been consistent with two possibilities — "transmits nothing" and "transmits something unreceivable" — and §24.2 narrowed that only as far as an ESP32 receiver can see. A spectrum analyser or an RTL-SDR answers it in one look, and costs less than another session.
27. Watching the analog bus
Added revision 2.8, 2026-08-19. §26 ended by proposing this and calling it a different kind of evidence. It is, and it works.
27.1 Finding the host
§26's argument: the analog front end is not memory-mapped, but the I²C host that reaches it is ordinary hardware, so the traffic can be watched even though the destinations cannot.
ANA_CONFIG_REG 0x6000E044 is the only address ESP-IDF publishes, and soc.h
has no DR_REG_*_BASE covering 0x3FF4Exxx at all — the block is undocumented.
So rather than assume, the i2cprobe command performs a real transaction and
reports which words moved:
rom_i2c_readReg(0x66, 4, 0) returned 0x00000018
0x3ff4e010 0x01c60566 -> 0x00180066
One word. Decoded against the call:
| bits | field | value |
|---|---|---|
| 7:0 | block | 0x66 = I2C_BBPLL |
| 15:8 | register | 0x00 |
| 23:16 | data | 0x18 — exactly what the call returned |
The before value is what confirms it beyond doubt: 0x01C60566 is block
0x66, register 0x05, data 0xC6. Register 5 is I2C_BBPLL_OC_DCUR and
kernel/clock.c writes it (3<<6)|6 = 0xC6. The word still held this
kernel's own last PLL write.
27.2 The instrument, and the mistake inside it
A change-recording capture on core 1: it appends only when the word differs, so
coverage is bounded by the number of transactions rather than by elapsed time.
That matters because register_chipv7_phy runs for 55 ms and §18's fixed-rate
tracer holds 3.9 ms.
The first run captured 78 transactions, every one to block 0x66 — the
BBPLL, the CPU clock, the single analog block with nothing to do with the radio.
The instrument was pointed at the one word that could not contain the answer. That is §26's mistake — bound the search to the PHY, then look everywhere else — repeated at a smaller scale, four hours later, by the same process.
nat-os's phyi2c settled it, by snapshotting the whole host block either side of
phyinit_run():
0x3ff4e000 00000000 -> 00170063 blk=63
0x3ff4e004 00000000 -> 00000562 blk=62
0x3ff4e008 00000000 -> 0147026b blk=6b
0x3ff4e00c 00000000 -> 01800168 blk=68
0x3ff4e010 01c60566 -> 00c60566 blk=66 <- the one being watched
Five words, five different analog blocks. ESP-IDF publishes headers for exactly
two of these — BBPLL and APLL. 0x62, 0x63, 0x68, 0x6B are the
undocumented RF blocks, and the capture was watching none of them.
27.3 The result
Widened to all five words. docs/data/i2c-trace-idf-phyinit.json:
5,095 transactions across eight analog blocks, over 55.4 ms.
| block | transactions | |
|---|---|---|
| 0x62 | 3007 | undocumented RF |
| 0x63 | 861 | undocumented RF |
| 0x64 | 707 | undocumented RF |
| 0x6b | 241 | undocumented RF |
| 0x67 | 157 | undocumented RF |
| 0x66 | 56 | BBPLL — the only block the first capture saw |
| 0x6a | 45 | undocumented RF |
| 0x68 | 21 | undocumented RF |
Fifty-six of five thousand and ninety-five. The first capture saw 1.1% of the traffic, and the 98.9% it missed is the entire radio.
The opening of the sequence, in order:
idx us blk reg data
0 0.0 6a 02 68
2 6.0 6a 00 26
6 94.5 62 00 00
7 95.7 62 00 30
8 101.2 62 01 30
9 102.5 62 01 80
10 106.3 62 02 08
11 110.0 62 03 08
19 142.7 63 01 f3
21 143.5 6b 03 a8
27 154.1 6b 04 07
31 161.4 62 0a b0
This is the ESP32's RF calibration writing to its analog front end. Nothing in this project has ever seen it, and as far as this author knows it is not published anywhere.
27.4 Stated limits
- The trace is a lower bound. Five words is ~140 cycles, about 0.6 µs at 240 MHz, against transactions observed ~1 µs apart. Some are missed. It records what happened at least, never at most.
- Duplicate suppression. Two identical consecutive transactions to the same
word collapse to one. Visible in the excerpt above —
6a 02 68at index 0 and 1 are two separate captures of the same value from different words, whereas a genuine repeat would vanish. - Only the five block words are watched.
phyi2cshowed other words in the host block move too (0x3FF4E02C,40,44,50,54), one of which carried block0x64. So the word-to-block mapping is not one-to-one and the capture may still be partial.
27.5 What is now needed, and what it costs
The reference sequence exists. The comparison does not, because nat-os is
single-core: register_chipv7_phy runs synchronously and there is no second
core to sample from while it does.
That is the APP CPU work §19.5 already priced: unstall through
DPORT_APPCPU_CTRL_A..D and point it at a capture loop in IRAM that touches
nothing else. Bounded, well-understood, and not free.
With it, the two sequences can be diffed directly — and both boards call the same blob with arguments already verified identical (§22), so any difference at all in 5,095 transactions is the answer, and no difference at all is a negative strong enough to close the RF hypothesis for good.
For the first time in this investigation the next step produces a decisive result in either direction.
27.6 A note kept from §25.5
That section observed that this report's measured findings hold up while its asides do not. §27 is the third instance in one session of the same specific error: narrow the search correctly, then aim the instrument somewhere else. §26 did it with the PHY blocks, §27.2 did it with the host word, and both were caught only by widening on a hunch rather than by design.
The pattern is worth naming because it is not carelessness — each aim was plausible. The defence is not to be more careful; it is to make the instrument cover the whole of the region the argument names, and to treat a narrow aim as a claim that needs its own justification.
28. Both analog traces, and a signal that was not one
Added revision 2.9, 2026-08-19. §27 captured the reference board's analog programming and said the comparison needed nat-os's APP CPU started. It is started, and the comparison exists.
28.1 Core 1, as an instrument
kernel/appcpu.S and kernel/appcpu.c. nat-os remains a single-core kernel;
core 1 gets no scheduler, no interrupts, no heap and no locks. It reads five
peripheral words into an array and stops.
The whole class of multicore bug is avoided by construction rather than by locking: core 1 writes only its own capture arrays, core 0 reads them and writes only the arm flag, and internal SRAM on the ESP32 is not cached so there is no coherency question. If core 1 wedges, core 0 is unaffected — they share no control flow.
It worked first attempt:
core 1 released from stall and reset
alive word : 0xc0dea11e CORE 1 REACHED C
spins : 3838661 and it is EXECUTING
and mem, stacks and every task were unchanged afterwards.
One build defect found on the way. build.ps1 named object files by
BaseName, so appcpu.c and appcpu.S both compiled to appcpu.o and the
second silently overwrote the first. The only symptom is an undefined-reference
at link time for symbols whose source file is plainly present. Now named by full
filename.
28.2 The comparison
| block | ESP-IDF | nat-os | ratio |
|---|---|---|---|
| 0x62 | 3007 | 2287 | 0.76 |
| 0x63 | 861 | 857 | 1.00 |
| 0x64 | 707 | 635 | 0.90 |
| 0x66 | 56 | 16 | 0.29 |
| 0x67 | 157 | 54 | 0.34 |
| 0x68 | 21 | 12 | 0.57 |
| 0x6a | 45 | 36 | 0.80 |
| 0x6b | 241 | 179 | 0.74 |
No block is programmed by one side and not the other. Volumes are broadly
comparable, in a band from 0.29 to 1.00. 0x66 is the BBPLL and is expected
to differ — that is kernel/clock.c's PLL bring-up against ESP-IDF's, and §22
already established both arrive at 80 MHz.
nat-os's PHY takes 220.7 ms against the reference's 55.4 ms, consistent with a 80 MHz core doing the same work as a 240 MHz one.
So the analog programming is equivalent too, as far as this instrument can see.
28.3 The signal that was not one, and how close it came
The first nat-os capture reported 4,099 transactions taken and only 3,454 parseable — 645 lines corrupted. Against that data the table above read very differently:
0x6b 241 -> 8 ratio 0.03
0x67 157 -> 15 ratio 0.10
0x6a 45 -> 7 ratio 0.16
Three blocks at a tenth or a thirtieth of the reference. And there was an argument ready for why sampling could not explain it: nat-os's PHY runs 4× longer while its sampler is only 2.9× slower, so it should catch a larger fraction, not a smaller one. That reasoning is sound, and it was pointing at noise.
A clean re-capture — 4,076 taken, 4,076 parsed — put those same blocks at 0.74, 0.34 and 0.80. The dramatic shortfall was entirely an artefact of the lossy capture.
Worse, and worth recording precisely: a fix was written for the loss (hold
console_lock across the dump, as regdump already does) and it failed to
apply — the edit's second assertion threw before the file was written. The
clean run happened with the unchanged binary. So the interleaving diagnosis is
plausible and unverified, and the clean data cannot be credited to it.
The lock is now in place because the hazard is real. It is commented as precautionary rather than as a fix, because that is what it is.
28.4 Where this leaves the investigation
Every layer that can be compared has been, and matches:
| layer | verdict |
|---|---|
| MAC transmit state machine | same sequence (§20) |
| MAC / PHY / baseband registers | 2,048 registers, one difference, matched (§26) |
| register_chipv7_phy arguments | identical after fixing a 9.5 dB TX-power defect (§22) |
| TX descriptor layout | bit-for-bit lldesc_t (§24.1) |
| OS adapter table | complete (§24.6) |
| analog programming over regi2c | all eight blocks, comparable volumes (§28) |
And a proven receiver 30 cm away, configured to report frames that fail their checksum, hears nothing (§21, §24.2).
There is no longer a layer in this system that has been argued about but not measured. That is worth something on its own, and it is not the same as knowing the answer.
28.5 What would actually decide it now
An SDR. Every instrument in this report is an ESP32 looking at an ESP32. Not one of them can answer the question the whole investigation rests on — does any RF energy leave this board at all — and each negative has been consistent with both "transmits nothing" and "transmits something no ESP32 can receive".
A ~£25 receiver answers it in one look. If energy appears, the fault is in modulation or framing and everything above becomes searchable again. If none does, the fault is in the transmit chain past the point any register or transaction reveals, and that is where this stops being a software problem.
Recommending it is not a retreat. It is the first instrument proposed here that is not a variation on the one that has now returned six negatives.
29. A receiver that is not an ESP32
Added revision 3.0, 2026-08-19. §28 closed by saying every instrument in this report is an ESP32 looking at an ESP32, and recommending an SDR. There was a better receiver already on the desk.
29.1 The test
Both boards beaconing simultaneously, 2.4 GHz channel 6, same bench, same moment, distinct SSIDs:
| SSID | board | role |
|---|---|---|
| NATOS-CTRL-6 | tools/idf_ref, ESP-IDF SoftAP | control — known-good transmitter |
| NATOS-TX-TEST | nat-os, beacon at 100 ms | test |
Receiver: the project owner's laptop. MediaTek MT7921 Wi-Fi 6, a different vendor, different silicon, different driver stack, different decoder.
It had already proved it can hear this bench without being asked to: during §21's
link test, idf_ref logged 20:0b:74:0d:42:bd among the transmitters it heard,
and that is this adapter's MAC.
29.2 The result
NATOS-CTRL-6appears.NATOS-TX-TESTdoes not.
29.3 What this closes
Every negative in this report until now carried the same caveat, stated repeatedly and never removed: the receiver was always an ESP32, so "unreceivable by an ESP32" and "not transmitted" could not be separated. §24.2 narrowed it as far as opening the FCS-failure filter allowed, and no further.
That caveat is gone. Two receiver families, two vendors, agreeing — with a perfect control transmitting from 30 cm away at the same instant on the same channel, which the same receiver sees without difficulty.
nat-os does not emit a decodable 802.11 frame. This is no longer an inference from instruments that share a common ancestor with the thing being measured.
29.4 What it does not close
A WiFi adapter still requires a well-formed frame. So this cannot distinguish:
- no RF energy leaving the antenna at all, from
- RF energy leaving it that no 802.11 receiver can demodulate.
That distinction still wants a spectrum view, and it is still worth £25 if anyone returns to this. But it is now a question about where in the transmit chain rather than whether, and for the practical purpose — does nat-os transmit usable WiFi — the answer is settled and independently confirmed.
29.5 The state of the investigation
Every layer that can be compared has been measured, and matches:
| layer | verdict |
|---|---|
| MAC transmit state machine | same sequence as a working stack (§20) |
| MAC / PHY / baseband registers | 2,048 registers, one difference, matched (§26) |
| register_chipv7_phy arguments | identical, after fixing a real 9.5 dB TX-power defect (§22) |
| TX descriptor layout | bit-for-bit lldesc_t (§24.1) |
| OS adapter table | complete (§24.6) |
| analog programming over regi2c | all eight blocks, comparable volumes (§28) |
| radiated output, non-Espressif receiver | nothing (§29) |
Everything the CPU can observe is the same. Nothing comes out. There is no remaining layer that has been argued about rather than measured, and no candidate left that this project can enumerate.
That is where it stops being a debugging problem and becomes a decision about
what to spend time on — which docs/blob-free.md and §17 both answered before
this session started, and which the SX1262 answers with a documented register
map and no blob at all.
29.6 What the session produced anyway
The radio did not start working. These did, and none of them were the goal:
boot/— a second-stage bootloader, 2,736 bytes against Espressif's 17,536 (UM-NATOS-035).kernel/clock.c— the PLL brought up by the kernel, closing an inherited dependency that had never been stated and that had the board running at half speed (UM-NATOS-036).kernel/appcpu.c— core 1, startable, as an instrument.docs/data/i2c-trace-*.json— a recording of the analog-config bus during PHY init on both firmwares. A capture, not a decode -- see §30 for what it is and its five known defects.- A 9.5 dB transmit-power defect in the PHY init data, found and fixed.
- Six broken or mis-aimed instruments caught, each recorded with what it would have cost.
An investigation that fails to find its target and produces a verified negative across every layer is not the same as a wasted one. But it is finished.
30. What the i2c traces actually are — three corrections
Added revision 3.1, 2026-08-19. §27–§29 describe these files in terms that do not survive looking at them properly. Asked directly what they contain and whether they are worth sharing, the honest answers required correcting the report first.
30.1 They are not 5,095 "transactions"
They are 5,095 observed state changes of the host register, which is not the same thing.
The upper byte carries control bits, and a single logical write appears more than once as those bits advance:
0 ts= 0 raw=0x0368026a upper=0x03 blk=0x6a reg=0x02 data=0x68
1 ts= 283 raw=0x0168026a upper=0x01 blk=0x6a reg=0x02 data=0x68
2 ts= 1444 raw=0x0326006a upper=0x03 blk=0x6a reg=0x00 data=0x26
3 ts= 1727 raw=0x0126006a upper=0x01 blk=0x6a reg=0x00 data=0x26
35.4% of adjacent row pairs carry identical block/register/data with a different control byte — the same write, seen twice. The distinct-write count is therefore substantially lower than 5,095, and every count in §27, §28 and §29 is a count of state changes wearing a transaction's label.
The comparison in §28 is not invalidated by this — both sides are counted the same way — but the numbers mean something narrower than they were made to say.
30.2 "One host word per block" is unsupported
§27.2 asserted it, from phyi2c showing five words each holding a different
block. The capture watched those same five words and recorded eight blocks:
0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6a, 0x6b.
Eight through five. So the mapping is not one-to-one, and worse, the capture does not record which word each change came from — it stores only the value. The relationship between host words and analog blocks is unknown, and §27 stated it as established.
30.3 "Not published anywhere the author can find" — I had not looked
§28.5 and §29.6 both say that. It was written without searching, which makes it an assertion about the world derived from not having checked, and this report has a §25.5 about exactly that habit.
Having now checked: esp32-open-mac's public documentation does not cover the PHY
analog front end, the regi2c bus, 0x3FF4E000, or those block IDs. Their stated
position is that the blobs "are currently still needed for hardware
initialization" and "will eventually be fully replaced with open source code."
So the area is plausibly uncovered in that project's public docs. That is a much smaller claim than the one made, and one README is not a literature search.
30.4 What the files therefore are, stated accurately
A recording of traffic on the ESP32's internal analog-configuration bus during
register_chipv7_phy(), from two firmwares.
Each row is (index, cycles_since_first, raw_word, block, register, data).
Across the reference capture: eight analog blocks, with 2 to 14 distinct
registers seen in each.
This is a bus capture, not reverse engineering. It records that block 0x62
register 0x0a received 0xb0. It says nothing whatever about what block 0x62
is, what register 0x0a controls, or what 0xb0 means. It is raw material for
reverse engineering and should never be described as its product.
30.5 Known defects, if this is ever shared or reused
- A lower bound. Sampling is ~0.6 µs on the reference against writes ~1 µs apart. Some are missed. Never treat it as a complete sequence, and never attempt to replay it as one.
- Phase duplicates, §30.1. Not filtered.
- Source word not recorded, §30.2.
- Incomplete coverage.
phyi2cshowed0x3FF4E02C,40,44,50and54also moving during PHY init. The capture watches none of them. - No semantics. None of it is decoded, and nothing here is a step toward decoding it.
All five are fixable — record the word index, filter on the control byte, widen to every host word that moves, and accept the rate limit explicitly rather than silently. None of that was done, because the trace existed to answer a comparison question and it answered it.
31. "The same blob with the same arguments" was true of PHY init only
Added revision 3.2, 2026-08-19. Asked how far this is from working, and the answer required checking a claim this report has leaned on repeatedly:
both boards call the same blob with arguments already verified identical (§22), so any difference at all is the answer
That is true of register_chipv7_phy. It was never true of transmit, and
several sections used it as though it were.
| | ESP-IDF | nat-os |
|---|---|---|
| PHY init | register_chipv7_phy in libphy.a | same function, same archive, arguments verified identical (§22) |
| transmit | esp_wifi_80211_tx in libnet80211.a → libpp → hal → registers | hand-written register writes, reconstructed from a disassembly of hal_mac_txq_enable |
esp_wifi_80211_tx is defined in libnet80211.a. nat-os does not have that
archive — vendor/phy/ holds libphy_natos.a and libpp_natos.a and nothing
else. And wifimac_tx() calls no vendor function at all; it writes the hardware
directly.
So the two firmwares are not running the same transmit code, have never been running the same transmit code, and one of them is running a reimplementation of one function out of a chain that spans three archives on the other side.
31.1 What this does and does not change
It does not weaken §20, §26, §28 or §29. Those measured the hardware's resulting state and the radiated output, and those measurements stand: the MAC reaches the same states, the registers match, the analog bus traffic is comparable, and nothing comes out.
What it changes is the inference drawn from them. "Everything is identical and nothing works" was always slightly wrong — the transmit software was never identical. The correct statement is narrower:
Every observable end state matches. The path taken to it on the nat-os side is this project's own reconstruction of an undocumented sequence, and has never been compared against the original because the original is not linked.
That is a materially better description of where the fault could be hiding, and it reopens exactly one thing §25 declared exhausted: the possibility that the reconstruction is incomplete in a way no end-state comparison can show.
Written by: Hare
../nat-os-test/docs/UM-NATOS-034-the-second-receiver.md*.md · Rendered from markdown at build time.