Skip to content

feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target - #239

Draft
Yona-Appletree wants to merge 28 commits into
mainfrom
claude/infallible-bose-84a52e
Draft

feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target#239
Yona-Appletree wants to merge 28 commits into
mainfrom
claude/infallible-bose-84a52e

Conversation

@Yona-Appletree

@Yona-Appletree Yona-Appletree commented Aug 1, 2026

Copy link
Copy Markdown
Member

IN PROGRESS. M1–M3 complete. M4's blocking fault is now diagnosed
it was never RMT, it is heap exhaustion — but the fix is a shared-crate design
call that is Yona's, so M4-P3/P4 remain blocked. See "Next" below.

Plan: lp2025/2026-07-31-1444-classic-esp32-bringup
Path: ~/.photomancer/planning/lp2025/2026-07-31-1444-classic-esp32-bringup/plan.md

Classic ESP32 (WROOM-32E "v3", Xtensa LX6) as a first-class target — the chip
behind 4-output WLED-class controllers.

What works

GLSL compiles and executes on the classic ESP32. A shader compiles
on-device in 65 ms (1,267 B GLSL → 2,032 B Xtensa) into the fixed SRAM1 code
region and renders continuously on the desk DOM-Z-102.

  • M1 — per-chip JIT placement seam: codemem_esp32 (word-mirrored install
    walk, span arena, real TooLarge), link_jit_at, JitBuffer::Placed.
    Classic-profile host tests execute real emitted code through the mirrored
    D-bus install. rv32 and S3 images byte-identical.
  • M2fw-esp32v3 crate: esp toolchain, abort tier, 4 MB partitions,
    gated CI job, domraem/dom-z-102 manifest, RAM ledger measured (radio costs
    44,244 B heap + ~28 KB static + ~390 KB flash).
  • M3 — server over a new UART0 transport (first chip without
    USB-Serial-JTAG), plus the xt-placed-code engine wiring. G-M3 gate passed:
    radio-off for v1, WiFi deferred with its price known.
  • M4-P1 — classic WS281x backend; the board reports
    4 of 4 usable RMT TX channels (blocks/channel=2, half_words=64).

What does not work

Diagnosed 2026-08-01; not RMT. Opening the third WS281x channel grows
Esp32OutputProvider's VecMap<i32, ChannelState> to capacity 4, asking for
12,864 contiguous bytes against 11,228 free on a heap already 90 % resident:

allocation failed: requested=12864 align=8 free=11228 used=101408
  RawVec<(i32, ChannelState)>::grow_one -> Esp32OutputProvider::open
  -> LpServer::advance_frame

96 % of each 3,216-byte element is DisplayPipeline::lut: [[u32; 257]; 3]
3,084 bytes carried inline, copied on every Vec growth, and built
unconditionally even though every output node in quad-strips-v3 sets
"lut_enabled": false.

This exonerates the prime suspect the defect had carried (the registry/lease
layer this port added); led-lab-esp32 survives the same code only because its
resident heap is smaller. It also finally attributes L553, the one fragment
that escaped the blind bisection — alloc.rs:553, handle_alloc_error. The
line number was pointing at the answer and could not be read.

Evidence and fix options: docs/defects/2026-08-01-classic-rmt-open-fault.md.

How it was found (M4-P2)

