UM-NATOS-028 — WiFi, Touch, and the Column That Ate the 3D View
UM-NATOS-028 — WiFi, Touch, and the Column That Ate the 3D View
Used Medias LLC — Embedded Systems Division Revision 1.2 · 2026-08-16 · Status: Receive working, transmit not. The display fault this report left open was found — see UM-NATOS-030. §3.1 carries a withdrawn claim; the correction is in the body.
1. Abstract
This report covers one long day that went: let us finish the WiFi MAC →
hold on, why is task_sleep not sleeping → the touchscreen has gone
strange → and now the 3D view is torn → ah, it was a rectangle of black
paint the whole time.
It ends well. nat-os now receives 802.11 frames, decodes beacons, names the networks around it, and keeps doing so continuously while rendering a 3D view. It also transmits — in the sense that the MAC accepts frames and reports them complete — but nothing on Earth has yet heard one, which is a distinction §5 will insist on at some length.
The debugging is the interesting part, as usual. Four separate theories about
the display fault were investigated, measured, and executed. All four were
wrong. The fifth was a display_fill_rect() that had been minding its own
business for weeks and only became a problem when something else grew to
240 pixels wide.
There is a running theme, and it is not "hardware is hard". It is that several of this kernel's own instruments were lying, and each one sent the investigation somewhere expensive. A tally is kept in §8.
2. The WiFi arc, briefly, because it actually worked
2.1 The OSI table gets bodies
wifi_osi_funcs_t is Espressif's 116-entry function table: the entire
contract between the MAC blob and whatever operating system is hosting it.
Previously nat-os provided all 116 entries with the correct types, in the
correct order, containing nothing whatsoever. It linked beautifully and would
have collapsed the instant anything called it.
Thirty-nine entries now do real work: semaphores, mutexes, queues, event groups, software timers, the malloc family, delays, tick conversion and a PRNG. The remaining stubs are things the MAC has not asked for yet.
The shape of the solution matters more than the count. The table must be
windowed ABI, because libpp calls it. The kernel is call0 and always will
be. So every windowed entry does no work at all — it forwards through
w2c_callN() in window.S into wifi_osi_impl.c, where the heap and
scheduler actually live:
libpp (windowed) → wifi_osi.c entry (windowed, does nothing)
→ w2c_callN (window.S)
→ wifi_osi_impl.c (call0: heap, scheduler, tick)
Design notes worth keeping:
- Fixed pools, not the heap. The blob allocates during init and holds its objects forever. Static arrays cannot fragment, cannot fail late, and cost a known amount of DRAM.
- Handles are tagged indices (
0x05100000 | index), checked on every use. The blob is not code anyone here can audit, and a stale handle should be rejected rather than become a wild write. - Blocking uses
task_sleep, nottask_block— even for an infinite wait. A lost wake-up ontask_blocknever runs again. This costs a tick of latency on a missed wake; the caller re-checks anyway. Deliberate pessimism.
ositest drives the table through its function pointers exactly as libpp
will, so each check crosses windowed→call0 and back. All six pass, pools
return to zero, nothing leaks.
2.2 The MAC wakes up
open-mac's entire MAC initialisation is one masked register write:
MAC_CTRL_REG = MAC_CTRL_REG & 0xffffe800;
The work was proving it did anything. This kernel has been caught three
separate times by peripherals whose registers read back perfectly while the
hardware sat completely dead — LEDC behind DPORT_PERIP_CLK_EN bit 11 being
the clearest (UM-NATOS-027 §3.3). A readback is not evidence.
So instead: scan the 4 KB MAC register window, wait, scan again, and count words that moved with nothing driving them. A gated block is static. A running MAC has free-running counters in it, and no write that went nowhere can fake one.
| stage | moving words |
|---|---|
| cold boot | 0 |
| after phyinit | 6 |
| after macinit | 13–15 |
2.3 Finding the TSF timer by behaviour
Espressif publishes nothing about this register map, and every address in circulation is reverse-engineered. Rather than assert that some offset is the TSF timer, the scan reports the rate of every mover:
0x3ff73c00 1000 kHz <- stable across every run
0x3ff73c14 1054 -> 1102 kHz
0x3ff73c18 1055 -> 1107 kHz
0x3ff73dd0 1063 -> 1114 kHz
0x3ff73c00 is the only word reporting exactly 1000 kHz every time;
everything else drifts. Confirmed over a long interval against the CPU's own
cycle counter — two clocks with no connection to each other:
tsf advanced 622458 over 622 ms of cpu time -> 1000 kHz
tsf advanced 508686 over 508 ms of cpu time -> 1001 kHz
Agreement to 0.1% over half a second is not something a misread address produces. nat-os now has a 1 MHz timebase, which it did not have before — the kernel tick is 10 ms.
2.4 The MAC address, and a better oracle
A six-byte address looks equally plausible in any byte order, so the decode
needed checking against something. The first attempt compared the top three
bytes against a list of Espressif OUIs and reported failure for
5c:01:3b:50:3f:64.
The decode was right. The list was wrong. That test was measuring a recollection of IEEE registrations, not the hardware.
eFuse burns a CRC8 of the address in the same block. Checking against that
tests the decode against data on the chip: a wrong byte order cannot pass, and
no outside knowledge is involved. 5c:01:3b:50:3f:64 gives 0x08, which is
the stored byte; the reversed order gives 0x8f.
The better oracle was already on the chip. This will come up again.
2.5 It receives
802.11 frame: BEACON len=348
bssid 44:25:38:19:0d:1a ssid "TC7NR"
A real beacon from a real access point, on a kernel built -mabi=call0
running Espressif's PHY blob through hand-written window handlers.
The blocker had been chip_v7_set_chan_nomac panicking with StoreProhibited.
Three theories; the two wrong ones cost the most, and the measurements that
killed them were the valuable part:
- Stack depth. The PHY got a private 6 KB stack. The fault moved forward without going away — which looked like progress and was not. Priming the stack with a pattern and reporting its high-water mark from the panic handler settled it: 272 bytes of 6144 used. Depth was never the problem, and without that number the obvious next move was a bigger stack, which would also have failed.
- Stale
WINDOWSTARTbits. Declaring exactly one live frame before entering the blob: no change. - The base frame was not a windowed frame. This was it.
_WindowOverflow8 — the handler in this very repository — spills a4..a7
relative to the caller's stack pointer, which it fetches with:
l32e a0, a1, -12 /* a0 <- call[j-1]'s sp */
Every windowed frame must carry its caller's stack pointer at sp - 12.
ENTRY does not write it; the caller's prologue does. phy_stack_call is
call0 code and wrote nothing there, so the handler read a fresh .bss zero
and spilled to 0 - 32.
That also explains why the reported PC was never where the store was: the
spill faults inside the overflow handler, which runs with PS.EXCM set, so
the second fault vectors to the double-exception handler while EPC1 still
holds the instruction that caused the original overflow — the call8.
Hours were spent disassembling the wrong instruction.
The fix is one store. Everything after it worked first try.
2.6 Continuous reception
Recycling descriptors per open-mac's rs_recycle_dma_item turned "captured
four frames once" into a working scanner:
frames=372 recycled=374 networks=2
44:25:38:19:0d:1a x199 "TC7NR"
38:88:71:2d:c1:cd x53 "Verizon_S6QHX4"
With four buffers, 372 frames can only come from reuse. It also kept receiving through a 3D rendering session — the radio and the display share nothing but the scheduler, and neither starved.
3. Transmit, and the difference between "sent" and "sent"
The MAC accepts frames and reports every one complete:
tx handed to hardware=178 completions reaped=178 forced=0
This report initially described a rising completion count as "the MAC saying the frame actually went out". That was an overclaim, and the user checked their phone for the beacon SSID, and it was not there.
The test that settles it is a probe request. A beacon is a statement and nothing has to answer it, so silence proves nothing. A broadcast probe request with a wildcard SSID obliges every access point in range to send a probe response addressed to this station — and one arriving is unforgeable, because a radio cannot hear itself and nobody sends to this MAC without having heard from it first.
sent 20 probe requests
completions reaped=20
frames addressed to us=0
Twenty frames the hardware called complete, zero answers from two access points loud enough that we receive them continuously. A completion bit says the frame left the queue, not that it left the antenna.
Two candidate causes were eliminated by measurement:
- Transmit power.
most_tpwalready reads0x28— 40 quarter-dBm, 10 dBm — straight out ofregister_chipv7_phy. Never zero.phy_set_most_tpw(78)returns success and changes nothing. - The MAC hardware init chain.
ic_mac_init,hal_init,ic_enable_rxandhal_mac_tsf_resetall run without a crash. Still zero answers.
That last one produced two genuinely good pieces of news:
The IRAM panic was unfounded. Referencing those four functions cost
2,459 bytes (116,692 → 119,151 text against 131,072 of IRAM), not the
48 KB this project has warned about since MAC-NEXT.md. That figure came from
a --whole-archive measurement, which pulls every object; a real link pulls
only what is referenced. A constraint that had been shaping decisions for
weeks simply did not exist.
The blob runs. This was the first time nat-os executed libpp at all, and the OSI table, the window handlers and the mixed-ABI bridges all held up under the code they were built for. That was never certain.
Also learned, expensively: naming a symbol changes the link. Referencing
ram_tx_pwctrl_bg_init, which was not previously linked, pulled fresh objects
out of libphy.a, after which register_chipv7_phy died with
IllegalInstruction inside set_rx_gain_testchip_70 — a calibration function
nobody had touched and which had worked for days. Rule adopted: reference
only what open-mac references, and check with nm that a symbol is already
in the image before calling it.
Strongest untried lead: periph_module_reset(0x19). nat-os has only ever
ungated the WiFi peripheral, never reset it, and a MAC left in whatever
state the ROM bootloader put it in would plausibly receive while refusing to
transmit. That asymmetry fits the symptom exactly.
3.1 Both of those leads are now dead (2026-08-18)
The MAC reset. Tried, and it is not the answer. DPORT_WIFI_RST_EN_REG
bit 2, DPORT_WIFIMAC_RST, pulsed directly — and only bit 2, because resetting
the baseband or front end would undo the ten-second register_chipv7_phy
calibration that receive depends on.
Correction (rev 1.2). This section first claimed that
periph_module_reset()"does nothing forPERIPH_WIFI_MODULEbecauseget_rst_en_mask()returns 0 for it". That was asserted from memory, without the ESP-IDF source to hand, and is exactly the kind of confident unverified claim the rest of this report is about. It is withdrawn.What is established: pulsing
DPORT_WIFIMAC_RSTdirectly changes nothing. Also unresolved — open-mac callsperiph_module_reset(0x19), and 0x19 is 25, which in the IDF v5periph_module_tenum is more plausiblyPERIPH_BT_MODULEthan WiFi. Whether open-mac is resetting Bluetooth on purpose, relying on a different enum ordering, or has its own transcription error is not known and would need the v5.0.1 header to settle.
Result: receive survived (beacons from 8e:49:62:f0:ae:bd and
7e:26:f6:5f:57:c6), and transmit is unchanged — 10 probes handed over, 10
completions reaped, forced=0, nothing answers. Retained behind macrst so it
is opt-in rather than removed.
The transmit-side init chain. Three functions that arm what transmit needs,
all verified present in the image with nm before being referenced:
| step | function | result |
|---|---|---|
| 4 | hal_mac_rate_autoack_init | returned, no crash, no change |
| 5 | hal_attenna_init | returned, no crash, no change |
| 6 | hal_mac_disable_low_rate | returned, no crash, no change |
phyinit still returns 0 afterwards, which is the canary for §3's link rule —
naming these three did not pull new objects, exactly as the nm check
predicted.
3.2 wifimac_tx() checked against open-mac's source, line by line
Done, and it is clean. The suspicion was that a reverse-engineered register
map had a transcription error in it — the same failure mode as UM-NATOS-030's
one-bit OUTLINK_START. It does not.
| register | open-mac | nat-os |
|---|---|---|
| MAC_TX_PLCP0 | 0x3ff73d20 | 0x3FF73D20 |
| WIFI_TX_CONFIG | 0x3ff73d1c | 0x3FF73D1C |
| MAC_TX_PLCP1 | 0x3ff74258 | 0x3FF74258 |
| MAC_TX_PLCP2 | 0x3ff7425c | 0x3FF7425C |
| MAC_TX_DURATION | 0x3ff74268 | 0x3FF74268 |
The strides differ in units only — open-mac's -2 and -0xf are uint32_t
indices, nat-os's 8 and 60 the same values in bytes — and are irrelevant
regardless, because TX_SLOT is 0 and every access lands on the base. Bit
patterns, write ordering, the descriptor fields, the sequence-number update and
the final 0xc0000000 are identical. The one omission is
(crypto_key_slot & 0x1f) << 17, which contributes zero because the slot is 0.
So the difference is in the INIT, not the transmit. What open-mac's
wifi_hw_start_openmac() does that nat-os does not:
| open-mac | nat-os | linked? |
|---|---|---|
| esp_wifi_power_domain_on() | never | no |
| coex_bt_high_prio() ×2 | never | no |
| WIFI_MAC_BITMASK_084 &= 0x7fffffff | never — only bit 0 is used | n/a |
| wifi_module_enable() | equivalent: DPORT_WIFI_CLK_EN | — |
| esp_phy_enable_openmac() | equivalent: phyinit | — |
| ic_mac_init, hal_init, ic_enable_rx, hal_mac_tsf_reset(0) | all four, via hwinit | yes |
3.3 Clearing bit 31 of WIFI_MAC_BITMASK_084 kills receive
This looked like the free experiment. open-mac clears bit 31 of 0x3ff73084
at init and sets it only in filters_set_ap_mode, alongside
hal_mac_tsf_reset(1) — which reads as an AP/beacon-mode flag. nat-os had never
touched it in either direction, and a bit the ROM bootloader left set would put
the MAC in a mode expecting a beacon schedule it has never been given. One
store, no link change.
It killed receive outright.
| | before | after clearing bit 31 | |---|---|---| | descriptors filled | beacons arriving continuously | 0, and still 0 eight seconds later | | rx chain acknowledges | 157 | 1 | | beacon decoded | yes | none |
Reverted, and receive returned immediately — chain acks back to 154, a beacon
from 7e:26:f6:8f:57:c6. The causality is not in doubt.
So bit 31 is not what its use in filters_set_ap_mode() implies, or the
write has a side effect at this point in nat-os's sequence that it does not have
in open-mac's. The inference was wrong and only the measurement caught it. The
register is now read at init and reported — the value says what the
bootloader left behind, which nothing else does — and deliberately not written.
What is left, in order of cost:
coex_bt_high_prio()andesp_wifi_power_domain_on()— open-mac calls both, nat-os neither, and neither is in the image, so calling them changes the link (§3's rule).lmacInit/lmacInitAc— also not linked, and the largest change of all.
Every cheap experiment is now spent. What remains all requires accepting a link
change, with phyinit returning 0 as the canary and a way back.
4. The task_sleep detour, which was not a detour
Mid-WiFi, the TSF check reported ~1 µs for a requested 500 ms sleep. Two independent clocks agreed on that, so it was not a measurement artefact.
Two bugs, stacked.
timer_ticks() counted ISR entries, not time. task_yield() ends a slice
by pulling CCOMPARE1 back to ccount + 64, so timer_isr ran on every
yield as well as every real deadline — and g_ticks++ sat at the bottom of it
unconditionally. Measured at 217 ticks per real second where 100 is
correct, and far higher when a task sat in an idle-yield loop. Every deadline
expressed in ticks came due early, in proportion to how much yielding the
system happened to be doing.
task_yield() arms a switch; it does not perform one. It writes the
comparator and returns, so the caller keeps running for ~60 cycles into what
it believes is a sleep. task_sleep(50) — half a second — returned to its
caller in 107 cycles, which is the cost of the function body and nothing
else. The sleep did happen; it started after the caller had already read the
clock and concluded no time had passed.
Both fixed. task_sleep(50) now takes 639 ms / 64 ticks, and 64 × 10 ms =
640 ms — the tick count and the wall clock agree, which they did not before.
This mattered for WiFi specifically: every OSI queue and semaphore timeout is
built on task_sleep, so anything the blob did with a timeout was quietly
unreliable. But the wider point is the uncomfortable one, and timer.c had
already written it down about an earlier bug in the same function:
nothing showed a symptom. Sleeps ran short, timeouts ran loose, and every
frame-rate figure this project has ever recorded was measured against a clock
running fast by a load-dependent factor.
5. The touchscreen gets worse, twice
5.1 First regression: a background task outranking the user
The WiFi receive task was raised to HIGH to get beacons from 3 Hz to 9.4 Hz.
The raycaster blit was checked, found unchanged, and the change declared a
clean win.
The touch task is NORMAL. With two flat-out HIGH tasks instead of one
it stopped being scheduled except when ageing rescued it — roughly every
300 ms. The panel went from responsive to intermittent, which no counter in
this kernel reports and which the user noticed within minutes.
It was also a bad trade on the merits: beacon timing was prioritised over input latency, and the beacons were not reaching the air anyway, so the 9.4 Hz was entirely theoretical.
The useful measurement here was the one that ruled the obvious suspect out.
update_rx_chain() spins on a hardware acknowledge and looked exactly like
the cost; instrumenting it showed a worst-case wait of one iteration. It
was never the radio, it was the scheduler.
5.2 Second regression: a faithful restoration that was not
Reverting the priority was not enough — touch degraded to needing a press-and-hold. The cause was an earlier change that had been described, in a commit message, as a "restoration".
The touch loop originally called task_sleep(1u) back when task_sleep
neither slept nor yielded. So it polled repeatedly inside whatever slice it
got, which is exactly why touch felt continuous. Replacing it with
task_yield() looked equivalent and was not: a yield surrenders the CPU after
every single poll, so the task sampled once per slice instead of many
times, and a quick tap fell between samples.
Fixing task_sleep had, with perfect irony, broken the thing that was
accidentally relying on it being broken.
The final configuration is a real 10 ms sleep at HIGH priority — 100 Hz
sampling for one SPI read per tick, which is far less CPU than the old
flat-out polling and, unlike it, has a rate that is a property of the clock
rather than of whatever else happens to be running.
| | before | after | |---|---|---| | touch samples per reporter interval | ~15 | ~150 |
6. The 3D view, and four confident wrong answers
Then the 3D view started tearing, and the close button vanished.
6.1 Theory one: DMA had fallen back to the FIFO path
The DMA wait is bounded on wall clock at ~25 ms, justified in a comment as "far beyond the ~100 µs a 480-byte transfer needs" — true of the transfer and false of the wait, because a preempted task can exceed it easily. And a timeout permanently disables DMA. The reasoning was sound.
The counter said dma=320/0. Zero timeouts. Ever.
The bound was raised anyway on the general principle that a wall-clock wait should survive preemption, then restored when it fixed nothing.
6.2 Theory two: touch preempting the display mid-transfer
Instead of one flash per guess, both knobs were made runtime-tunable
(touchcfg <prio> <sleep>), and all four combinations tested in a single
build:
NORMAL + yield 31399 31332 31394
HIGH + yield 31352 31398 31353
NORMAL + sleep1 31399 31346 31385
HIGH + sleep1 31424 31387 31438
Identical. Touch scheduling does not move it. This is the single best thing that happened during the display investigation, and it should have happened three rounds earlier.
6.3 Theory three: applications drawing over the view
ps showed ping and pong running and drawing, with drawskip climbing —
proof that some draws were being skipped, and therefore that others were
succeeding. Killed all three applications. drawskip froze. No change on
screen.
6.4 Theory four: the panel clock
The display driver has documented since UM-NATOS-015 that clocking this panel
too fast puts visible noise on the glass while every counter reports
success. That is a suspiciously exact description of the symptom, so a
runtime clock selector was added (spiclk 0|1|2) and the panel dropped from
40 MHz to 10 MHz.
Different. Still wrong.
At this point the investigation had a genuinely misleading piece of evidence: the same commit rendered correctly earlier in the day and torn later, with no code change in between. That reads as hardware. A reasonable-sounding case was made for a marginal display flex and a sagging USB rail, and the user was asked to reseat connectors.
The user replied, in effect, no, it is a bug in the code, because the X button is also missing.
They were right. What differed between the two runs was not the hardware. It was which application slots happened to be occupied — see §6.6.
6.5 The test that should have been first
A static test pattern through the raycaster's exact blit call: same buffer, same width, same stride, same contiguous path. Six vertical colour bars, plus a white block in the top-right corner where the close button goes.
This splits one question into two, and the two have completely different answers:
- If the pattern is sheared or torn → the transport is broken.
- If the pattern is clean but something is missing → the content is fine and something overwrites it afterwards.
Result: clean bars, and a hole exactly where the white block had been written.
(The first attempt at this panicked, because raycast_framebuffer() returns a
boolean and the test used it as a pointer, storing through address
0x00000001. raycast_fb_ptr() now exists so nobody repeats that. It was a
fast and educational panic.)
6.6 The actual bug
desktop_chrome() repaints a right-hand column every frame, one strip per
application slot — a name and a red X for running slots, and solid black
for empty ones:
display_fill_rect(CHROME_X, y, APP_CHROME_W, APP_VIEW_H, COLOR_BLACK);
That column is documented as reserved outside every application viewport, which is entirely true — for applications, whose viewports are narrower.
The 3D view is not an application. RAY_VIEW_W is 240. DISP_W is 240. The
view owns the whole width, including that column, and the chrome runs
immediately after raycast_frame(), painting over the right edge of a view
that had just drawn it.
One cause, every symptom:
- the tearing along the right edge
- the missing close button
- the vanished white test block, in precisely that corner
- and why killing the applications made it worse — an empty slot fills the column with solid black, while a running one at least draws text
That last point is also why "the same binary behaved differently" was so convincing and so wrong. The binary was identical. The application slots were not.
The fix follows a pattern already established in that file for exactly this
reason: a full-width view stamps its own close button into the framebuffer so
it arrives in the same transfer, and the chrome loop now returns early in
MODE_3D. Applications remain closable from the launcher.
7. Where things ended up
| | |
|---|---|
| 3D view | fixed, close button present, corrupt=0 |
| Touch | 100 Hz at HIGH priority, ~10× the previous sample rate, default |
| WiFi receive | working — continuous scanning, beacon decode, descriptor recycling |
| WiFi transmit | MAC accepts and completes frames; nothing hears them |
| task_sleep | actually sleeps, for the first time |
| timer_ticks() | actually counts time, for the first time |
| IRAM budget | not a real constraint; the 48 KB figure was a measurement error |
8. The instruments that lied
This is the part worth keeping. Every one of these cost real time.
1. The blit timer. t_blit uses task_cpu_cycles(), which only advances
when the scheduler credits a slice at a context switch — it does not tick
inside one. So the figure tracks context switches, not work. A 55.85 → 31.3
ms shift was read as a serious regression, reported to the user as a 44%
improvement an hour earlier, and was neither. It was an artifact both times.
2. timer_ticks(). Counted ISR entries rather than elapsed time, so every
tick-denominated deadline in the kernel came due early by a load-dependent
factor. Silent for months.
3. The transmit completion bit. Reports that a frame left the queue. Was described as proof the frame left the antenna. It is not, and a phone was the instrument that caught it.
4. The OUI list. A hand-written table of Espressif prefixes declared a correct MAC decode invalid. The chip's own CRC8 was sitting right there.
5. The PHY stack high-water mark. Reported 6144 of 6144 bytes used in a
real panic report — not a large number, but a missing measurement, because
an unprimed .bss stack is all zeros and the scan reads that as fully
consumed. It looked exactly like a stack exhaustion that had not happened.
6. raycast_framebuffer(). Returns a boolean. Named like an accessor.
Panicked a diagnostic that trusted the name.
7. fb=on in the reporter. Reports the framebuffer mode, not whether a
buffer exists — fine, but easy to misread while hunting a rendering fault.
The pattern across all seven: the counters were all clean while the picture
was visibly wrong. corrupt=0, dma=320/0, no timeouts, no skew, no lock
contention worth mentioning. Every automated check this kernel has said
everything was fine, and every one of them was telling the truth about the
narrow thing it measured.
The person looking at the screen was the only instrument that could see the actual fault, and twice in this session that person was right while the counters and the reasoning were wrong. Worth remembering before the next confident hardware diagnosis.
9. Method notes for next time
Three things worked well enough to be worth repeating deliberately:
Make the variable runtime-tunable before forming a theory about it.
touchcfg turned four hypotheses into one flash. spiclk did the same for
the panel clock. Both were written after several reflash-per-guess rounds
that they would have eliminated.
When something looks corrupted, draw a static test pattern first. It separates "the content is wrong" from "something overwrites it afterwards" in a single step, and those two have disjoint suspect lists. This was the fifth thing tried and should have been the first.
Prefer an oracle that lives on the chip. The eFuse CRC beat a hand-maintained OUI list. Two independent clocks agreeing beat one clock asserting. A hardware acknowledge bit clearing beat a register reading back what was written.
And one process note: when a user says it worked a minute ago on this exact build, that is data, but it is not automatically evidence of hardware. It is equally evidence that something in the run-time state differs — in this case, which application slots were full. Enumerate that state before reaching for the screwdriver.
10. Open: the startup glitch, and the measurement that cracked it open
Not everything got solved, and the unsolved part has the sharpest clue in the whole report.
Symptom. Opening the 3D view sometimes shows a wrong picture for ten to
thirty seconds, then it comes good on its own. A second program (gfxrogue)
was observed glitching over the same period and recovering at the same moment.
What is measured and known:
- With no applications running, the view is correct immediately. That is a clean A/B and rules out the renderer in isolation.
- In steady state there is no contention to speak of: 18-19 frames per
reporter interval,
cont=0,dlock blk=0, with or without applications. - There is no periodic stall. A 55-second capture shows
dHoldsteady at ~2000 ms per interval and frames advancing uniformly. - The camera is not burying itself in a wall — it wanders normally from the first sample.
- Opening
gfxroguemakes the 3D view correct. Starting another program REPAIRS it.
That last one is the interesting one. If a second program starting fixes the first, the view is missing an initialisation step that an application start happens to perform, rather than being actively corrupted by anything.
A wrong turn worth recording. The first fix returned from
desktop_chrome() outright in MODE_3D, reasoning that the chrome column was
painting over a full-width view. The geometry disagrees: APP_VIEW_Y0 is 224
and RAY_VIEW_H is 224, so the strips begin exactly where the view ends. The
guard has been narrowed to a _Static_assert that fails the build if the view
ever grows into the strips — which is the check that should have been written
first, since it answers the overlap question at compile time instead of from
the glass.
The blittest that produced that theory was also contaminated: it called
desktop_set_active(1) to stop the renderer overwriting the pattern, which
put the launcher into repaint mode and erased the test block itself. A
diagnostic that changes the state it is measuring is worse than no
diagnostic, and this one was believed for several rounds.
Everything ruled out so far, each by direct measurement rather than argument:
| theory | how it died |
|---|---|
| DMA fell back to the FIFO path | dma=320/0 — the guard has never fired |
| Touch scheduling | identical blit across all four priority/poll combinations |
| Applications overdrawing the view | killed them all; drawskip froze; no change |
| Panel signal integrity | clock 40 -> 10 MHz changes appearance, does not fix |
| The chrome column | APP_VIEW_Y0 224 == RAY_VIEW_H 224; they never overlap |
| Movement catch-up burst | fixed by raycast_open(); symptom unchanged |
| Touch calibration | was genuinely broken and is fixed; symptom unchanged |
| An arena overlapping the framebuffer | fb 0x3ffbcd70..0x3ffd7170, arenas from 0x3ffd7590 — clear |
What is known for certain:
- The renderer runs at FULL RATE throughout. A 54-second capture of a real
tap-driven session shows
act=0and frames climbing steadily at ~8 fps for the entire bad period. This is not slowness. - The framebuffer holds a real rendered scene, and the camera wanders normally rather than being stuck in geometry.
- Transport is clean: a static colour-bar pattern renders correctly through the raycaster's own blit call.
- The picture is garbled, not frozen, not blank.
- Starting a program REPAIRS it, without leaving the view.
10.1 The measurement that finally said something
The obvious experiment — compare the framebuffer before and after a launch — does not work as stated, and the reason is worth pausing on. The raycaster rewrites every pixel of the buffer every frame as the camera moves, so any two samples differ for entirely innocent reasons. A comparison whose result is "different" no matter what is not a measurement.
So the renderer gets frozen first. dfreeze stops every drawer in the display
task, which makes the buffer static and turns the question into a sharp one:
with nothing rendering, does launching a program change the framebuffer at
all?
BEFORE launch equal-to-first=25088 rows 67e0 67e0 67e0 67e0
CONTROL equal-to-first=25088 rows 67e0 67e0 67e0 67e0
AFTER launch equal-to-first=25088 rows 67e0 67e0 67e0 67e0
The control row is the load-bearing one. Taken with nothing done in between, it proves the freeze holds the buffer still and the sampler is stable — without it, "no change" would be indistinguishable from a broken measurement. That is the same lesson as the contaminated blittest above, applied in advance for once.
Launching a program does not alter one sampled byte of the framebuffer.
This is the first positive result in the entire investigation, and it
eliminates more than the previous eight negatives combined: the renderer, the
framebuffer contents, the heap, and all five of app_start()'s operations.
Whatever the repair is, it happens downstream of the image.
10.2 A mechanism that finally fits
Downstream of the image means the blit or the panel itself, and there is a candidate that explains the impossible part.
The ILI9341 holds a window, set by set_window(), and pixel data streams
into it with CS asserted. If a stream ever ends short, or CS is left asserted,
the controller sits mid-window and the next pixels land at the wrong offset.
That is what a garbled image is. Any other drawer issuing a fresh
set_window() and push_end() resynchronises the controller — and
applications draw constantly, while the raycaster alone only ever writes one
full-screen window per frame.
That accounts for every observation, including the one that looked like magic: code which never touches the image can repair the image, because it is not repairing the image — it is repairing the panel's idea of where the image goes.
It also retires an earlier claim. "Transport is clean, proven with colour bars" tested a single blit from a fresh state. It did not test a blit issued after a previous one may have left the controller mid-window, which is a different question and the one that matters.
Status: untested. It is a hypothesis with a mechanism, which is more than the previous eight had.
Next step: with the view visibly garbled, force one clean set_window()
and full-screen fill from the shell, touching no framebuffer. If the view
repairs, it is panel desynchronisation, and the fix belongs in the blit path —
re-asserting window and CS state per frame rather than assuming they survived.
If it does not repair, the remaining suspect is the SPI/DMA stream itself.
Filed under: things that were not the WiFi's fault.
Written by: Hare
../nat-os-test/docs/UM-NATOS-028*.md · Rendered from markdown at build time.