The ledger came forward from M7 into M4 and named the fault on the first
boot after flashing. Three supporting pieces, each of which was load-bearing:

  • fw-esp32v3/src/recovery/ — the RTC-RAM ledger (976 B in RTC fast RAM at
    0x3FF8_0000; 504 B of .stack for the code, zero DRAM for the region). No
    RWDT — arming one on a board under investigation for reset-looping would add
    a second reset source to the diagnosis. That stays M7.
  • lp_recovery::commit_staged_crash() — commit without resetting.
    Recovery::init discards a tentative record unless the reset was a
    watchdog, and this fault is not one, so staging alone bought nothing. Abort
    tier only, where entering the panic handler already means a reset is certain.
  • lpc-shared feature xt-map-esp32-classic — the Xtensa stack walker's bounds
    checks were hard-wired to the S3's IRAM/flash windows. Every frame in this
    backtrace (0x400d…0x4013…) would have been rejected and the walk would
    have reported zero frames as "the stack was unreadable". Both chip maps now
    compile on host so the classic constants are unit-tested; the tests also
    record that the two maps genuinely overlap (the classic's 11 MB IROM
    window contains the S3's IRAM), so the guarantee runs one way only.

Plus a #[alloc_error_handler], so an OOM records requested/align/free/used
and the walk starts at the allocation site instead of eight frames deep in
panic_nounwind_fmt machinery — where the chain broke and named no caller.

Bonus: safe mode now breaks the reset loop. The board stays up, reachable,
at ~830 fps with the crash record on every heartbeat. It used to be
unrecoverable from the host — clearing it meant espflash erase-region on lpfs.

Next

Yona owes one decision (D7): how to fix the LUT. The effective fix is in
lpc-shared's DisplayPipeline, not in fw-esp32v3 — Option<Box<[[u32; 257]; 3]>> built only when lut_enabled takes ChannelState from 3,216 B to ~132 B.
That changes the S3 and C6 too; both carry the same waste today and merely have
the arena to absorb it, so it is latent there rather than absent. Not taken
unilaterally.

M4-P3 (capacity sweep) and M4-P4 (full pipeline + G-M4 visual gate) stay blocked
behind it — the sweep cannot measure channels the board cannot open.

🤖 Generated with Claude Code

Yona-Appletree and others added 7 commits July 31, 2026 18:19
Classic (LX6) heap has no I-bus view — no address offset makes a heap JIT
buffer fetchable, so placement becomes a per-chip strategy instead of a
per-arch address rule:

- codemem_esp32: fixed SRAM1 code region written through the word-mirrored
  D-bus walk (iram = 0x400B_FFFC - (dram - 0x3FFE_0000)), span arena with a
  real TooLarge capacity edge, host-testable math pinned against
  lp-xt-emu's BoardProfile::esp32().
- link::link_jit_at: link an image against the execute base it will be
  installed at (in-place link_jit byte-identical, untouched).
- rt_jit::JitBuffer::Placed + compile_module_jit_placed: reserve span,
  link at base, install via mirrored walk, fence, wrap.
- tests/xt_classic_profile.rs: real pipeline output executed under the
  classic profile, including an install THROUGH the mirrored D-bus view of
  the silicon-measured alias model.

Q6 deviation, recorded in the plan: the xtn.q32 corpus lane needs a
classic-linked builtins base image (the old roadmap's 'corpus for free'
trap), so the classic guard is these emitted-code + install-walk tests
instead.

Plan: 2026-07-31-1444-classic-esp32-bringup (M1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…M-Z-102

New per-SOC firmware crate for the classic ESP32 (WROOM-32E v3 class),
per ADR 2026-07-29-per-chip-fw-toolchains: own workspace root, esp
toolchain, abort-tier panic posture, C6-shaped 4 MB partition table
(factory 3 MB + lpfs 960 KB), release-esp32v3 profile at opt-level=s.

Hardware-verified on the desk DOM-Z-102 (esp32 rev v3.1, 4 MB,
/dev/cu.wchusbserial1140): [INIT] fw-esp32v3 boot / chip=esp32
arch=xtensa heap_free=102400 / ready, steady 1 Hz heartbeat, image
100,656 B of 3,145,728 B. Carries the experiment repo's UART
baud-divisor fix (esp-println uart never programs the divisor; a kept
Uart binding does).

Bench note: espflash monitor alone halts the app in the flash stub —
use flash --monitor under a pty on this board, same flow as the S3.

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P1+P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runtime manifest for the classic bring-up roadmap's desk board and
WLED-class exemplar (Q5 as amended): LED1-4 on GPIO 18/16/14/2 per the
board silkscreen, four /rmt/ws281xK resources (channel count lives in
the manifest per ADR 2026-07-31-lp-ws281x-multi-channel-driver-adoption),
UART0 GPIO1/3 reserved as the CH340K host link, GPIO0 = OPT button.
default_esp32v3_hardware_manifest() mirrors the C6/S3 pattern;
schemas/hardware.schema.json regenerated for the new target variant
(drift gate green). C6 size-check green after the shared-crate edit
(headroom 284,528 B).

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P5, partial)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DOM-Z-102

Radio-linked (esp-rtos 0.3 + esp-radio 0.18, the experiment-proven cohort):
wifi::new + STA config costs 44,244 B of heap; steady-state free = 29,528 B
of a 72 KB arena. Static cost ~+20 KB bss / +8 KB data; flash +~390 KB
(image 490,368 B). Radio-off baseline: 100 KB arena fully free, ~87 KB
static bss, image 100,656 B. G-M3's remaining input is the measured heap
cost of a real on-device compile (M3).

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lippy recipes

build-fw-esp32v3 / clippy-fw-esp32v3 / fw-esp32v3-size-check (3 MB factory
limit, 64 KB margin; prints 3,045,072 B headroom today). _xt-gcc-dir
generalized to probe per-chip gcc binaries. Workflow job cloned from
firmware-xtensa: same pinned fork (1.95.0.0, buildtargets esp32), same
broad firmware path filter, rust-cache pointed at the crate's own
workspace target dir.

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds elsewhere

domraem/dom-z-102's runtime manifest arrives with the classic bring-up
roadmap while its display sidecar + catalog entry are authored on PR #231
(board catalog work) — the first runtime-first board, so the drift gate
gains the mirror of DISPLAY_ONLY, strict the same way: once #231 merges,
the entry must be removed or the gate fails. Also refreshed the two stale
DISPLAY_ONLY reasons ('no HardwareTarget yet' — HardwareTarget::Esp32
landed with this branch).

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P5 follow-through)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yona-Appletree added a commit that referenced this pull request Aug 1, 2026
The runtime manifest (PR #239) deliberately omits /gpio/13 — its function
is unverified — so the display pin must be non-output-eligible or the
drift gate's Rule 2 fails the moment both files share a branch. rsvd
keeps the terminal drawn (it exists on the silkscreen) but muted and
never claimable; both files upgrade together if IO13 is verified usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yona-Appletree and others added 9 commits July 31, 2026 18:42
…2, not LED1-4)

Cross-checked with the board-catalog session's silkscreen read (PR #231):
terminals are printed IO18/IO16/IO14/IO2, and an unverified IO13 terminal
exists (omitted here, drawn non-claimable in their sidecar). Description
now states which facts are silkscreen-verified vs standard-wiring
inference.

Plan: 2026-07-31-1444-classic-esp32-bringup (M2 P5 follow-through)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… classic ESP32

M3-P1: fold the crate into the root workspace (fw-esp32s3 shape;
release-esp32v3 profile moves to the root manifest), port flash_storage +
board init, and add the genuinely new piece: a UART0 transport
(src/serial/) — this chip has no USB-Serial-JTAG. TX chunks are sized in
line time (64 B ≈ 5.6 ms at 115200) with RX drained between chunks
(128-byte FIFO ≈ 11 ms); RX is polled read_buffered() because the classic
cannot clear the RX-timeout interrupt while the FIFO is non-empty.

Hardware-verified on the DOM-Z-102: boots to the server loop, littlefs
mounted, manifest domraem/dom-z-102 loaded, 819 fps idle, and a raw hello
round-trip over the wire answers with proto=4. Image 1,675,120 B of 3 MB
(headroom 1,470,608 B). Heap 110 KB / stack 47,136 B — dram_seg is a hard
1:1 trade, ceiling ≈156 KB measured via stack.x.

Shader compile works; EXECUTION faults until M3-P2 wires the placed path
(marked ⚠️⚠️ at the TargetLpvmGraphics construction site). lp-cli connect
is blocked host-side: transport_serial's reset dance never asserts DTR for
a CH340-bridged classic (FrameBeforeHello) — fixed next.

Plan: 2026-07-31-1444-classic-esp32-bringup (M3 P1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in the fixed region

The xt-placed-code feature (enabled only by fw-esp32v3) redirects
link_compiled_module_jit — the chokepoint shared by compile,
compile_with_config, and the async compile job — onto the placed path: a
span from the boot-installed codemem_esp32::global arena, link_jit_at
against its I-bus base, install via the mirrored D-bus walk, and a
PlacedGlobal buffer that frees its span on drop (Studio recompiles must
not leak the 92 KiB region). Region-full is a clean NativeError. Hosts,
the C6 and the S3 never enable it: S3 image byte-identical (1,699,072 B),
C6 unchanged (2,861,200 B).

Plan: 2026-07-31-1444-classic-esp32-bringup (M3 P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-status TIOCMSET

Two hardware-found fixes for the first UART-bridged device (DOM-Z-102):

- The WCH CH34x macOS driver silently ignores the single-bit TIOCMBIS/
  TIOCMBIC ioctls behind serialport's per-line DTR/RTS calls, while
  honoring whole-status TIOCMSET (the same reason espflash carries
  UnixTightReset). set_signals now issues ONE TIOCMSET when both lines are
  written; single-line writes keep the per-line calls the USB-JTAG dance
  depends on.
- reset_after_open gains a UartBridge style, selected by USB VID
  (non-Espressif => external bridge): the classic EN pulse with IO0 held
  high, passing through both-asserted so it can never select the ROM
  bootloader. Espressif-VID ports keep the exact JTAG dance byte-for-byte.

Verified on the DOM-Z-102: connect resets the chip, readiness gates on the
fresh hello, upload round-trips — twice.

Plan: 2026-07-31-1444-classic-esp32-bringup (M3 P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes on the classic ESP32

Boot installs CodeRegion::ESP32_DEFAULT into the global arena and the
engine's link step lands every module in SRAM1's I-bus image
(0x400A_1000..0x400B_8000). On the DOM-Z-102: quad-strips auto-loads from
lpfs, its shader compiles on-device in 65 ms (1,267 B GLSL -> 2,032 B
Xtensa code) and renders continuously — the same project that crash-looped
the board (EXCCAUSE=2 at a D-bus heap PC) before this wiring. Unload frees
the span (heap 90k->14k used), recompile reuses it.

UART0 now runs at 921600 = lpc_model::DEFAULT_SERIAL_BAUD_RATE — the first
chip where that constant meets a real wire; at 115200 every lp-cli connect
opened a deaf port (NoSerialOutput). Write chunks stay 64 B: line TIME is
what protects the 128-byte RX FIFO, so the chunk does not grow with the
baud.

Plan: 2026-07-31-1444-classic-esp32-bringup (M3 P2+P3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict was justfile only: this branch added clippy-fw-esp32v3 directly
above build-fw-esp32s3, while main gave build-fw-esp32s3 a features=""
argument. Kept both — the new clippy recipe and main's parameterized
build-fw-esp32s3 header (its {{ features }} body had already merged in).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rift exemptions

Semantic cross-PR drift the textual merge couldn't see: PR #231 (main)
landed the display sidecar with a DISPLAY_ONLY exemption waiting on this
branch's runtime manifest, while this branch carried the RUNTIME_ONLY
exemption waiting on #231. Merging main satisfied both conditions at
once, and the drift test correctly demanded both entries be removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… each, from the board manifest

M4-P1: the experiment repo's hardware-validated classic RmtHw backend
(esp32_rmt.rs @ b1dff8f) ported behind the lp-ws281x driver core per ADR
2026-07-31-lp-ws281x-multi-channel-driver-adoption. 64-word halves
(BLOCKS_PER_CHANNEL=2, findings §12: the ~48k refills/s ISR ceiling makes
32-word halves a hard 2-channel limit); absorbed slots skipped BY
CONSTRUCTION (slots 1/3/5/7 never reach configure_tx — the experiment
harness's failure). Channel count from the manifest's /rmt/ws281xK
resources; endpoints ws281x:rmt:{board label} exactly like the S3; classic
pin guards (AnyPin::steal panics on nonexistent GPIOs, 34-39 are
input-only). init_tx()'s global mem_tx_wrap_en and set_tx_threshold's
reverted-restart negative results carried over verbatim.

ws281x_telemetry feature (off by default, clippy-linted on): per-channel
[WS281X] counters every 10 s from the frame path — P3's capacity sweep
reads these. Test projects: quad-strips-v3 (G-M4 walk) and
quad-equal100-v3 (the §12 equal-length stress shape through the real app).

Image 1,691,728 B (+16,608). ⚠️ .stack 47,136→43,400 — the driver static
was paid from the stack; recorded in the README DRAM table. Also fixes the
unreachable-statement warning M3-P2 left in lpvm-native's chokepoint on
device builds.

lp-ws281x untouched (byte-identical to the proven experiment copy).

Plan: 2026-07-31-1444-classic-esp32-bringup (M4 P1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Yona-Appletree and others added 2 commits July 31, 2026 23:38
…ing it cannot here

Panic handler now masks interrupts, drains the TX FIFO before and between
prints (esp-println DROPS what does not fit rather than blocking), and
prints location and message separately so a truncated report still names a
file. All three are correct for panics outside exception context.

They do NOT rescue a fault inside the RMT open path, and the attempt is
worth more than the fix: docs/defects/2026-08-01-classic-rmt-open-fault.md
records that opening a WS281x channel reset-loops the DOM-Z-102 in under a
millisecond, that bisection puts the fault inside open() ABOVE the RAM
clear and clock configure, and that stack exhaustion / RAM base / double
panic are all ruled out by measurement. It also records why the channel
cannot report: a second exception inside exception context vectors to
reset. The instrument that would name it is lp-recovery's RTC ledger,
deferred to M7 — this is the argument for pulling it forward.

Also adds the classic desk projects: quad-strips-v3 (silkscreen endpoints,
the G-M4 walk) and quad-equal100-v3 (the findings §12 equal-length stress
shape).

Plan: 2026-07-31-1444-classic-esp32-bringup (M4 P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t IO13

Corrects a photo misread, per Yona's sketch of the physical board and the
product note "Relay configuration (to GPIO12)". Still omitted from the
runtime manifest, now for a stronger reason than 'unverified': GPIO12 is
the classic ESP32's MTDI strap pin (flash voltage select at reset), so
driving a relay from it has boot implications that need hardware
verification first. Buttons relabeled BOOT/RESET per the silkscreen.

Plan: 2026-07-31-1444-classic-esp32-bringup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Yona-Appletree Yona-Appletree changed the title feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target [HANDOFF] feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

CI refreshed the story baselines on this branch

The validate-stories job detected drift and committed the fresh set: 1 changed
in d38bdbf.

Review every PNG in the PR's Files changed view (swipe / onion-skin).
Your local branch is now behind — git pull before pushing again.

Story Before After
studio__node__node__dirty-unsaved-surface-tint__sm.png studio__node__node__dirty-unsaved-surface-tint__sm.png@c87ec509a2 studio__node__node__dirty-unsaved-surface-tint__sm.png@d38bdbf57e

@Yona-Appletree Yona-Appletree changed the title [HANDOFF] feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target feat: Classic ESP32 (LX6) bring-up — the 4-output WLED-class deployment target Aug 1, 2026
github-actions Bot and others added 4 commits August 1, 2026 16:57
`xtensa_lx::interrupt::disable()` is a safe fn in esp-hal 1.1.1, so the
`unsafe` block around it is `unused_unsafe` — and `-D warnings` makes that
fatal for the firmware job. The `link_jit` import is genuinely dead under
`xt-placed-code`, where every module routes through `link_jit_at` instead;
gate the import on the same cfg as its only use rather than deleting it,
since the non-placed path still needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o M4

The classic cannot report a fault in the WS281x open path. Measured
2026-08-01: it resets after ~5 characters of panic output, and interrupt
masking, FIFO drains, chunked writes and two heap sizes all produced the
same five. A faster print is not the answer; not needing to print is.

Three pieces, each with a classic-specific wrinkle the S3 does not have:

* `src/recovery/` mirrors fw-esp32s3's split minus the watchdog. The RTC
  fast-RAM region lands at 0x3FF8_0000 (976 B of the chip's 8 KiB, and
  zero bytes of the 192 KB dram_seg that .stack fights over — .stack pays
  only 504 B, for the code). The reset-cause map is NOT the S3's: the
  classic has no USB peripheral, so espflash's EN-line reset reports as
  ChipPowerOn and there is no UserReset arm at all.

* The panic path inverts the S3's order — ledger first, serial second —
  and commits the record rather than leaving it staged. That needed a new
  `lp_recovery::commit_staged_crash`: `Recovery::init` throws a tentative
  record away unless the reset was a watchdog, which this fault is not, so
  staging alone would have bought nothing. It is sound on the abort tier
  only, where entering the handler already means a reset is certain.

* `lpc_shared::backtrace`'s Xtensa walker is LX6/LX7-generic in mechanism
  but bounds-checks frames against the S3's IRAM/flash windows. On classic
  silicon it would reject every candidate and report zero frames, which the
  panic path renders as "the stack was unreadable" — confidently wrong.
  The windows are now chip-selected by `xt-map-esp32-classic`. Both maps
  compile on host so the classic constants are testable; the tests record
  that the two maps genuinely OVERLAP (the classic's 11 MB IROM window
  contains the S3's IRAM), so the guarantee runs one way only.

Safe mode is the immediate practical win: quad-strips-v3's reset loop was
unbreakable from the host before this, and clearing it meant erasing lpfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yona-Appletree and others added 5 commits August 1, 2026 10:17
Added a `#[alloc_error_handler]` so an allocation failure reaches the RTC
ledger as an `Oom` carrying requested/align/free/used, and so the stack
walk starts at the allocation site instead of eight frames deep in
`panic_nounwind_fmt` machinery (where the chain broke before naming any
caller). Both changes paid off on the first boot after flashing:

    allocation failed: requested=12864 align=8 free=11228 used=101408
    ...RawVec<(i32, ChannelState)>::grow_one
    ...Esp32OutputProvider::open
    ...LpServer::advance_frame

`Esp32OutputProvider`'s `VecMap<i32, ChannelState>` grows to capacity 4 when
the third channel opens; 4 x size_of::<(i32, ChannelState)>() = 4 x 3,216 =
12,864 B, against 11,228 free on a heap already 90% resident. 96% of each
element is `DisplayPipeline::lut: [[u32; 257]; 3]` — 3,084 bytes INLINE,
copied on every growth, and built unconditionally even though every output
node in quad-strips-v3 sets `lut_enabled: false`.

This exonerates the prime suspect the defect had carried since P2: the
registry/lease layer this port added is not involved, and `led-lab-esp32`
survives the same code only because its resident heap is smaller. It also
finally attributes `L553`, the one character-fragment that escaped the
blind bisection — it was alloc.rs:553, `handle_alloc_error`. The line
number was pointing at the answer all along and could not be read.

Fix not applied: the effective one moves the LUT out of line in
`lpc-shared`, which changes the S3 and C6 too (both latent, not immune).
That is a design call, recorded in the defect rather than taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rustfmt sorts the two cfg'd `use ... as xt_map` lines alphabetically, which
moved the doc comment onto whichever arm sorted first. Demoted it to a plain
comment above both and formatted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
D7 resolved as option A. `DisplayPipeline` carried
`lut: [[u32; 257]; 3]` — 3,084 bytes INLINE per channel, copied on every
`VecMap` growth — to evaluate what measurement shows is a straight line:

    lut[i] = clamp(round((i/256) * white_point * 65535))

Linearly interpolating between samples of a line reproduces the line, so
the table computed `value * white_point` and nothing else. Replaced with
a Q16.16 multiply precomputed once from the (immutable) options.

Verified rather than asserted. `legacy_lut` in the test module keeps the
deleted implementation executable as a migration oracle:
`white_point_matches_the_lut_it_replaced` sweeps all 65,536 inputs at
eight white points and holds the delta to <= 2 counts of 65,535 — a
quarter ULP at the 8-bit wire the pipeline drives, so no LED resolves it.
Where they differ the multiply is the more correct of the two: the table
was a piecewise approximation of a line, this is the line.
`unit_white_point_is_the_identity` pins the overwhelmingly common case as
EXACTLY identity, since a systematic off-by-one there would dim every
strip in the product forever.

`lut.rs` is deleted rather than kept as an extension point: the module was
private, so keeping it unused would need `#[allow(dead_code)]`. The
interpolation shape it pioneered is wanted by the 16-bit gamma work
(separate branch) as a shared const table in flash — which is what settled
D7 toward A, since the one plausible future user of the per-channel heap
machinery turned out to want a different, cheaper mechanism.

Also reserves `RESERVED_CHANNELS` in the provider so opening the Nth
channel never reallocs the live ones. That guard is now cheap insurance
rather than the fix, and its comment says so.

ON SILICON (DOM-Z-102, quad-strips-v3): all four channels open on boot 1,
no safe mode, stable. Heap 18,128 free / 94,508 used, against 11,228 free
at the OOM — with two MORE channels live. Closes the M4 blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intermediate point between quad-strips-v3 (120 LEDs, runs) and
quad-equal100-v3 (400 LEDs, OOMs). 240 LEDs runs with 7,384 B free, which
brackets the classic's real ceiling at ~300 total LEDs.

P3's finding, recorded in the plan: the constraint is HEAP, not RMT. Refill
lag peaked at 20 of 64 words with zero trips/skips/errors at both 120 and
240 LEDs, so `BLOCKS_PER_CHANNEL=2` carries four outputs with 3x margin —
the §12 arithmetic was marginal, the measurement is not. Heap meanwhile
runs out at ~89.5 B/LED, only ~21 of which is DisplayPipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant