diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index f3145104e..921105f88 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -357,6 +357,79 @@ jobs: - name: Clippy esp32s3 firmware run: cd lp2025 && just clippy-fw-esp32s3 + # fw-esp32v3 (classic ESP32, "v3"/WROOM-32E) — the second crate on the + # Xtensa fork, cloned from firmware-xtensa above. Same anti-rot rationale: + # nothing else in CI compiles this crate. + # + # Shares the broad `firmware` filter with firmware-xtensa and + # firmware-size, for the same reason both give: it is the link closure of + # the device image, and a narrow lp-fw/fw-esp32v3 filter would stop + # rebuilding on shared-crate changes most likely to break it — which since + # M3-P1 is the whole `lpa-server` / `fw-esp32-common` stack. + # + # Compile-only, like firmware-xtensa: hardware validation is manual. + firmware-esp32v3: + name: Firmware build (esp32v3) + runs-on: ubuntu-24.04 + needs: detect-changes + if: needs.detect-changes.outputs.firmware == 'true' + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Install system deps (libudev for espflash) + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + + - name: Checkout lp2025 + uses: actions/checkout@v4 + with: + path: lp2025 + + # Installs the Xtensa Rust fork as the `esp` toolchain plus its bundled + # GNU binutils, which the build needs on PATH — the Rust target spec + # links through xtensa-esp32-elf-gcc, not rust-lld. + # + # Pinned rather than floating, same reasoning and same pin as + # firmware-xtensa: the crate's rust-toolchain.toml says `channel = "esp"`, + # which locally tracks whatever espup installed; CI pins so a fork + # release cannot turn a green PR red on its own. Keep this version in + # sync with firmware-xtensa's. + - name: Install Xtensa Rust toolchain + uses: esp-rs/xtensa-toolchain@v1.6 + with: + version: 1.95.0.0 + buildtargets: esp32 + default: true + ldproxy: false + + # `lp2025 -> target`, mirroring firmware-xtensa: this crate is a + # repo-root workspace member (M3-P1 folded it in) and writes into the + # shared root target/, so the cache key must be the repo root. + - name: Cache cargo registry and build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: lp2025 -> target + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Install just + uses: extractions/setup-just@v3 + + # Pinned to match the firmware-size and firmware-xtensa jobs. + # ~/.cargo/bin is restored by rust-cache above, so this is a no-op on a + # warm cache. + - name: Install espflash + run: cargo install espflash --version 3.3.0 --locked + + # Builds the crate and checks the image against its 3 MB app partition. + - name: Check esp32v3 image size + run: cd lp2025 && just fw-esp32v3-size-check + + # The lint gate for this crate. Nothing else in CI lints it — + # `clippy-host` excludes it, because it cross-compiles for Xtensa under + # a different toolchain. + - name: Clippy esp32v3 firmware + run: cd lp2025 && just clippy-fw-esp32v3 + # The Xtensa HOST path: `lpvm-native`'s `rt_emu` compiling shaders for Xtensa # and executing them on `lp-xt-emu`, linked against a cross-compiled builtins # image. Nothing else in CI runs Xtensa *code* — `firmware-xtensa` above only diff --git a/Cargo.lock b/Cargo.lock index 769f34637..fbd43d309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3097,6 +3097,8 @@ dependencies = [ "rand_core 0.6.4", "rand_core 0.9.5", "riscv", + "sha1", + "sha2", "strum 0.27.2", "ufmt-write", "xtensa-lx", @@ -3155,7 +3157,9 @@ dependencies = [ "esp-hal", "esp-metadata-generated", "esp-sync", + "esp-wifi-sys-esp32", "esp-wifi-sys-esp32c6", + "esp32", "esp32c6", "log", ] @@ -3205,6 +3209,7 @@ dependencies = [ "esp-wifi-sys-esp32h2", "esp-wifi-sys-esp32s2", "esp-wifi-sys-esp32s3", + "esp32", "esp32c6", "heapless 0.9.2", "instability", @@ -3213,6 +3218,7 @@ dependencies = [ "num-traits", "portable-atomic", "portable_atomic_enum", + "xtensa-lx-rt", ] [[package]] @@ -3246,6 +3252,7 @@ dependencies = [ "cfg-if", "document-features", "esp-metadata-generated", + "esp32", "esp32c6", "esp32s3", ] @@ -3971,6 +3978,40 @@ dependencies = [ "lpvm-native", ] +[[package]] +name = "fw-esp32v3" +version = "40.0.0" +dependencies = [ + "embassy-executor", + "embassy-futures", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-io-async 0.6.1", + "embedded-storage", + "esp-alloc", + "esp-backtrace", + "esp-bootloader-esp-idf", + "esp-hal", + "esp-println", + "esp-radio", + "esp-rtos", + "esp-storage", + "fw-core", + "fw-esp32-common", + "littlefs-rust", + "log", + "lp-gfx-lpvm", + "lp-recovery", + "lp-ws281x", + "lpa-server", + "lpc-hardware", + "lpc-shared", + "lpc-wire", + "lpfs", + "lpvm-native", + "ser-write-json", +] + [[package]] name = "fw-host" version = "40.0.0" @@ -5605,6 +5646,7 @@ dependencies = [ "async-trait", "futures-util", "hashbrown 0.15.5", + "libc", "log", "lp-emu-core", "lp-riscv-elf", diff --git a/Cargo.toml b/Cargo.toml index 3d5cb6aac..0cf618fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,10 @@ members = [ "lp-cli", "lp-fw/fw-esp32c6", "lp-fw/fw-esp32s3", + # NOT in default-members (like the two above): a cross-target Xtensa crate + # that only `just build-fw-esp32v3` / `just clippy-fw-esp32v3` build, under + # its own `esp` toolchain. + "lp-fw/fw-esp32v3", "lp-fw/fw-emu", "lp-fw/fw-tests", "lp-core/lpc-mapping", @@ -310,6 +314,16 @@ opt-level = "z" inherits = "release" opt-level = "s" +# fw-esp32v3 (classic ESP32, LX6): same reasoning as the S3 profile above, and +# deliberately the same `opt-level = "s"` rather than the workspace default +# "z". The S3's evidence is that "z" missed a 30 µs cold-frame RMT deadline on +# the *faster* LX7; there is no reason to assume the LX6 is safe where the LX7 +# was not. M4 re-checks the cold first frame with real RMT output on this chip. +# No per-package overrides — the S3 profile carries none either. +[profile.release-esp32v3] +inherits = "release" +opt-level = "s" + # Profile for fw-emu: opt-level=3 avoids Cranelift codegen bugs in emulator (InvalidMemoryAccess). # Used by test_scene_render_fw_emu via ensure_binary_built(..with_profile("release-emu")). [profile.release-emu] diff --git a/docs/adr/2026-08-01-esp32v3-flash-budget.md b/docs/adr/2026-08-01-esp32v3-flash-budget.md new file mode 100644 index 000000000..7da22b384 --- /dev/null +++ b/docs/adr/2026-08-01-esp32v3-flash-budget.md @@ -0,0 +1,203 @@ +# Classic ESP32 keeps the C6's 4 MB table, and the binding constraint is RAM + +- Status: accepted +- Date: 2026-08-01 +- Context: M6 of `2026-07-31-1444-classic-esp32-bringup` (classic ESP32 / LX6 + bring-up). Sibling of `2026-07-28-esp32c6-flash-budget.md` and + `2026-07-30-esp32s3-partition-floor.md`. + +## Context + +`fw-esp32v3`'s `partitions.csv` copied the C6's shape verbatim — 3 MB +`factory` + 960 KB `lpfs`, exactly 4 MB — as roadmap decision Q7, on the +reasoning that the classic's flash budget is constrained like the C6's rather +than like the S3's. That was a prediction made before the app layer existed. +The S3 made the opposite call three days earlier and moved to an 8 MB floor, +so the question is live: does the classic need to follow? + +All numbers below are measured on this branch, not estimated. + +## Flash: the prediction was wrong, and in the comfortable direction + +| build | image | `.text` | `.rodata` | +|---|---|---|---| +| hello (`--no-default-features --features esp32`) | 84,016 B | 18,409 | 6,712 | +| radio probe (`+radio_ram_probe`) | 449,872 B | 319,317 | 29,636 | +| **server + JIT + ws281x (shipping)** | **1,707,792 B** | 1,441,693 | 233,240 | + +Against the 3 MB (3,145,728 B) `factory` partition that is **1,437,936 B of +headroom — 46 % free**, comfortably past the 65,536 B margin +`just fw-esp32v3-size-check` enforces. + +Cross-chip, measured the same way (`espflash save-image` at each chip's real +flash size; S3 and C6 from local ELFs built 2026-07-31, so they are a snapshot +of that day rather than of this branch): + +| chip | image | app partition | headroom | +|---|---|---|---| +| esp32c6 (rv32imac) | 2,861,200 B | 3 MB | ~284 KB | +| **esp32v3 (LX6)** | **1,707,792 B** | **3 MB** | **1,437,936 B** | +| esp32s3 (LX7) | 1,699,072 B | 6 MB | ~4.6 MB | + +The classic's image lands within **8,720 B of the S3's** — the two Xtensa +builds are effectively the same size — while the C6 is 1.15 MB larger. Two +factors plausibly account for that gap and this ADR does **not** claim a split +between them, because nothing here measured one: the C6 is the *unwinding* +tier (it links `unwinding` and carries `.eh_frame`) where both Xtensa builds +are abort tier, and rv32imac and Xtensa have different code densities. + +The practical consequence is the only part that needs deciding, and it is +unambiguous either way. + +## Decision + +**The classic ESP32 keeps the 4 MB table unchanged.** + +``` +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x300000, # 3 MB +lpfs, data, spiffs, 0x310000, 0xF0000, # 960 KB +``` + +Ends at 0x400000 of 0x400000 — no slack, by construction, and none needed. Q7 +stands. Unlike the S3, this chip does **not** narrow its supported hardware: +any 4 MB N4-class module runs the shipping image with 46 % of the app +partition spare. + +**The serde-surface lever is NOT pulled for this chip** (roadmap Q3 — go/no-go +below). + +## Flash: where the image actually goes + +`cargo bloat --profile release-esp32v3 --crates`, top of the `.text` section +(1.4 MiB of a 2.2 MiB file): + +| crate | share of `.text` | size | +|---|---|---| +| `lps_glsl` | 13.1 % | 184.5 KiB | +| `lpc_model` | 12.3 % | 174.1 KiB | +| `lpc_engine` | 12.0 % | 169.3 KiB | +| `lpa_server` | 7.8 % | 109.5 KiB | +| `lpvm_native` | 6.4 % | 90.1 KiB | +| `lps_builtins` | 5.2 % | 73.2 KiB | +| `core` | 5.0 % | 71.1 KiB | +| `lpc_wire` | 3.9 % | 55.0 KiB | +| `serde_core` | 3.8 % | 53.1 KiB | +| `lpc_registry` | 3.7 % | 52.0 KiB | + +The on-device shader toolchain (`lps_glsl` + `lpvm_native` + `lps_builtins` = +347.8 KiB, 24.7 %) is the largest single thing this firmware carries, and it is +the feature that distinguishes the product. It is not a candidate for removal. + +## Q3 — serde-surface go/no-go: **NO-GO for the classic** + +The serde surface (`lpc_model` + `lpc_wire` + `serde_core` + `serde_json` + +`ser_write_json`) totals **331.9 KiB** of `.text`. Memory +`serde-surface-is-the-flash-lever` records why that matters on the C6: at +~284 KB of headroom, that lever is the difference between fitting and not. + +On the classic it buys nothing anyone needs. Pulling all of it would take +headroom from 1,437,936 B to ~1,777,000 B — from 46 % free to 56 % free, on a +chip that is not flash-constrained. **This is C6-only pressure.** If the lever +is ever pulled it should be justified by the C6's budget and merely inherited +here, never the reverse. + +Recommendation only; no size-reduction work is in scope for this roadmap. + +## RAM is the real constraint, and it binds hard + +Flash is not this chip's problem. DRAM is. + +`dram_seg` is **192 KB total** (`0x3FFB_0000..0x3FFE_0000`) and `.data`, +`.bss` and `.stack` are strictly zero-sum within it — `.stack` takes whatever +the other two leave (esp-hal's `stack.x`). + +Shipping image, measured: + +| section | size | +|---|---| +| `.data` | 19,420 B | +| `.bss` (incl. the 112,640 B heap arena) | 134,296 B | +| `.stack` | 42,888 B | +| **sum** | **196,604 B of 196,608** | + +⚠️ **"As high as it links" is not the ceiling.** A 160 KB arena fails the link +by 4,064 B, so the hard limit is ≈155.9 KB — at which `.stack` is *zero* and +the board cannot run. The real constraint is stack headroom for the Xtensa +windowed ABI's large frames and the recursive GLSL parser. + +The `lp-recovery` RTC ledger costs **nothing** from this budget: its 976 B live +in RTC fast RAM (`0x3FF8_0000`, 8 KiB, a separate segment). Only its code came +out of `.stack`, 504 B. + +### Runtime heap, measured on silicon (112,640 B arena) + +| state | free | used | +|---|---|---| +| idle, no project | 102,156 B | 10,484 B | +| `quad-strips-v3` (4 × 30 = 120 LEDs) | 18,128 B | 94,508 B | +| `quad60-v3` (4 × 60 = 240 LEDs) | **7,384 B** | 105,256 B | +| `quad-equal100-v3` (4 × 100 = 400 LEDs) | — | **OOM** | + +> ⚠️ **These numbers are from commit `08779e059` and have already moved.** +> Re-measured after merging `origin/main` (2026-08-01, same board, same +> projects): per-project heap grew **8,136 B** — 120 LEDs now leaves 9,992 B +> free, and **240 LEDs OOMs where it previously ran**. Idle heap is unchanged, +> so it is per-project allocation, not static growth; `tick` simultaneously +> dropped 69 ms → 47 ms. The 16-bit gamma fix (#252) is ruled out by direct +> measurement (4 bytes). See +> `docs/defects/2026-08-01-classic-heap-regression-after-f32-merge.md`. +> +> The **method and the shape of the conclusion stand** — heap binds, flash does +> not, and LED counts must be quoted from RAM. The **absolute ceiling below is +> optimistic by roughly 91 LEDs** until that regression is attributed and +> either accepted or reversed. + +A loaded project costs ~79 KB (observed directly: `stop_all_projects` took the +board from 95 KB used to 16 KB used). Beyond that, **≈89.5 B per LED** — and +only ~21 B/LED of that is `DisplayPipeline`'s three `Vec` plus +`dither_overflow`. The other **~68 B/LED is engine-side and scales with +`render_size`**; it is unattributed and is the single most valuable RAM lead +this chip has. + +**Practical ceiling: ~240 LEDs comfortable, ~300 at the edge, 400 impossible.** +For a WLED-class product claim that number matters more than the channel count, +and it is a RAM number, not an RMT one — M4-P3 measured RMT refill lag peaking +at 20 of 64 words (31 % utilisation) with zero trips at 240 LEDs. + +### Radio, if it is ever attempted + +M2-P3 measured `wifi::new` + STA at **44,244 B of heap** plus ~28 KB static +and ~390 KB flash. Against an app that already uses ~94 KB of a 110 KB arena, +WiFi + JIT does not fit without a RAM diet of serde-surface scale. That is the +measured price of D1's "radio-off for v1", recorded here so the future attempt +starts from a number rather than a hope. + +⚠️ Note the asymmetry this ADR exists to make explicit: **the RAM diet WiFi +would need is a different lever from the flash diet the C6 needs**, even though +both point at `lpc_model`/serde. Flash headroom on this chip is 1.4 MB; heap +headroom at four channels is 7 KB. + +## Consequences + +- Any 4 MB N4-class classic module is supported. No hardware narrowing. +- The flash size check keeps its 65,536 B margin; at 1.4 MB of headroom it is + a tripwire for accidents, not a real constraint. +- LED-count claims for this chip must be quoted from the RAM ledger, not from + the RMT channel count. +- The ~68 B/LED engine-side cost is the next RAM lead. It is shared with the + S3 and C6, which have the arena to absorb it — so it is latent there, not + absent, exactly like the per-channel LUT this roadmap removed. + +## Reproducing + +```bash +just fw-esp32v3-size-check +cd lp-fw/fw-esp32v3 && cargo bloat --profile release-esp32v3 --crates -n 18 +``` + +Per-variant images: `touch src/main.rs` before each build (feature-driven cfg +is not reliably tracked on this crate), then `espflash save-image --chip esp32 +--flash-size 4mb `. Section sizes via `xtensa-esp32-elf-size -A`. +Runtime heap comes from the device's own heartbeat `memory` field. diff --git a/docs/debt/README.md b/docs/debt/README.md index c68002e82..cfd5a1f2e 100644 --- a/docs/debt/README.md +++ b/docs/debt/README.md @@ -53,6 +53,7 @@ stay in place when retired; the log is the history). | Entry | Status | Since | Area | Cost in one line | | --- | --- | --- | --- | --- | +| [brightness-applied-before-gamma](brightness-applied-before-gamma.md) | carried | 2026-08-01 | lpc-engine fixture node (value pipeline) | dim gamma-on fixtures collapse to ~1 wire code (30× resolution loss at brightness 38); projects work around it by shipping gamma off | | [lps-probe-perf-test-load-sensitive](lps-probe-perf-test-load-sensitive.md) | carried | 2026-08-01 | lps-probe/tests | spurious full-gate reds whenever a dev server or sibling session runs; ~20% wall-clock headroom | | [story-capture-pipeline](story-capture-pipeline.md) | carried | 2026-07-08 | studio-web/story-capture | ~15 min + flake retries per UI change; visual gates block under load | | [web-serial-js-untestable](web-serial-js-untestable.md) | carried | 2026-07-10 | lpa-link/browser-serial | JS session/flash layer ships untested; bugs surface only on hardware | diff --git a/docs/debt/brightness-applied-before-gamma.md b/docs/debt/brightness-applied-before-gamma.md new file mode 100644 index 000000000..c1e44a6b0 --- /dev/null +++ b/docs/debt/brightness-applied-before-gamma.md @@ -0,0 +1,57 @@ +--- +status: carried +since: 2026-08-01 +logged: 2026-08-01 +area: lp-core/lpc-engine/src/nodes/fixture (brightness → gamma ordering) +related: + - docs/design/brightness-gamma-dithering.md + - docs/defects/2026-08-01-gamma-8bit-choke.md + - lp-core/lpc-engine/src/nodes/fixture/power_limit.rs +--- +# Brightness is applied before gamma, starving the 8-bit wire at dim settings + +**Shape** — `fixture_node.rs` applies fixture brightness in the *perceptual* +domain, before the γ=2.8 encode. Because `(s·c)^γ = s^γ·c^γ`, the slider value +is effectively raised to the 2.8 power on its way to the wire: brightness +38/255 delivers 0.48 % duty, compressing the entire image into **1.24 of the +wire's 256 codes**. Contrast is unaffected (the scale factors out exactly); +what is lost is output resolution — 30× fewer usable codes at that setting +than a linear-domain brightness would keep. Measured on the classic-ESP32 +bench 2026-08-01: gamma-on at brightness 38 lights only pixels above 72 % +content, as visible sparkle (device refresh ~20 fps puts sub-code dithering +below flicker fusion). Full derivation and stage-by-stage domain table: +`docs/design/brightness-gamma-dithering.md`. + +**Why it is acceptable now** — the semantics is *coherent*: the slider is +perceptually linear (15 % slider ≈ 15 % perceived), and every project authored +so far was tuned against this behavior. The measured workaround in the wild is +projects shipping `gamma_correction: false` (all classic test projects do), +which sidesteps the starvation by dropping the encode entirely. Changing the +order changes visible output for every gamma-on fixture on every chip, so it +is a deliberate product decision, not a bug fix to slip in. + +**Exit criteria** — a decision, then a small change: + +1. **Decide the slider semantics.** The recommended direction (matches WLED's + advised configuration and FastLED's `setBrightness`): brightness becomes a + linear multiply in the post-gamma u16 domain, composed with the power-limit + scale that already lives there for the same physical reason. Slider 38 + would then read as ~51 % perceived instead of ~15 %, in exchange for 38 + usable codes instead of 1.24. +2. Apply it in `fixture_node.rs` (move the multiply below `apply_gamma16`, + compose with `power.channel`), keeping the load-bearing + `gamma → power scale` order intact. +3. If a perceptual slider *feel* is still wanted, add a UI-side curve + decoupled from the LED encode — explicitly not γ=2.8 mapped into linear, + which is numerically identical to today and would recreate the condition. +4. Re-run the classic bench case (`projects/test/quad-gamma-v3`, brightness + 38, gamma on): full-white content should land near code 38, and mid-tones + should be visible. + +**Log** + +- 2026-08-01 — condition identified on the DOM-Z-102 bench ("very dim, just a + few blue ones lit" at brightness 38 with the fresh 16-bit gamma); verified + as ordering rather than the gamma table by flipping `gamma_correction` on + identical firmware (4 B heap / ~1 fps delta). WLED/FastLED conventions + verified from their documentation the same day. diff --git a/docs/defects/2026-08-01-classic-heap-regression-after-f32-merge.md b/docs/defects/2026-08-01-classic-heap-regression-after-f32-merge.md new file mode 100644 index 000000000..b83f7189b --- /dev/null +++ b/docs/defects/2026-08-01-classic-heap-regression-after-f32-merge.md @@ -0,0 +1,113 @@ +# Classic ESP32: per-project heap grew 8,136 B on main, cutting the LED ceiling + +- **Date:** 2026-08-01 +- **Status:** OPEN — measured and bracketed, not yet attributed to a single PR +- **Board:** DOM-Z-102 (classic ESP32 rev v3.1), `fw-esp32v3` +- **Found by:** M6/M4-P3 re-measurement after merging `origin/main` into + `claude/infallible-bose-84a52e` + +## Symptom + +`projects/test/quad60-v3` (4 channels × 60 = **240 LEDs**) ran on this board +with 7,384 B of heap to spare before the merge. After merging main it **OOMs**: + +``` +cause=oom "alloc 360 bytes failed" → safe mode +``` + +The board recovers correctly (the `lp-recovery` ledger attributes it, gates the +path, and stays reachable at 889 fps) — but the configuration that passed +M4-P3 no longer fits. + +## Measurement + +Identical project (`quad-strips-v3`, 4 × 30 = 120 LEDs), clean boot with +auto-load, same board: + +| | pre-merge | merged main | delta | +|---|---|---|---| +| free heap | 18,128 B | **9,992 B** | **−8,136 B** | +| used | 94,508 B | 102,644 B | +8,136 B | +| fps | 13 | **20** | +54 % | +| `tick` | 69 ms | 47 ms | −22 ms | + +**Idle heap is unchanged** — 102,156 B free before, 102,144 B after, with no +project loaded. So this is **not** static/`.bss` growth; it is per-project +allocation. Something the project-load or render path allocates now costs +~8 KB more, and simultaneously runs ~30 % faster. + +That pairing — faster and fatter — is the signature of work being cached or +precomputed rather than recomputed per frame. + +## Not the gamma fix + +PR #252 (16-bit gamma) was isolated on identical firmware by flipping +`gamma_correction` on the same board: + +| | fps | `tick` | free | used | +|---|---|---|---|---| +| gamma **on** | 19 | 49 ms | 6,268 B | 106,368 B | +| gamma **off** | 20 | 48 ms | 6,264 B | 106,372 B | + +**4 bytes of heap and ~1 fps** — inside noise. `GAMMA16` is a `const` in +`.rodata`; it costs flash (image 1,707,792 → 1,720,448 B, which also includes +main's other changes) and no heap. Gamma is exonerated. + +## Candidates + +Six PRs merged into main since `08779e059`: + +| PR | subject | prior | +|---|---|---| +| #249 | f32 native math roadmap | **likely** — changes shader codegen | +| #251 | f32 probe2 capture | likely | +| #253 | M8 xtn f32 targets | likely | +| #250 | hardware board selection | unlikely (UI/metadata) | +| #236 | boards catalog page M3 | unlikely (UI/metadata) | +| #252 | 16-bit gamma | **ruled out by measurement** | + +The three f32 PRs are the natural suspects: hardware-float shader execution +would plausibly both speed up `tick` and change what the JIT/engine holds +resident. Note memory `f32-native-math-roadmap` records "+65,680 B for an +unreachable path" on the S3 — that was *flash*, and this is *heap*, so it is a +different measurement, not the same one resurfacing. + +**Not bisected.** Doing so needs a firmware build + flash + upload + read per +point (~8 min), and `quad-strips-v3` / `quad60-v3` do not exist on main, so +each point also needs the project copied in. Left for whoever owns the f32 +work rather than guessed at here. + +## Why it matters + +The classic's binding constraint is heap, not flash or RMT +(`docs/adr/2026-08-01-esp32v3-flash-budget.md`). At ≈89.5 B per LED, 8,136 B is +**~91 LEDs of capacity** — roughly a third of the chip's usable budget, gone +without a compensating feature on this chip. It moved the measured ceiling from +"~240 comfortable / ~300 at the edge" to somewhere between 120 and 240. + +The S3 and C6 have the arena to absorb it and will not notice, which is exactly +why it needs recording here: the classic is the family's canary for per-project +heap growth, in the same way it was the canary for the per-channel white-point +LUT. + +## Reproduce + +```bash +just build-fw-esp32v3 +espflash flash --chip esp32 --port --partition-table lp-fw/fw-esp32v3/partitions.csv \ + --flash-size 4mb --baud 921600 --after hard-reset \ + target/xtensa-esp32-none-elf/release-esp32v3/fw-esp32v3 +espflash erase-region --port 0x310000 0xF0000 +cargo run -p lp-cli -- upload projects/test/quad60-v3 serial: +``` + +Read the board's heartbeat `memory` field. To read without reflashing, the fd +must be held open across `stty` — a bare `stty` then `cat` reopens the port and +loses the baud: + +```bash +exec 3<> /dev/cu.wchusbserial1130 +stty -f /dev/cu.wchusbserial1130 921600 raw -echo clocal +timeout 30 cat <&3 > out.log +exec 3<&- +``` diff --git a/docs/defects/2026-08-01-classic-rmt-open-fault.md b/docs/defects/2026-08-01-classic-rmt-open-fault.md new file mode 100644 index 000000000..601c731c9 --- /dev/null +++ b/docs/defects/2026-08-01-classic-rmt-open-fault.md @@ -0,0 +1,151 @@ +# Classic ESP32: opening a WS281x channel faults and reset-loops the board + +- **Date:** 2026-08-01 +- **Status:** **DIAGNOSED** 2026-08-01 — root cause is heap exhaustion, not RMT. + Blocks M4-P3/P4 until the fix is chosen. +- **Board:** DOM-Z-102 (classic ESP32 rev v3.1), `fw-esp32v3` +- **Plan:** `2026-07-31-1444-classic-esp32-bringup`, M4-P2 + +## Root cause + +`Esp32OutputProvider::open` inserts into a `VecMap`. Opening +the **third** channel grows that map to capacity 4, which asks the allocator for +one contiguous **12,864-byte** block. There were **11,228 bytes** free. The +allocation fails, and on this chip an allocation failure is fatal. + +Measured on silicon, `projects/test/quad-strips-v3`: + +``` +allocation failed: requested=12864 align=8 free=11228 used=101408 +``` + +The heap is 112,640 B total, so the app is already at **90 % occupancy before +the first channel opens**. This is not an outsized request; it is the straw. + +### Where the 12,864 comes from + +`size_of::<(i32, ChannelState)>()` is **3,216 B**, and 4 × 3,216 = 12,864. +Of those 3,216 bytes, **3,084 — 96 % — are one field**: + +```rust +// lp-core/lpc-shared/src/display_pipeline/pipeline.rs +pub struct DisplayPipeline { + ... + lut: [[u32; LUT_LEN]; 3], // LUT_LEN = 257 => 3 * 257 * 4 = 3,084 B, INLINE +} +``` + +Two things make that worse than its size alone suggests: + +1. **It is inline in the struct**, so it is not a pointer the `VecMap` copies — + it is 3 KB of table copied per element on every growth, and `Vec` growth + holds the old and new buffers simultaneously. Opening channel 3 therefore + needs 6,432 (old) + 12,864 (new) = **19,296 B** live at the peak. +2. **It is built unconditionally.** `DisplayPipeline::new` calls `build_lut` + three times with no reference to `options.lut_enabled`, and every + `output*.json` in `quad-strips-v3` sets `"lut_enabled": false`. The board + dies carrying 12.3 KB of lookup tables across four channels that the project + explicitly asked it not to use. + +### The full stack, symbolized from the crash record + +``` +fw_esp32v3::recovery::panic_path::stage_oom_and_reset +fw_esp32v3::on_alloc_error +alloc::raw_vec::handle_error +>::grow_one +::open +::open +::advance_frame +::tick_and_send::<...> +...::Executor::run -> main -> Reset +``` + +Note `advance_frame`: the channels open on the **first render**, not during +project load. That is why the boot frame guard was not on the stack (``) and why the fault outlives `boot_firmware`. + +## What this overturns + +The pre-diagnosis version of this document named a **prime suspect: the +registry/lease layer this port added in front of the backend, which +`led-lab-esp32` does not have.** That suspect is **exonerated**. Nothing in +endpoint resolution, `AnyPin::steal`, or `bind_channel` is involved; the fault +is in the provider's own bookkeeping `Vec`, one frame above the driver. Do not +spend more time diffing against `led-lab-esp32` — it runs on the same silicon +with a far smaller resident heap, which is the only reason it survives. + +The three "ruled out by measurement" findings all still stand, and all three are +now *explained* rather than merely excluded: + +- **Not stack exhaustion.** Correct — and 110 KB and 64 KB heaps faulted + identically because *both* OOM'd. Trading heap for stack could only ever have + made this worse. +- **Not the RMT RAM base.** Correct; execution never reaches the RAM clear. +- **Not a double panic from interrupts.** Correct; masking changes nothing + because the fault is an allocator return value, not an exception. + +## Why it took an RTC ledger to see this + +The old §"this fault cannot report itself" was accurate: every print variant +yielded the same ~5 characters, and the one datum that escaped was `L553` from +a file truncated to `/U`. That is now attributable — it was +`.../library/alloc/src/alloc.rs:553`, the standard library's +`handle_alloc_error`. **The line number was pointing at the answer the whole +time and could not be read.** + +The instrument that resolved it is `lp-recovery`'s RTC-RAM ledger, pulled +forward from M7 into M4, plus two things built on it: + +- A `#[alloc_error_handler]` that records `requested/align/free/used` into the + crash record, so an OOM arrives as an `Oom` with numbers instead of a panic + with a formatted string. +- `lpc-shared`'s `xt-map-esp32-classic` feature. The Xtensa stack walker's + bounds checks were hard-wired to the S3's IRAM/flash windows; on classic + silicon every frame in this backtrace (`0x400d…`–`0x4013…`) would have been + rejected and the walk would have reported zero frames. + +It named the fault on the **first** boot after flashing. + +## Fix directions, cheapest first + +Not yet chosen — the second one changes a struct every firmware and the host +share, so it is a design call, not a bring-up call. + +1. **`VecMap::with_capacity(channel_count)` in `Esp32OutputProvider`.** Removes + the growth spike (the 6,432 B of old buffer held during the copy) but *not* + the steady-state 12,864 B. **Insufficient alone** — 12,864 still exceeds the + 11,228 free. +2. **Stop carrying the LUT inline.** `Option>`, built + only when `options.lut_enabled`, drops `ChannelState` from 3,216 B to ~132 B + and this project's four channels from 12.9 KB to ~0.5 KB. It also stops + building a table the project disabled. Touches `lpc-shared`, so it changes + the S3 and C6 too — both currently survive on a larger arena, so this is + latent there rather than absent. +3. **Reduce the 101,408 B baseline.** The real headroom problem, and the + `serde`-surface-scale diet M6 was already going to have to describe. Out of + scope for M4. + +## Reproduce + +```bash +just build-fw-esp32v3 +espflash flash --chip esp32 --port /dev/cu.wchusbserial1130 \ + --partition-table lp-fw/fw-esp32v3/partitions.csv --flash-size 4mb \ + --baud 921600 --after hard-reset --monitor --monitor-baud 921600 \ + target/xtensa-esp32-none-elf/release-esp32v3/fw-esp32v3 +``` + +The board auto-loads `quad-strips-v3` and OOMs on the first render. Boot 2 +prints the record; boot 3 enters **safe mode** and stays up, reachable, at +~830 fps with the crash attached to every heartbeat. That last part is new — the +loop used to be unbreakable from the host, and clearing it meant erasing lpfs. + +Notes that still apply: + +- `espflash monitor` alone stub-halts the app; use `flash --monitor` under a + pty, or read the port directly. +- The board's port number moves between sessions (`…serial1140` and + `…serial1130` both observed). Always pass `--port` and check the chip id. +- To clear a wedged auto-load: `espflash erase-region --port … 0x310000 0xF0000`. + Safe mode makes this rarely necessary now. diff --git a/docs/defects/README.md b/docs/defects/README.md index 66f6387eb..ca1b753cf 100644 --- a/docs/defects/README.md +++ b/docs/defects/README.md @@ -207,6 +207,8 @@ the combination first being registered as a target. | opt-in-degradation | 2026-08-01 | [xt-builtins-image-strands-just-test](2026-08-01-xt-builtins-image-strands-just-test.md) | fixed | justfile (`ci-prereqs`/`test`) + build-builtins-xt.sh + lpvm-native tests | | config-masked-defect | 2026-08-01 | [xtlpn-f32-loses-writes-to-value-parameters](2026-08-01-xtlpn-f32-loses-writes-to-value-parameters.md) | fixed | lpvm-native lowering (lower_f32.rs) | | precision-loss-at-a-seam | 2026-08-01 | [gamma-8bit-choke](2026-08-01-gamma-8bit-choke.md) | fixed | lpc-engine fixture node | +| misattributed-symptom | 2026-08-01 | [classic-rmt-open-fault](2026-08-01-classic-rmt-open-fault.md) | fixed | lpc-shared DisplayPipeline + fw-esp32-common provider | +| capacity-regression | 2026-08-01 | [classic-heap-regression-after-f32-merge](2026-08-01-classic-heap-regression-after-f32-merge.md) | **open** | unattributed (3 f32 PRs are the candidates) | | test-rig-lies-about-its-subject | 2026-08-01 | [xt-pipeline-rigs-declare-param-types-as-return-types](2026-08-01-xt-pipeline-rigs-declare-param-types-as-return-types.md) | fixed (f32 rig; Q32 rig outstanding) | lpvm-native tests + lpir::builder | | model-conflation | 2026-08-01 | [xt-f32-builtins-exhaust-the-emulator-code-region](2026-08-01-xt-f32-builtins-exhaust-the-emulator-code-region.md) | fixed | lp-xt-emu (board/memory) + lps-builtins-xt-app + lpvm-native/rt_emu | | upstream-toolchain-limitation | 2026-08-01 | [xtensa-backend-cannot-select-float-constant-pool](2026-08-01-xtensa-backend-cannot-select-float-constant-pool.md) | **open** (worked around) | lps-builtins + esp Rust toolchain | diff --git a/docs/design/brightness-gamma-dithering.md b/docs/design/brightness-gamma-dithering.md new file mode 100644 index 000000000..f0d4b6e7c --- /dev/null +++ b/docs/design/brightness-gamma-dithering.md @@ -0,0 +1,142 @@ +# Brightness, gamma, white point, and dithering — how light actually flows + +How a shader sample becomes photons, which numeric domain each stage lives +in, and why the order of operations is the whole ballgame. Written 2026-08-01 +after the classic-ESP32 bring-up measured all of it on silicon +(`docs/defects/2026-08-01-gamma-8bit-choke.md`, PR #252, and the bench session +recorded in `docs/debt/brightness-applied-before-gamma.md`). + +## The wire's reality + +A WS281x pixel accepts **8 bits per channel of duty cycle**, and duty is +(approximately) linear light: code 128 emits half the photons of code 255. +There is no getting more resolution out of the protocol itself; everything +above the wire exists to spend those 256 codes well. + +On device, the LED refresh rate is currently **locked to the engine frame +rate** — `Esp32OutputProvider::write` runs once per engine frame and each call +transmits one frame (`lp-fw/fw-esp32-common/src/output/provider.rs`). On the +classic ESP32 that is ~20 fps today. This matters for dithering; see below. + +## Two languages, one translator + +- **The LED speaks linear light** (duty cycle, photons). +- **The eye speaks ratios** — it compresses, roughly `perceived ≈ light^(1/γ)`. + Half the photons reads as ~78 % as bright, not 50 %. +- **Gamma is the translator**: `linear = perceptual^γ`, with **γ = 2.8** here + (inherited from the Adafruit LED convention via the legacy `GAMMA8` table — + steeper than sRGB's ~2.2; kept for visual continuity when the 16-bit table + replaced it, see `lp-core/lpc-engine/src/nodes/fixture/gamma.rs`). + +The graphics-industry lesson (sRGB) applies verbatim: **do math in linear +space, apply the encode once, at the boundary.** Scales, blends, +interpolation, and power limiting all belong on the linear side; the +perceptual encode is a wire format, not a place to compute. + +## The pipeline, stage by stage + +| stage | where | domain | notes | +|---|---|---|---| +| shader output | engine / JIT | u16, **perceptual** | what the author sees in Studio previews | +| fixture sampling | `fixture_node.rs` | u16 perceptual | mapping only, no value math | +| **brightness** | `fixture_node.rs` (`apply_brightness_unorm16`) | u16 **perceptual** ⚠️ | applied **before** gamma — see "the ordering question" | +| **gamma** (`gamma_correction: true`) | `gamma.rs` (`apply_gamma16`) | perceptual → **linear** | the encode. 16-bit in/out since PR #252; `[u32; 513]` const table, max error 0.75 counts in 65,535 | +| power limit | `power_limit.rs` | u16 linear | **after gamma, load-bearing**: power ∝ duty ∝ linear, so a scale derived from duty sums must land on linear values | +| color order | `fixture_node.rs` | u16 linear | byte shuffle | +| control product | wire between nodes | u16 linear | `Unorm16` | +| frame interpolation | `DisplayPipeline` | u16 linear | correct domain — blending light levels is a linear-space operation | +| white point (`lut_enabled: true`) | `DisplayPipeline` | u16 linear | Q16.16 multiply per channel (the 3 KB/channel table it replaced computed exactly this — see the defect) | +| **temporal dithering** | `DisplayPipeline` (`dither_step`) | u16 → u8 | error-carry across frames; expresses fractional codes | +| wire | RMT / driver | **u8 duty** | 256 codes, hard stop | + +Everything downstream of the gamma step is coherently linear. The one +domain oddity in the pipeline is brightness sitting on the perceptual side. + +## The ordering question + +Because gamma is a pure power law, moving a constant scale across it is an +exact identity: + +``` +(s · c)^γ = s^γ · c^γ +``` + +Two consequences, one of which is easy to get wrong: + +1. **The image does not change.** The scale factors out, so pixel-to-pixel + contrast ratios are *identical* whether brightness is applied before or + after gamma. Ordering is not an artistic choice. +2. **The slider's meaning and the wire's resolution change enormously.** + - Brightness **before** gamma (today): the slider is *perceptual*. + Slider `s` asks for `s` of the perceived brightness, which is `s^2.8` of + the photons — and `s^2.8` of the wire codes. + - Brightness **after** gamma: the slider is *linear*. Slider `s` gives + `s` of the photons and `s` of the codes; perceived brightness moves as + `s^(1/2.8)` (slider 15 % reads as ~51 %). + +Measured consequence at the top of the range (`255·s^2.8` vs `255·s` codes): + +| slider | codes today (perceptual) | codes if linear | +|---|---|---| +| 255 | 255 | 255 | +| 127 | 32 | 127 | +| 64 | 5.3 | 64 | +| 38 | **1.24** | 38 | + +At brightness 38 — the value the classic bring-up's test projects ship — +**the entire image fits in 1¼ wire codes**. Only content above 72 % clears +code 1; below ~19 % nothing reaches the wire at all. On the bench this looks +like "mostly dark, a few of the brightest pixels dimly lit", and it is the +pipeline faithfully executing a request the wire cannot carry. It is also, +almost certainly, why those projects ship `gamma_correction: false`: gamma +was unusable at dim brightness, and the ordering — not gamma itself — was the +cause. + +Verified on silicon 2026-08-01: identical firmware, identical project, +`gamma_correction` flipped — 4 bytes of heap and ~1 fps of difference. The +16-bit gamma itself is free; the ordering is what starves the wire. + +## What other systems do + +- **WLED** ships gamma for *color* and gamma for *brightness* as separate + toggles ([kno.wled.ge/features/settings](https://kno.wled.ge/features/settings/)): + color gamma — *"Will correct colors to match those on a monitor. Strongly + advised to keep on."* Brightness gamma — *"Will correct brightness changes + to make it appear more linear. Advised to leave off."* + Our current behavior is equivalent to WLED with brightness-gamma **on** — + the configuration WLED advises against. +- **FastLED**: `setBrightness()` is a linear whole-animation scale applied at + show time, with temporal dithering recovering sub-code resolution + ([FastLED temporal-dithering wiki](https://github.com/FastLED/FastLED/wiki/FastLED-Temporal-Dithering)). + Their docs warn that at low refresh rates and low brightness *"you may see + the dithered pixel output as flickering"* — see below. +- **Displays**: OS brightness moves the backlight (linear light); the pixel + encode is untouched. +- **Stage lighting**: dimmer curves (linear / square / S-curve) are an + explicit per-fixture configuration, because the semantics genuinely is a + choice — the sin is making it implicitly, which is where we are today. + +## Dithering: what it can and cannot rescue + +`dither_step` carries the 16→8 quantization error across frames, so code 0.66 +becomes "on 2 frames of every 3". Two hard limits: + +1. **It needs refresh well above flicker fusion** (~100 Hz+; FastLED aims for + hundreds). Our device refresh is the engine frame rate — ~20 fps on the + classic today — so sub-code dithering there renders as visible *sparkle*, + not smooth dimness. Higher-fps hosts (sim, S3) fuse much better. +2. **It cannot restore information destroyed upstream.** The 8-bit gamma + choke (fixed in PR #252) quantized before dithering ever ran; the + brightness ordering compresses the signal into ~1 code before dithering + sees it. Dithering is a last-inch tool: it spends fractional codes, it does + not mint new ones. + +## The open decision + +Whether to move brightness to the linear side (composing it with the power +scale that already lives there), matching the WLED/FastLED convention, is +tracked — with the measured costs of both options — in +`docs/debt/brightness-applied-before-gamma.md`. Any slider-*feel* curve, if +wanted after that change, belongs in the UI layer, decoupled from the LED +encode: mapping the slider through γ = 2.8 and applying it in linear space is +numerically identical to today's behavior and would recreate the problem. diff --git a/justfile b/justfile index ec0f4258a..df31dd921 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,23 @@ fw_esp32s3_elf := "target/" + xt_s3_target + "/release-esp32s3/fw-esp32s3" # size 0x400000". The same value is duplicated in # lp-fw/fw-esp32s3/.cargo/config.toml's runner, which cannot read this var. s3_flash_size := "8mb" + +# fw-esp32v3 (classic ESP32, "v3"/WROOM-32E) also builds on Espressif's Rust +# fork (see lp-fw/fw-esp32v3/rust-toolchain.toml). Like fw-esp32s3 it is a +# repo-root workspace member excluded from `default-members` (M3-P1 folded it +# in when it gained real lp2025-internal path dependencies), so its artifacts +# land in the shared root `target/`. The build still runs from the crate +# directory — see `build-fw-esp32v3`. +xt_v3_target := "xtensa-esp32-none-elf" +fw_esp32v3_dir := "lp-fw/fw-esp32v3" +fw_esp32v3_elf := "target/" + xt_v3_target + "/release-esp32v3/fw-esp32v3" + +# This crate's 4 MB flash size (docs/adr/2026-07-29-per-chip-fw-toolchains.md, +# Q7 in the classic-ESP32 bring-up plan: C6-shaped table, not the S3's 8 MB +# floor). Must match lp-fw/fw-esp32v3/partitions.csv and the runner in +# lp-fw/fw-esp32v3/.cargo/config.toml, which cannot read this var — same +# reasoning as s3_flash_size above. +v3_flash_size := "4mb" lps_dir := "lp-shader" studio_assets_dir := "target/studio-web-assets" @@ -626,6 +643,45 @@ clippy-fw-esp32s3: cargo clippy --release --no-default-features \ --features esp32s3,server,test_xt_jit_corpus -- --no-deps -D warnings +# Lint gate for fw-esp32v3, mirroring clippy-fw-esp32s3. Separate from +# `clippy-host` for the same reason as the S3: the crate is excluded there +# (it cross-compiles for Xtensa under a different toolchain), so nothing else +# lints it. +clippy-fw-esp32v3: + #!/usr/bin/env bash + set -euo pipefail + GCC_BIN="$(just _xt-gcc-dir xtensa-esp32-elf-gcc)" + if [[ -n "$GCC_BIN" ]]; then + export PATH="$GCC_BIN:$PATH" + fi + # `cd` for the same reason the build recipe does it: .cargo/config.toml + # here selects the Xtensa target, and cargo reads it from the CWD upward. + cd {{ fw_esp32v3_dir }} + # `--profile release-esp32v3`, NOT fw-esp32s3's `--release`: esp-storage's + # build script hard-errors on this chip at the workspace release profile's + # `opt-level = "z"` ("Building esp-storage for ESP32 needs optimization + # level 2, 3 or s"), because classic-ESP32 flash operations must execute + # from IRAM inside a tight window that "z" codegen misses. `release-esp32v3` + # is "s", which is also what ships — so this lints the real image. + # + # The app path (default features = esp32 + server). + cargo clippy --profile release-esp32v3 -- --no-deps -D warnings + # The two non-default entrypoints in main.rs. Neither is reachable from + # the default build, so linting only the defaults would leave both + # completely uncovered — the same way 13 fw-esp32 harnesses once rotted. + for feats in "esp32" "esp32,radio_ram_probe"; do + echo "clippy: --no-default-features --features $feats" + cargo clippy --profile release-esp32v3 --no-default-features --features "$feats" -- --no-deps -D warnings + done + # `ws281x_telemetry` is ADDITIVE (it turns on a module inside the default + # app build), so it needs its own invocation on top of the defaults rather + # than a `--no-default-features` one. Linted for the same reason the two + # entrypoints above are: a diagnostic build nothing compiles is a + # diagnostic build that has rotted by the time someone reaches for it. + echo "clippy: --features ws281x_telemetry" + cargo clippy --profile release-esp32v3 --features ws281x_telemetry -- --no-deps -D warnings + + # `features` is a comma-separated list added to the defaults — for the app path # that means `frame-dump` and nothing else today. Harnesses have their own # recipes because they REPLACE the entrypoint; this argument only decorates it, @@ -643,19 +699,40 @@ build-fw-esp32s3 features="": fi cd lp-fw/fw-esp32s3 && cargo "${args[@]}" -# Print the directory to prepend to PATH so xtensa-esp32s3-elf-gcc resolves, -# or fail with the fix. Prints NOTHING when the toolchain is already on PATH — -# which is how CI arrives (the esp-rs/xtensa-toolchain action puts it there), -# versus a local espup install, which leaves it under ~/.rustup. -_xt-gcc-dir: +# Build the classic ESP32 ("v3") firmware. Same Xtensa-fork story as the S3 +# (see build-fw-esp32s3 above), and — since M3-P1 — the same workspace shape: +# a root-workspace member writing into the shared root `target/`. The `cd` is +# still required, exactly as it is for fw-esp32s3: `.cargo/config.toml` inside +# the crate selects the Xtensa target, the linker flags and the espflash +# runner, and cargo reads that file from the CWD upward — invoking from the +# repo root would silently build for the host. +build-fw-esp32v3: #!/usr/bin/env bash set -euo pipefail - if command -v xtensa-esp32s3-elf-gcc >/dev/null 2>&1; then + GCC_BIN="$(just _xt-gcc-dir xtensa-esp32-elf-gcc)" + if [[ -n "$GCC_BIN" ]]; then + export PATH="$GCC_BIN:$PATH" + fi + cd {{ fw_esp32v3_dir }} && cargo build --profile release-esp32v3 + +# Print the directory to prepend to PATH so the chip's xtensa-*-elf-gcc +# resolves, or fail with the fix. Prints NOTHING when the toolchain is already +# on PATH — which is how CI arrives (the esp-rs/xtensa-toolchain action puts +# it there), versus a local espup install, which leaves it under ~/.rustup. +# +# `bin` names the specific gcc binary to probe for (all Xtensa chips share one +# toolchain bundle, so any installed chip's binary proves the bundle exists, +# but only checking the caller's own chip catches a bundle that is present but +# missing that target — e.g. `buildtargets` scoped too narrowly in CI). +_xt-gcc-dir bin="xtensa-esp32s3-elf-gcc": + #!/usr/bin/env bash + set -euo pipefail + if command -v {{ bin }} >/dev/null 2>&1; then exit 0 fi GCC_BIN="$(echo "$HOME"/.rustup/toolchains/esp/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin | tr ' ' '\n' | tail -1)" - if [[ ! -x "$GCC_BIN/xtensa-esp32s3-elf-gcc" ]]; then - echo "error: xtensa-esp32s3-elf-gcc is not on PATH and was not found under" >&2 + if [[ ! -x "$GCC_BIN/{{ bin }}" ]]; then + echo "error: {{ bin }} is not on PATH and was not found under" >&2 echo " ~/.rustup/toolchains/esp — run 'espup install' (or 'espup update'" >&2 echo " if it is stale). The Rust target spec links through it, not rust-lld." >&2 exit 1 @@ -931,6 +1008,22 @@ fw-esp32s3-size-check margin="65536": build-fw-esp32s3 just _fw-size-check esp32s3 esp32s3 {{ s3_flash_size }} {{ fw_esp32s3_elf }} 6291456 {{ margin }} \ "See lp-fw/fw-esp32s3/README.md 'Partitions'." +# Fail when the esp32v3 (classic ESP32) app image gets too close to its 3 MB +# partition. Same C6-shaped 4 MB table and budget posture as fw-esp32c6 (Q7 in +# the classic-ESP32 bring-up plan) — unlike the S3, this chip has no 8 MB +# floor to grow into, so this IS a hard budget gate, not just a trend. +# +# `chip` passed to `_fw-size-check` is the real espflash chip id ("esp32"); +# `name` is the crate's own "esp32v3" label, same split as fw-esp32c6's +# name/chip both being "esp32c6" happens to hide (there the two coincide). +fw-esp32v3-size-check margin="65536": build-fw-esp32v3 + #!/usr/bin/env bash + set -euo pipefail + # Keep `partition` in sync with the `factory` app partition in + # lp-fw/fw-esp32v3/partitions.csv (0x300000). + just _fw-size-check esp32v3 esp32 {{ v3_flash_size }} {{ fw_esp32v3_elf }} 3145728 {{ margin }} \ + "See lp-fw/fw-esp32v3/README.md 'Partitions'." + # Shared tail of the per-chip size checks: measure the flashable image and # compare it against the app partition. Factored so the two chips cannot drift # apart — the C6 partition has overrun twice, and a second copy of this logic @@ -1106,7 +1199,7 @@ fmt-check: # heavy wgpu/naga dependency tree into an otherwise wgpu-free build graph. # They are covered by `clippy-gfx`, which CI runs in the gated Validate GFX job. clippy-host: - cargo clippy --workspace --exclude lps-builtins-emu-app --exclude fw-esp32c6 --exclude fw-esp32s3 --exclude fw-emu --exclude lp-riscv-emu-guest-test-app --exclude lp-riscv-emu-guest --exclude lp-gfx-wgpu --exclude fw-browser --exclude naga-wasm-poc -- --no-deps -D warnings + cargo clippy --workspace --exclude lps-builtins-emu-app --exclude fw-esp32c6 --exclude fw-esp32s3 --exclude fw-esp32v3 --exclude fw-emu --exclude lp-riscv-emu-guest-test-app --exclude lp-riscv-emu-guest --exclude lp-gfx-wgpu --exclude fw-browser --exclude naga-wasm-poc -- --no-deps -D warnings # The wgpu-tree workspace members excluded from clippy-host. clippy-gfx: @@ -1689,6 +1782,40 @@ decode-backtrace-esp32s3 *addrs: addr2line -e {{ fw_esp32s3_elf }} -f -a $ADDRS fi +# Symbolize a classic-ESP32 (fw-esp32v3) backtrace. +# +# Separate from `decode-backtrace-esp32s3` because the ELF differs — and on this +# chip the addresses look nothing alike either: classic flash text lives at +# 0x400Dxxxx where the S3's and C6's live at 0x42xxxxxx, so feeding one to the +# other's recipe produces confident nonsense rather than an obvious failure. +# `recovery::panic_path` and the boot report both print the right recipe name +# next to the addresses for exactly that reason. +# +# Usage: just decode-backtrace-esp32v3 0x400d1234 ... +# pbpaste | just decode-backtrace-esp32v3 +decode-backtrace-esp32v3 *addrs: + #!/usr/bin/env bash + set -e + test -f {{ fw_esp32v3_elf }} + if [ -n "{{ addrs }}" ]; then + ADDRS="{{ addrs }}" + else + ADDRS=$(grep -oE '0x[0-9a-fA-F]+' | tr '\n' ' ') + fi + if [ -z "$ADDRS" ]; then + echo "No addresses. Usage: just decode-backtrace-esp32v3 0x400d... or: pbpaste | just decode-backtrace-esp32v3" + exit 1 + fi + GCC_BIN="$(just _xt-gcc-dir xtensa-esp32-elf-gcc)" + if [[ -n "$GCC_BIN" ]]; then + export PATH="$GCC_BIN:$PATH" + fi + if command -v xtensa-esp32-elf-addr2line >/dev/null 2>&1; then + xtensa-esp32-elf-addr2line -pfiaC -e {{ fw_esp32v3_elf }} $ADDRS + else + addr2line -e {{ fw_esp32v3_elf }} -f -a $ADDRS + fi + # ============================================================================ # Profiling (lp-cli profile) # ============================================================================ diff --git a/lp-app/lpa-boards/tests/manifest_drift.rs b/lp-app/lpa-boards/tests/manifest_drift.rs index 4936d785d..804ef6b3e 100644 --- a/lp-app/lpa-boards/tests/manifest_drift.rs +++ b/lp-app/lpa-boards/tests/manifest_drift.rs @@ -19,18 +19,21 @@ use lpc_hardware::{HardwareBoardLabelStatus, HardwareManifestFile}; const DISPLAY_ONLY: &[(&str, &str)] = &[ ( "espressif/esp32-devkitc-v4", - "classic ESP32 (v3) has no HardwareTarget yet", + "no devkit on the desk; classic target (HardwareTarget::Esp32) landed \ + 2026-07-31 — manifest when one needs calibrating", ), ( "quinled/dig-uno", - "classic ESP32 (v3) has no HardwareTarget yet", - ), - ( - "domraem/dom-z-102", - "classic ESP32 (v3) has no HardwareTarget yet", + "no board on the desk to verify GPIOs against; classic target landed \ + 2026-07-31", ), ]; +/// The mirror case: runtime manifests whose display sidecar is landing on a +/// different in-flight branch, with the reason. Strict like DISPLAY_ONLY — +/// once the sidecar merges, the entry must be removed. +const RUNTIME_ONLY: &[(&str, &str)] = &[]; + fn boards_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../lp-core/lpc-hardware/boards") } @@ -85,10 +88,17 @@ fn manifest_pairs() -> BTreeMap, Option panic!( + "{board_id}: runtime manifest has no display sidecar and no RUNTIME_ONLY \ + reason — the catalog can't show it" + ), + (Some(_), true) => { + panic!("{board_id}: has a display sidecar — remove it from RUNTIME_ONLY") + } + _ => {} + } let allowlisted = DISPLAY_ONLY.iter().any(|(id, _)| *id == board_id); match (&runtime, allowlisted) { (None, false) => panic!( @@ -109,7 +119,14 @@ fn embedded_catalog_matches_the_directory() { .iter() .map(|(id, _)| *id) .collect(); - for board_id in pairs.keys() { + // The embedded catalog mirrors DISPLAY sidecars; runtime-only boards + // (RUNTIME_ONLY above) have nothing to embed yet. + let with_display: Vec<&String> = pairs + .iter() + .filter(|(_, (display, _))| display.is_some()) + .map(|(id, _)| id) + .collect(); + for board_id in &with_display { assert!( embedded.contains(&board_id.as_str()), "{board_id}: display sidecar exists on disk but is not embedded in lpa_boards::catalog" @@ -117,7 +134,7 @@ fn embedded_catalog_matches_the_directory() { } assert_eq!( embedded.len(), - pairs.len(), + with_display.len(), "embedded catalog lists a board with no on-disk display sidecar" ); // And the embedded bytes are the on-disk bytes (include_str! path typos). diff --git a/lp-app/lpa-client/Cargo.toml b/lp-app/lpa-client/Cargo.toml index 9cffeb9b1..acd5bfc30 100644 --- a/lp-app/lpa-client/Cargo.toml +++ b/lp-app/lpa-client/Cargo.toml @@ -20,6 +20,10 @@ lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", optional = true, features lp-riscv-emu = { path = "../../lp-riscv/lp-riscv-emu", optional = true, default-features = false } lp-emu-core = { path = "../../lp-emu/lp-emu-core", optional = true } serialport = { version = "4.8", optional = true } +# Raw TIOCMGET/TIOCMSET for whole-status modem-line writes (see +# stream/serialport_stream.rs — the WCH CH34x macOS driver ignores the +# single-bit ioctls serialport's per-line calls use). +libc = { version = "0.2", optional = true } hashbrown = { workspace = true, optional = true } log = { workspace = true, features = ["std"], optional = true } @@ -35,6 +39,7 @@ serial = [ "lp-emu-core/std", "hashbrown", "serialport", + "dep:libc", ] emu = ["host", "lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "lp-emu-core", "lp-emu-core/std", "hashbrown"] ws = ["host", "tokio-tungstenite"] diff --git a/lp-app/lpa-client/src/stream/serialport_stream.rs b/lp-app/lpa-client/src/stream/serialport_stream.rs index cdcbffb1f..e65abc3f5 100644 --- a/lp-app/lpa-client/src/stream/serialport_stream.rs +++ b/lp-app/lpa-client/src/stream/serialport_stream.rs @@ -13,22 +13,81 @@ use crate::stream::{ByteStreamError, DeviceByteStream}; pub struct SerialPortByteStream { port_name: String, port: Box, + /// Raw fd of the open port, for the whole-status modem-line ioctl (see + /// [`DeviceByteStream::set_signals`] below). Captured at open/reopen — + /// the boxed `SerialPort` trait object does not expose it. + #[cfg(unix)] + raw_fd: std::os::fd::RawFd, } impl SerialPortByteStream { /// Open `port_name` at `baud_rate`. pub fn open(port_name: &str, baud_rate: u32) -> Result { - let port = open_serial_port(port_name, baud_rate)?; - Ok(Self { - port_name: port_name.to_string(), - port, - }) + #[cfg(unix)] + { + let port = open_serial_port_native(port_name, baud_rate)?; + let raw_fd = std::os::fd::AsRawFd::as_raw_fd(&port); + Ok(Self { + port_name: port_name.to_string(), + port: Box::new(port), + raw_fd, + }) + } + #[cfg(not(unix))] + { + let port = open_serial_port(port_name, baud_rate)?; + Ok(Self { + port_name: port_name.to_string(), + port, + }) + } } /// The OS port name this stream was opened on. pub fn port_name(&self) -> &str { &self.port_name } + + /// Set DTR and RTS in ONE `TIOCMSET` whole-status write. + /// + /// Load-bearing, not an optimization: the WCH CH34x macOS driver (the + /// DOM-Z-102's CH340K bridge) silently ignores the single-bit + /// `TIOCMBIS`/`TIOCMBIC` ioctls behind `write_data_terminal_ready` / + /// `write_request_to_send`, while honoring `TIOCMSET` — verified on + /// hardware (classic bring-up M3; a reset dance through the per-line + /// calls never reset the chip, the identical sequence through `TIOCMSET` + /// did). This is also why espflash carries `UnixTightReset` alongside + /// its per-line `ClassicReset`. + #[cfg(unix)] + fn set_signals_whole_status(&mut self, dtr: bool, rts: bool) -> Result<(), ByteStreamError> { + let fd = self.raw_fd; + let mut status: libc::c_int = 0; + if unsafe { libc::ioctl(fd, libc::TIOCMGET, &mut status) } != 0 { + return Err(ByteStreamError::io(format!( + "TIOCMGET on {}: {}", + self.port_name, + std::io::Error::last_os_error() + ))); + } + if dtr { + status |= libc::TIOCM_DTR; + } else { + status &= !libc::TIOCM_DTR; + } + if rts { + status |= libc::TIOCM_RTS; + } else { + status &= !libc::TIOCM_RTS; + } + if unsafe { libc::ioctl(fd, libc::TIOCMSET, &status) } != 0 { + return Err(ByteStreamError::io(format!( + "TIOCMSET on {}: {}", + self.port_name, + std::io::Error::last_os_error() + ))); + } + Ok(()) + } } impl DeviceByteStream for SerialPortByteStream { @@ -52,6 +111,14 @@ impl DeviceByteStream for SerialPortByteStream { } fn set_signals(&mut self, dtr: Option, rts: Option) -> Result<(), ByteStreamError> { + // Both lines at once → one whole-status write, where supported (see + // set_signals_whole_status for the driver that requires it). + // Single-line writes keep the per-line calls: the USB-Serial-JTAG + // dance depends on their exact pin-write sequence. + #[cfg(unix)] + if let (Some(dtr), Some(rts)) = (dtr, rts) { + return self.set_signals_whole_status(dtr, rts); + } if let Some(dtr) = dtr { self.port .write_data_terminal_ready(dtr) @@ -66,25 +133,50 @@ impl DeviceByteStream for SerialPortByteStream { } fn reopen(&mut self, baud_rate: u32) -> Result<(), ByteStreamError> { - let reopened = open_serial_port(&self.port_name, baud_rate)?; - self.port = reopened; + #[cfg(unix)] + { + let reopened = open_serial_port_native(&self.port_name, baud_rate)?; + self.raw_fd = std::os::fd::AsRawFd::as_raw_fd(&reopened); + self.port = Box::new(reopened); + } + #[cfg(not(unix))] + { + self.port = open_serial_port(&self.port_name, baud_rate)?; + } Ok(()) } } -/// Open a serial port with the transport's standard settings. -fn open_serial_port( - port_name: &str, - baud_rate: u32, -) -> Result, ByteStreamError> { +/// The transport's standard port settings. +fn port_builder(port_name: &str, baud_rate: u32) -> serialport::SerialPortBuilder { serialport::new(port_name, baud_rate) .data_bits(serialport::DataBits::Eight) .stop_bits(serialport::StopBits::One) .parity(serialport::Parity::None) .flow_control(serialport::FlowControl::None) .timeout(Duration::from_millis(100)) - .open() +} + +/// Open a serial port as the platform-native type (which exposes the raw fd). +#[cfg(unix)] +fn open_serial_port_native( + port_name: &str, + baud_rate: u32, +) -> Result { + port_builder(port_name, baud_rate) + .open_native() .map_err(|error| { ByteStreamError::io(format!("Failed to open serial port {port_name}: {error}")) }) } + +/// Open a serial port with the transport's standard settings. +#[cfg(not(unix))] +fn open_serial_port( + port_name: &str, + baud_rate: u32, +) -> Result, ByteStreamError> { + port_builder(port_name, baud_rate).open().map_err(|error| { + ByteStreamError::io(format!("Failed to open serial port {port_name}: {error}")) + }) +} diff --git a/lp-app/lpa-client/src/transport_serial/hardware.rs b/lp-app/lpa-client/src/transport_serial/hardware.rs index 4f99cae07..855c26c80 100644 --- a/lp-app/lpa-client/src/transport_serial/hardware.rs +++ b/lp-app/lpa-client/src/transport_serial/hardware.rs @@ -44,7 +44,9 @@ fn serial_thread_loop( options: HardwareSerialOptions, ) { if options.reset_after_open { - if let Err(e) = reset_after_open(stream.as_mut()) { + let style = detect_reset_style(&stream_label); + log::debug!("Serial thread: resetting {stream_label} via {style:?}"); + if let Err(e) = reset_after_open(stream.as_mut(), style) { log::error!("Serial thread: Failed to reset device after opening {stream_label}: {e}"); drop(server_tx); return; @@ -170,26 +172,95 @@ fn serial_thread_loop( log::debug!("Serial thread: Exiting"); } -/// The USB-JTAG-serial reset dance espflash performs after flashing an +/// How the DTR/RTS lines reach the chip's reset, which decides the dance. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum SerialResetStyle { + /// Espressif native USB-Serial-JTAG (C6, S3): the peripheral interprets + /// DTR/RTS *patterns*; there are no real EN/IO0 wires. + UsbSerialJtag, + /// External USB-UART bridge (CH340/CP210x class — the classic ESP32's + /// only option): DTR/RTS drive the standard two-transistor EN/IO0 + /// auto-reset circuit, so the run-mode reset is a plain EN pulse with + /// IO0 left high. + UartBridge, +} + +/// Pick the reset dance from the port's USB vendor id. Espressif's native +/// USB-Serial-JTAG enumerates as VID 0x303A; anything else on a real port is +/// an external UART bridge (the desk classic's CH340K is 0x1A86). Streams +/// whose label is not a native port (fakes, tests) fall back to the JTAG +/// dance — the pre-existing behavior, and harmless where no pins exist. +fn detect_reset_style(port_name: &str) -> SerialResetStyle { + const ESPRESSIF_VID: u16 = 0x303A; + let Ok(ports) = serialport::available_ports() else { + return SerialResetStyle::UsbSerialJtag; + }; + for port in ports { + if port.port_name == port_name { + if let serialport::SerialPortType::UsbPort(info) = &port.port_type { + if info.vid != ESPRESSIF_VID { + return SerialResetStyle::UartBridge; + } + } + break; + } + } + SerialResetStyle::UsbSerialJtag +} + +/// Reset the device after opening the port, per [`SerialResetStyle`]. +/// +/// `UsbSerialJtag` is the dance espflash performs after flashing an /// ESP32-C6, expressed as single-pin [`DeviceByteStream::set_signals`] writes /// so the pin-write sequence is identical to the pre-seam code. /// -/// Before the final release (the RTS falling edge that actually reboots the -/// chip), any pending input is discarded: a previously RUNNING device flushes -/// its buffered TX — heartbeat `M!` frames included — into the freshly -/// opened port, and delivering those to the readiness gate misclassifies the -/// boot as `Incompatible { FrameBeforeHello }` (found on hardware, M5 +/// `UartBridge` is espflash's classic run-mode reset: DTR deasserted (IO0 +/// high — this must NOT be the bootloader entry), RTS asserted to hold EN +/// low, then released. Without this arm, a CH340-bridged classic ESP32 was +/// never reset at all — the client attached to a still-running device whose +/// unsolicited hello had gone out minutes earlier, and the readiness gate +/// misclassified the session as `Incompatible { FrameBeforeHello }` (found +/// on the DOM-Z-102, classic bring-up M3). +/// +/// In both arms, pending input is discarded before the edge that reboots the +/// chip: a previously RUNNING device flushes its buffered TX — heartbeat +/// `M!` frames included — into the freshly opened port, and delivering those +/// to the readiness gate misclassifies the boot (found on hardware, M5 /// smoke). Bytes that arrive before the reset takes effect are not boot -/// output; everything after the falling edge is. -fn reset_after_open(stream: &mut dyn DeviceByteStream) -> Result<(), ByteStreamError> { - stream.set_signals(Some(false), None)?; - thread::sleep(Duration::from_millis(100)); - stream.set_signals(None, Some(true))?; - stream.set_signals(Some(false), None)?; - stream.set_signals(None, Some(true))?; - thread::sleep(Duration::from_millis(100)); - discard_stale_input(stream)?; - stream.set_signals(None, Some(false))?; +/// output; everything after the edge is. +fn reset_after_open( + stream: &mut dyn DeviceByteStream, + style: SerialResetStyle, +) -> Result<(), ByteStreamError> { + match style { + SerialResetStyle::UsbSerialJtag => { + stream.set_signals(Some(false), None)?; + thread::sleep(Duration::from_millis(100)); + stream.set_signals(None, Some(true))?; + stream.set_signals(Some(false), None)?; + stream.set_signals(None, Some(true))?; + thread::sleep(Duration::from_millis(100)); + discard_stale_input(stream)?; + stream.set_signals(None, Some(false))?; + } + SerialResetStyle::UartBridge => { + // Every step writes BOTH lines, which on unix goes through one + // whole-status ioctl — the WCH CH34x macOS driver ignores the + // single-bit calls entirely (see SerialPortByteStream). The + // (true,true) pass-through mirrors espflash's UnixTightReset: + // both-asserted is the transistor pair's neutral state, so the + // sequence never crosses (dtr asserted, rts released), which + // would select the ROM bootloader. + stream.set_signals(Some(false), Some(false))?; + stream.set_signals(Some(true), Some(true))?; + // EN low, IO0 high: chip held in reset. + stream.set_signals(Some(false), Some(true))?; + thread::sleep(Duration::from_millis(100)); + discard_stale_input(stream)?; + // Release EN with IO0 still high: the chip boots the app. + stream.set_signals(Some(false), Some(false))?; + } + } Ok(()) } diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png index 5b6cc7908..2d41f7663 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png differ diff --git a/lp-base/lp-recovery/src/lib.rs b/lp-base/lp-recovery/src/lib.rs index f8d1061f1..ec3798b53 100644 --- a/lp-base/lp-recovery/src/lib.rs +++ b/lp-base/lp-recovery/src/lib.rs @@ -60,8 +60,8 @@ pub use ledger::{GatedInfo, Ledger}; pub use path_entry::{ENTRY_NAME_CAP, PathEntry}; pub use recovery::{ BootAssessment, EnterDenied, EnteredFrame, Recovery, RecoveryHandle, clear_tentative_crash, - enter, finalize_crash_and_reset, is_initialized, mark_boot_complete, record_recovered_crash, - set_global, snapshot, stage_crash, + commit_staged_crash, enter, finalize_crash_and_reset, is_initialized, mark_boot_complete, + record_recovered_crash, set_global, snapshot, stage_crash, }; pub use recovery_level::RecoveryLevel; pub use recovery_region::{REGION_MAGIC, REGION_MAX_SIZE, REGION_VERSION, RecoveryRegion}; diff --git a/lp-base/lp-recovery/src/recovery.rs b/lp-base/lp-recovery/src/recovery.rs index 5d6c209af..56a765705 100644 --- a/lp-base/lp-recovery/src/recovery.rs +++ b/lp-base/lp-recovery/src/recovery.rs @@ -78,6 +78,12 @@ pub trait RecoveryHandle { oom: Option, ); fn clear_tentative_crash(&mut self); + /// Promote the staged record to committed **without** resetting. + /// + /// For abort-tier panic handlers that cannot trust themselves to survive + /// long enough to reach [`RecoveryHandle::finalize_crash_and_reset`]. See + /// the free function [`commit_staged_crash`] for the full argument. + fn commit_staged_crash(&mut self); /// A panic was caught in-process (layer 1): void the staged record AND /// feed the crash into the blame ledger so repeated in-process crashes /// gate their path exactly like reboot-causing ones. @@ -264,6 +270,13 @@ impl RecoveryHandle for Recovery { self.backend.region().crash_mut().clear_tentative(); } + fn commit_staged_crash(&mut self) { + let region = self.backend.region(); + if region.crash().is_tentative() { + region.crash_mut().finalize(); + } + } + fn record_recovered_crash(&mut self) { let region = self.backend.region(); // Blame the staged record's path (snapshotted at panic time, before @@ -400,6 +413,32 @@ pub fn stage_crash( with_global(|r| r.stage_crash(cause, msg, location, pcs, oom)).is_some() } +/// Commit the staged crash record **without** resetting. Returns `false` if no +/// global is installed (or it was busy). +/// +/// [`stage_crash`] leaves the record *tentative*, and `Recovery::init` throws a +/// tentative record away on the next boot unless the reset cause was +/// `WatchdogReset` — a deliberate rule, because on the unwinding tier a +/// tentative record usually means layer-1 caught the panic and there was never +/// a crash to report. +/// +/// That rule makes a tentative record worthless as a breadcrumb for a fault +/// that kills the chip through some *other* reset path. The classic ESP32 has +/// exactly that fault: `docs/defects/2026-08-01-classic-rmt-open-fault.md` +/// records a fault in the WS281x open path that resets the board in well under +/// a millisecond, mid-`println!`, with no watchdog involved. A panic handler +/// there cannot assume it will live long enough to reach +/// [`finalize_crash_and_reset`]. +/// +/// So: on the **abort tier only**, where entering the panic handler already +/// means the boot is over and a reset is certain, commit as the very first act +/// and print afterwards. Do not call this from an unwinding-tier handler — it +/// would commit crashes that layer-1 goes on to recover, and every one of them +/// would be reported as a reboot cause that never happened. +pub fn commit_staged_crash() -> bool { + with_global(|r| r.commit_staged_crash()).is_some() +} + /// Void a staged (tentative) crash after layer-1 recovery caught the panic. pub fn clear_tentative_crash() { with_global(|r| r.clear_tentative_crash()); @@ -476,6 +515,52 @@ mod tests { assert!(assessment.prior_boot_complete); } + /// The classic-ESP32 panic path's contract: a record committed *before* the + /// handler prints survives a reset that never reaches + /// `finalize_crash_and_reset` and is not a watchdog. + /// + /// This is the whole reason the classic's WS281x fault can be named at all — + /// it dies mid-`println!` after ~5 characters, so anything that depends on + /// the handler running to completion is lost. Contrast + /// [`stale_tentative_crash_is_cleared_on_normal_boot`]: without the commit, + /// this exact sequence reports nothing. + #[test] + fn committed_crash_survives_a_reset_the_handler_never_completed() { + let (mut recovery, _) = boot_fresh(); + recovery.mark_boot_complete(); + let _f = recovery + .enter_frame(FrameKind::NodeRender, "nodes/out") + .unwrap(); + recovery.stage_crash( + CrashCause::Panic, + &"died opening rmt", + Some(("rmt.rs", 553)), + &[0x400d_1234], + None, + ); + recovery.commit_staged_crash(); + // The handler dies here — no finalize, no reset request. The chip + // resets through some other path and reports it as a plain software + // reset (NOT a watchdog). + let (_recovery, assessment) = InMemoryBackend::reboot(recovery, ResetCause::SoftwareReset); + + let crash = assessment.prior_crash.expect("crash reported"); + assert_eq!(crash.cause, CrashCause::Panic); + assert_eq!(crash.msg.as_str(), "died opening rmt (at rmt.rs:553)"); + assert_eq!(crash.boots_ago, 1); + } + + #[test] + fn committing_nothing_stages_nothing() { + let (mut recovery, _) = boot_fresh(); + recovery.mark_boot_complete(); + // No staged record: commit must not synthesize one the way + // `finalize_crash_and_reset` deliberately does. + recovery.commit_staged_crash(); + let (_recovery, assessment) = InMemoryBackend::reboot(recovery, ResetCause::SoftwareReset); + assert!(assessment.prior_crash.is_none()); + } + #[test] fn finalize_asks_the_backend_to_reset() { let (mut recovery, _) = boot_fresh(); diff --git a/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs b/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs index ffaaa1be5..5607cd2e0 100644 --- a/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs +++ b/lp-cli/src/commands/hardware/manifest/board_manifest_commands.rs @@ -281,6 +281,7 @@ fn non_empty(value: String) -> Option { )] fn target_matches_calibration(target: HardwareTarget, calibration_target: &str) -> Result<()> { let expected = match calibration_target { + "esp32" => HardwareTarget::Esp32, "esp32c6" => HardwareTarget::Esp32c6, "esp32s3" => HardwareTarget::Esp32s3, "rv32imac_emu" => HardwareTarget::Rv32imacEmu, diff --git a/lp-core/lpc-hardware/boards/domraem/dom-z-102.json b/lp-core/lpc-hardware/boards/domraem/dom-z-102.json new file mode 100644 index 000000000..afac1437d --- /dev/null +++ b/lp-core/lpc-hardware/boards/domraem/dom-z-102.json @@ -0,0 +1,155 @@ +{ + "id": "domraem/dom-z-102", + "target": "esp32", + "vendor": "domraem", + "product": "DOM-Z-102", + "description": "Domraem DOM-Z-102 classic-ESP32 (LX6, rev v3.x, 4 MB flash) 4-output WS281x controller \u2014 the classic bring-up roadmap's desk board and WLED-class deployment exemplar. Data terminals are silkscreened IO18/IO16/IO14/IO2 (top to bottom of the DATA group), verified against a photo of the desk unit; GPIO0=OPT and GPIO1/3=UART are standard-wiring inference, though the UART link itself is hardware-verified through the onboard CH340K bridge (0x1A86:0x7522). An IO12 screw terminal at the top of the right rail drives the onboard relay (product note: \"Relay configuration (to GPIO12)\"; verified against Yona's sketch of the physical board, correcting an earlier IO13 photo misread). It is deliberately omitted here: GPIO12 is the classic ESP32's MTDI strap pin, which selects flash voltage at reset, so driving it has boot implications that need hardware verification before this manifest offers it. The display sidecar draws it non-claimable. GPIO capabilities are listed only for pins the board actually exposes. macOS needs the WCH VCP driver before the CH340K serial port appears at all; see the fw-esp32v3 README.", + "board_label": [ + { + "label": "IO18", + "gpio": "/gpio/18", + "status": "assigned", + "note": "data channel 1 (top of the DATA group)" + }, + { + "label": "IO16", + "gpio": "/gpio/16", + "status": "assigned", + "note": "data channel 2" + }, + { + "label": "IO14", + "gpio": "/gpio/14", + "status": "assigned", + "note": "data channel 3" + }, + { + "label": "IO2", + "gpio": "/gpio/2", + "status": "assigned", + "note": "data channel 4 (bottom); classic-ESP32 strapping pin (boot mode); must be low/floating at reset, which a WS281x data line satisfies \u2014 fine as an output once booted" + } + ], + "gpio": [ + { + "address": "/gpio/0", + "display_label": "GPIO0", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO0" + ], + "reserved_reason": "BOOT strapping pin, wired to the board's BOOT button (silkscreen BOOT/RESET, stacked); not exposed as an output terminal" + }, + { + "address": "/gpio/1", + "display_label": "GPIO1", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO1", + "U0TXD" + ], + "reserved_reason": "UART0 TX into the CH340K bridge \u2014 the host serial link; driving it drops the link" + }, + { + "address": "/gpio/2", + "display_label": "IO2", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO2", + "GPIO2" + ] + }, + { + "address": "/gpio/3", + "display_label": "GPIO3", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO3", + "U0RXD" + ], + "reserved_reason": "UART0 RX from the CH340K bridge \u2014 the host serial link; driving it drops the link" + }, + { + "address": "/gpio/14", + "display_label": "IO14", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO14", + "GPIO14" + ] + }, + { + "address": "/gpio/16", + "display_label": "IO16", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO16", + "GPIO16" + ] + }, + { + "address": "/gpio/18", + "display_label": "IO18", + "capabilities": [ + "gpio-output", + "gpio-input" + ], + "aliases": [ + "IO18", + "GPIO18" + ] + } + ], + "resource": [ + { + "address": "/rmt/ws281x0", + "display_label": "RMT WS281x 0", + "capabilities": [ + "rmt", + "ws281x-output" + ] + }, + { + "address": "/rmt/ws281x1", + "display_label": "RMT WS281x 1", + "capabilities": [ + "rmt", + "ws281x-output" + ] + }, + { + "address": "/rmt/ws281x2", + "display_label": "RMT WS281x 2", + "capabilities": [ + "rmt", + "ws281x-output" + ] + }, + { + "address": "/rmt/ws281x3", + "display_label": "RMT WS281x 3", + "capabilities": [ + "rmt", + "ws281x-output" + ] + } + ] +} \ No newline at end of file diff --git a/lp-core/lpc-hardware/src/lib.rs b/lp-core/lpc-hardware/src/lib.rs index 00d74f1bf..e604a7960 100644 --- a/lp-core/lpc-hardware/src/lib.rs +++ b/lp-core/lpc-hardware/src/lib.rs @@ -66,7 +66,7 @@ pub use hw_system::HardwareSystem; pub use lpc_model::HwEndpointSpec; pub use manifest::default_manifests::{ default_esp32c6_hardware_manifest, default_esp32s3_hardware_manifest, - permissive_emu_hardware_manifest, + default_esp32v3_hardware_manifest, permissive_emu_hardware_manifest, }; pub use manifest::hw_manifest::HwManifest; pub use manifest::hw_manifest_file::{ diff --git a/lp-core/lpc-hardware/src/manifest/default_manifests.rs b/lp-core/lpc-hardware/src/manifest/default_manifests.rs index aedd650a2..6606e317f 100644 --- a/lp-core/lpc-hardware/src/manifest/default_manifests.rs +++ b/lp-core/lpc-hardware/src/manifest/default_manifests.rs @@ -7,6 +7,7 @@ use crate::{ const XIAO_ESP32_C6_JSON: &str = include_str!("../../boards/seeed/xiao-esp32-c6.json"); const XIAO_ESP32_S3_PLUS_JSON: &str = include_str!("../../boards/seeed/xiao-esp32-s3-plus.json"); +const DOM_Z_102_JSON: &str = include_str!("../../boards/domraem/dom-z-102.json"); pub fn default_esp32c6_hardware_manifest() -> HwManifest { HardwareManifestFile::read_json(XIAO_ESP32_C6_JSON) @@ -42,6 +43,22 @@ pub fn default_esp32s3_hardware_manifest() -> HwManifest { .expect("checked-in seeed/xiao-esp32-s3-plus hardware manifest must parse") } +/// Compiled-in board profile for `fw-esp32v3` (classic ESP32, LX6). +/// +/// The DOM-Z-102 — the classic bring-up roadmap's desk board and its +/// WLED-class deployment exemplar — rather than a bare devkit: the roadmap's +/// Q5-as-amended decision (see the plan's notes.md, 2026-07-31). Data +/// channels LED1-4 = GPIO 18/16/14/2, matching the board silkscreen and the +/// display sidecar authored from a photo of the desk unit. Like the S3 +/// profile above, deliberately incomplete: only pins the board exposes are +/// listed, and UART0's GPIO1/3 are reserved because they ARE the host link +/// (CH340K bridge — no separate USB-Serial-JTAG on this chip). +pub fn default_esp32v3_hardware_manifest() -> HwManifest { + HardwareManifestFile::read_json(DOM_Z_102_JSON) + .and_then(|manifest| manifest.to_manifest()) + .expect("checked-in domraem/dom-z-102 hardware manifest must parse") +} + /// Emulator manifest: XIAO ESP32-C6 pin map, board D-labels for endpoint specs, /// and no reserved GPIOs so projects like fyeah-sign load without hardware errors. pub fn permissive_emu_hardware_manifest() -> HwManifest { @@ -138,6 +155,41 @@ mod tests { } } + #[test] + fn default_esp32v3_manifest_loads_checked_in_board_profile() { + let manifest = default_esp32v3_hardware_manifest(); + + assert_eq!(manifest.board_id(), "domraem/dom-z-102"); + assert_eq!(manifest.target(), Some(HardwareTarget::Esp32)); + // The four silkscreened data channels, LED1-4. + for pin in [18, 16, 14, 2] { + assert!( + manifest.resource(&HwAddress::gpio(pin)).is_some(), + "gpio{pin} (a LED data channel) must be declared" + ); + } + // Four RMT resources — the count the classic ws281x backend offers + // (ADR 2026-07-31-lp-ws281x-multi-channel-driver-adoption: channel + // count comes from here, never from driver logic). + for channel in 0..4 { + assert!( + manifest.resource(&HwAddress::rmt_ws281x(channel)).is_some(), + "/rmt/ws281x{channel} must be declared" + ); + } + // GPIO1/3 are UART0 through the CH340K bridge — the host link — and + // GPIO0 is the OPT/boot button; all reserved so no driver claims them. + for pin in [0, 1, 3] { + assert!( + manifest + .resource(&HwAddress::gpio(pin)) + .and_then(|resource| resource.reserved_reason()) + .is_some(), + "gpio{pin} must be reserved" + ); + } + } + /// The omissions are the point of this profile, so they are asserted rather /// than left to a comment: each pin here is one somebody could plausibly /// guess into the manifest, and guessing wrong shorts a pin. diff --git a/lp-core/lpc-hardware/src/manifest/hw_target.rs b/lp-core/lpc-hardware/src/manifest/hw_target.rs index 8a3d383c5..f3176facd 100644 --- a/lp-core/lpc-hardware/src/manifest/hw_target.rs +++ b/lp-core/lpc-hardware/src/manifest/hw_target.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum HardwareTarget { + /// Classic ESP32 (Xtensa LX6) — `fw-esp32v3`. + Esp32, Esp32c6, Esp32s3, Rv32imacEmu, @@ -13,6 +15,7 @@ pub enum HardwareTarget { impl HardwareTarget { pub fn as_str(self) -> &'static str { match self { + Self::Esp32 => "esp32", Self::Esp32c6 => "esp32c6", Self::Esp32s3 => "esp32s3", Self::Rv32imacEmu => "rv32imac_emu", diff --git a/lp-core/lpc-shared/Cargo.toml b/lp-core/lpc-shared/Cargo.toml index d7e929f13..7debfdd83 100644 --- a/lp-core/lpc-shared/Cargo.toml +++ b/lp-core/lpc-shared/Cargo.toml @@ -10,6 +10,17 @@ rust-version.workspace = true default = ["std"] std = ["lpc-hardware/std", "lpfs/std"] +# Calibrate the Xtensa stack walker for the **classic** ESP32 (LX6) instead of +# the ESP32-S3 (LX7). See the `xt_map` modules in `src/backtrace.rs`: the walk +# itself is identical on both cores, but the IRAM / flash-cache / DRAM windows a +# candidate frame is bounds-checked against are not. +# +# ⚠️ Getting this wrong is silent. The walker does not fail — it rejects every +# frame and reports zero, which the firmware's crash report renders as "the +# stack was unreadable". Only `fw-esp32v3` should enable it, and only ever +# alongside the `esp32` chip feature. +xt-map-esp32-classic = [] + [dependencies] lp-collection = { workspace = true, features = ["serde"] } libm = "0.2" diff --git a/lp-core/lpc-shared/src/backtrace.rs b/lp-core/lpc-shared/src/backtrace.rs index bd4560045..ce95d4184 100644 --- a/lp-core/lpc-shared/src/backtrace.rs +++ b/lp-core/lpc-shared/src/backtrace.rs @@ -284,28 +284,114 @@ fn capture_frames_arch(_buf: &mut [u32]) -> usize { // (`lp-xt/lp-xt-emu/src/executor/window.rs`, `save_slot`), which was dual-run // against S3 silicon to depth 100 in the Xtensa backport spike. -/// Internal SRAM as the D-bus sees it — SRAM1 `0x3FC8_8000..0x3FCF_0000` plus -/// SRAM2 `0x3FCF_0000..0x3FD0_0000` (ESP32-S3 TRM, "System and Memory"). Task -/// stacks live here. PSRAM (`0x3C00_0000..`) is deliberately **not** accepted: -/// nothing in this firmware puts a stack there, and widening the window only -/// buys a larger space in which garbage can look valid. -#[cfg(any(target_arch = "xtensa", test))] -const S3_DRAM_START: u32 = 0x3FC8_8000; -#[cfg(any(target_arch = "xtensa", test))] -const S3_DRAM_END: u32 = 0x3FD0_0000; - -/// Internal SRAM as the I-bus sees it: SRAM0 `0x4037_0000..0x4038_0000` and -/// SRAM1 `0x4038_0000..0x403E_0000`. -#[cfg(any(target_arch = "xtensa", test))] -const S3_IRAM_START: u32 = 0x4037_0000; +// --------------------------------------------------------------------------- +// Chip memory windows +// --------------------------------------------------------------------------- +// +// Everything above this point — the window spill, the base-save-area chain, +// the region-bit restore — is pure windowed-ABI and byte-identical on LX6 and +// LX7. The only thing that differs between an ESP32-S3 and a classic ESP32 is +// *where* the six window boundaries sit. +// +// They are picked by a cargo feature rather than sniffed at runtime, because a +// firmware image targets exactly one chip and the wrong window set does not +// fail loudly. It reports **zero frames** — which `panic_path::print_frames` +// then renders as "the walk ran and rejected everything", i.e. "the stack was +// unreadable". That sentence would be confidently wrong; the truth would be +// "this walker was calibrated for a different chip". A silent miscalibration +// that reads as a real forensic result is worse than no walker at all, which +// is why the choice is explicit at the dependency line. +// +// `xt-map-esp32-classic` selects the classic ESP32 (LX6); the default is the +// ESP32-S3 (LX7). A build that enables neither and runs on classic silicon is +// the failure this comment exists to prevent. +// +// Both sets are *compiled* on every Xtensa and host build and only the alias +// below is cfg'd, so the host test suite bounds-checks the classic constants +// even though no host build ever selects them. Constants nothing can execute +// are exactly the kind that rot. + +// `xt_map` is the window set this build's walker is calibrated for. Plain +// comments rather than doc comments on purpose: rustfmt sorts these two +// imports alphabetically, so a `///` here would drift onto whichever arm +// happens to sort first. +#[cfg(all(any(target_arch = "xtensa", test), feature = "xt-map-esp32-classic"))] +use esp32_classic_map as xt_map; +#[cfg(all( + any(target_arch = "xtensa", test), + not(feature = "xt-map-esp32-classic") +))] +use esp32s3_map as xt_map; + +/// ESP32-S3 (LX7) windows — the default. #[cfg(any(target_arch = "xtensa", test))] -const S3_IRAM_END: u32 = 0x403E_0000; +#[cfg_attr(feature = "xt-map-esp32-classic", allow(dead_code))] +mod esp32s3_map { + /// Internal SRAM as the D-bus sees it — SRAM1 `0x3FC8_8000..0x3FCF_0000` + /// plus SRAM2 `0x3FCF_0000..0x3FD0_0000` (ESP32-S3 TRM, "System and + /// Memory"). Task stacks live here. PSRAM (`0x3C00_0000..`) is deliberately + /// **not** accepted: nothing in this firmware puts a stack there, and + /// widening the window only buys a larger space in which garbage can look + /// valid. + pub const DRAM_START: u32 = 0x3FC8_8000; + pub const DRAM_END: u32 = 0x3FD0_0000; + + /// Internal SRAM as the I-bus sees it: SRAM0 `0x4037_0000..0x4038_0000` and + /// SRAM1 `0x4038_0000..0x403E_0000`. + pub const IRAM_START: u32 = 0x4037_0000; + pub const IRAM_END: u32 = 0x403E_0000; + + /// External flash through the instruction cache: `0x4200_0000..0x4400_0000`. + pub const FLASH_START: u32 = 0x4200_0000; + pub const FLASH_END: u32 = 0x4400_0000; +} -/// External flash through the instruction cache: `0x4200_0000..0x4400_0000`. -#[cfg(any(target_arch = "xtensa", test))] -const S3_FLASH_START: u32 = 0x4200_0000; +/// Classic ESP32 (LX6) windows — `fw-esp32v3`. +/// +/// From the ESP32 TRM §1.3.2, "Embedded Memory" address mapping. Every bound +/// here is architectural, matching how the S3 set above is derived: these are +/// the ranges the *silicon* can execute or stack in, not the ranges this +/// firmware's linker segments happen to occupy. A tighter window would reject +/// real frames the moment a segment moved. #[cfg(any(target_arch = "xtensa", test))] -const S3_FLASH_END: u32 = 0x4400_0000; +#[cfg_attr(not(feature = "xt-map-esp32-classic"), allow(dead_code))] +mod esp32_classic_map { + /// Internal SRAM as the D-bus sees it — SRAM2 `0x3FFA_E000..0x3FFE_0000` + /// (200 KB) plus SRAM1 `0x3FFE_0000..0x4000_0000` (128 KB). esp-hal's + /// `dram_seg` (`0x3FFB_0000..0x3FFE_0000`) sits inside it, and so does the + /// `dram2_seg` the JIT's code region overlaps. + /// + /// RTC fast RAM's D-bus alias (`0x3FF8_0000`, 8 KB — where the recovery + /// ledger itself lives) is **not** included: no stack is ever placed there, + /// and the walker must not accept a save area inside the very region it is + /// about to write a crash record into. + pub const DRAM_START: u32 = 0x3FFA_E000; + pub const DRAM_END: u32 = 0x4000_0000; + + /// Internal SRAM as the I-bus sees it: SRAM0 `0x4008_0000..0x400A_0000` + /// (128 KB) and SRAM1 `0x400A_0000..0x400C_0000` (128 KB). + /// + /// Both halves matter on this chip. SRAM1's I-bus alias is where + /// `lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT` installs JIT'd + /// shader code (`0x400A_1000..0x400B_8000`), so a frame that faulted inside + /// a compiled shader lands in this window and is reported rather than + /// silently dropped. + /// + /// Internal ROM (`0x4000_0000..0x4008_0000`, 512 KB across ROM0 and ROM1) + /// is excluded for the same reason as on the S3, and the exclusion is + /// load-bearing in the same way: a zeroed `a0` restores to `0x4000_0000`, + /// and accepting ROM would turn the chain terminator into a plausible + /// frame. RTC fast RAM's I-bus alias (`0x400C_0000`, 8 KB) is excluded too + /// — nothing here executes from it. + pub const IRAM_START: u32 = 0x4008_0000; + pub const IRAM_END: u32 = 0x400C_0000; + + /// External flash through the instruction cache: `0x400D_0000..0x40C0_0000` + /// (11 MB of IROM). This is where `.text` actually lives on this image, so + /// it is the window most frames fall in. + pub const FLASH_START: u32 = 0x400D_0000; + pub const FLASH_END: u32 = 0x40C0_0000; +} /// Bytes in a `CALLn`/`CALLXn` instruction. The saved return address points at /// the instruction *after* the call, so reporting `ra - 3` makes `addr2line` @@ -314,16 +400,17 @@ const S3_FLASH_END: u32 = 0x4400_0000; #[cfg(any(target_arch = "xtensa", test))] const XT_CALL_INST_BYTES: u32 = 3; -/// Is `pc` inside a range the S3 can execute LightPlayer code from? +/// Is `pc` inside a range this chip can execute LightPlayer code from? /// -/// Internal ROM (`0x4000_0000..0x4008_0000`) is executable but deliberately -/// **excluded**: no frame in this firmware returns into ROM, and accepting it -/// would both widen the garbage-looks-valid window by 512 KB and make a zeroed -/// `a0` (which the region fix-up turns into `0x4000_0000`) look like a real -/// frame instead of the chain terminator it is. +/// Internal ROM is executable on both chips but deliberately **excluded** from +/// both [`xt_map`] sets: no frame in this firmware returns into ROM, and +/// accepting it would both widen the garbage-looks-valid window and make a +/// zeroed `a0` (which the region fix-up turns into `0x4000_0000`) look like a +/// real frame instead of the chain terminator it is. #[cfg(any(target_arch = "xtensa", test))] -fn is_valid_esp32s3_text(pc: u32) -> bool { - (S3_IRAM_START..S3_IRAM_END).contains(&pc) || (S3_FLASH_START..S3_FLASH_END).contains(&pc) +fn is_valid_xt_text(pc: u32) -> bool { + (xt_map::IRAM_START..xt_map::IRAM_END).contains(&pc) + || (xt_map::FLASH_START..xt_map::FLASH_END).contains(&pc) } /// Is `sp` a stack pointer we are willing to read a base save area from? @@ -332,15 +419,15 @@ fn is_valid_esp32s3_text(pc: u32) -> bool { /// room below for `[sp-16, sp)`, so a caller that passes this check may read /// the save area unconditionally. #[cfg(any(target_arch = "xtensa", test))] -fn is_valid_esp32s3_stack(sp: u32) -> bool { - sp % 16 == 0 && (S3_DRAM_START + 16..S3_DRAM_END).contains(&sp) +fn is_valid_xt_stack(sp: u32) -> bool { + sp % 16 == 0 && (xt_map::DRAM_START + 16..xt_map::DRAM_END).contains(&sp) } /// Walk the windowed-ABI base-save-area chain, starting from a frame whose /// return address is `ra` and whose stack pointer is `sp`. /// /// `read_u32` reads a 4-byte-aligned word; it is only ever called for addresses -/// that already passed [`is_valid_esp32s3_stack`], so implementations may read +/// that already passed [`is_valid_xt_stack`], so implementations may read /// raw memory without further checks. Splitting it out is what lets the host /// test suite drive this against a synthetic stack. /// @@ -353,18 +440,19 @@ fn walk_save_area_chain(buf: &mut [u32], ra: u32, sp: u32, read_u32: impl Fn(u32 let mut count = 0; while count < buf.len() { - // Restore the region bits `CALLn` did not store. Everything the S3 - // executes — internal SRAM's I-bus alias and the flash cache window — - // lives in region 1, so this is a constant. A wrong guess cannot leak - // through: the result is bounds-checked immediately below. + // Restore the region bits `CALLn` did not store. Everything either + // chip executes — internal SRAM's I-bus alias and the flash cache + // window — lives in region 1 (`0x4xxx_xxxx`) on both the S3 and the + // classic, so this is a constant, not a per-chip fact. A wrong guess + // cannot leak through: the result is bounds-checked immediately below. let pc = (ra & 0x3FFF_FFFF) | 0x4000_0000; - if !is_valid_esp32s3_text(pc) { + if !is_valid_xt_text(pc) { break; } buf[count] = pc.saturating_sub(XT_CALL_INST_BYTES); count += 1; - if !is_valid_esp32s3_stack(sp) { + if !is_valid_xt_stack(sp) { break; } let next_ra = read_u32(sp - 16); @@ -375,7 +463,7 @@ fn walk_save_area_chain(buf: &mut [u32], ra: u32, sp: u32, read_u32: impl Fn(u32 // is used: a save area whose stack pointer is garbage is not a save // area, and reporting the return address next to it would be exactly // the plausible-looking-garbage failure this walk exists to avoid. - if next_sp <= sp || !is_valid_esp32s3_stack(next_sp) { + if next_sp <= sp || !is_valid_xt_stack(next_sp) { break; } ra = next_ra; @@ -395,7 +483,7 @@ fn walk_save_area_chain(buf: &mut [u32], ra: u32, sp: u32, read_u32: impl Fn(u32 #[cfg(target_arch = "xtensa")] pub fn walk_frames_from(buf: &mut [u32], ra: u32, sp: u32) -> usize { // SAFETY: `walk_save_area_chain` only calls this for addresses that passed - // `is_valid_esp32s3_stack`, i.e. 16-aligned words inside internal SRAM. + // `is_valid_xt_stack`, i.e. 16-aligned words inside internal SRAM. walk_save_area_chain(buf, ra, sp, |addr| unsafe { (addr as *const u32).read_volatile() }) @@ -403,7 +491,10 @@ pub fn walk_frames_from(buf: &mut [u32], ra: u32, sp: u32) -> usize { /// How many nested windowed calls guarantee every live window has spilled. /// -/// The S3 (LX7) has a 64-entry physical register file = 16 `WindowBase` units. +/// Both supported chips have a 64-entry physical register file = 16 +/// `WindowBase` units (LX7 on the S3, LX6 on the classic — the register file +/// is one of the things the two cores do *not* differ on, which is why this +/// depth is not in [`xt_map`]). /// Each nested call advances `WindowBase` by its call increment — 1 for /// `call4`, 2 for `call8`, 3 for `call12` — and `ENTRY` spills whichever live /// frame still owns the units the new frame claims. Sixteen nested calls @@ -456,7 +547,8 @@ fn force_window_spill() { let _ = core::hint::black_box(spill_step(WINDOW_SPILL_DEPTH)); } -/// Xtensa (ESP32-S3 / LX7) windowed-ABI walk. +/// Xtensa windowed-ABI walk (LX7 on the ESP32-S3, LX6 on the classic ESP32 — +/// the walk is identical; only [`xt_map`]'s bounds differ). /// /// Spills the register windows, then follows the base save-area chain from /// this frame's live `a0`/`a1`. Frame 0 is the return address into *this* @@ -647,7 +739,7 @@ mod tests { for frame in &buf[..count] { assert!( - is_valid_esp32s3_text(*frame), + is_valid_xt_text(*frame), "reported {frame:#010x}, outside IRAM and the flash cache window", ); } @@ -711,8 +803,8 @@ mod tests { // The seed return address is still reportable; nothing is read. assert_eq!(walk(&stack, RA_INNER, 0x2000_0000, &mut buf), 1); - assert_eq!(walk(&stack, RA_INNER, S3_DRAM_END, &mut buf), 1); - assert_eq!(walk(&stack, RA_INNER, S3_DRAM_START, &mut buf), 1); + assert_eq!(walk(&stack, RA_INNER, xt_map::DRAM_END, &mut buf), 1); + assert_eq!(walk(&stack, RA_INNER, xt_map::DRAM_START, &mut buf), 1); } #[test] @@ -751,33 +843,105 @@ mod tests { #[test] fn xtensa_text_window_excludes_rom_dram_and_psram() { - assert!(is_valid_esp32s3_text(S3_IRAM_START)); - assert!(is_valid_esp32s3_text(S3_IRAM_END - 4)); - assert!(is_valid_esp32s3_text(S3_FLASH_START)); - assert!(is_valid_esp32s3_text(S3_FLASH_END - 4)); + assert!(is_valid_xt_text(xt_map::IRAM_START)); + assert!(is_valid_xt_text(xt_map::IRAM_END - 4)); + assert!(is_valid_xt_text(xt_map::FLASH_START)); + assert!(is_valid_xt_text(xt_map::FLASH_END - 4)); - assert!(!is_valid_esp32s3_text(0x4000_0000), "internal ROM"); - assert!(!is_valid_esp32s3_text(S3_IRAM_START - 4)); - assert!(!is_valid_esp32s3_text(S3_IRAM_END)); - assert!(!is_valid_esp32s3_text(S3_FLASH_END)); - assert!(!is_valid_esp32s3_text(0x3FCC_0000), "DRAM"); - assert!(!is_valid_esp32s3_text(0x3C00_0000), "PSRAM"); + assert!(!is_valid_xt_text(0x4000_0000), "internal ROM"); + assert!(!is_valid_xt_text(xt_map::IRAM_START - 4)); + assert!(!is_valid_xt_text(xt_map::IRAM_END)); + assert!(!is_valid_xt_text(xt_map::FLASH_END)); + assert!(!is_valid_xt_text(0x3FCC_0000), "DRAM"); + assert!(!is_valid_xt_text(0x3C00_0000), "PSRAM"); } #[test] fn xtensa_stack_window_requires_alignment_and_headroom() { - assert!(is_valid_esp32s3_stack(S3_DRAM_START + 16)); - assert!(is_valid_esp32s3_stack(S3_DRAM_END - 16)); + assert!(is_valid_xt_stack(xt_map::DRAM_START + 16)); + assert!(is_valid_xt_stack(xt_map::DRAM_END - 16)); assert!( - !is_valid_esp32s3_stack(S3_DRAM_START), + !is_valid_xt_stack(xt_map::DRAM_START), "no room for [sp-16, sp)" ); - assert!(!is_valid_esp32s3_stack(S3_DRAM_END)); + assert!(!is_valid_xt_stack(xt_map::DRAM_END)); assert!( - !is_valid_esp32s3_stack(S3_DRAM_START + 20), + !is_valid_xt_stack(xt_map::DRAM_START + 20), "not 16-aligned" ); - assert!(!is_valid_esp32s3_stack(0)); + assert!(!is_valid_xt_stack(0)); + } + + /// Every window is non-empty and correctly ordered. + #[test] + fn both_chip_maps_are_ordered() { + for (name, start, end) in [ + ("s3 dram", esp32s3_map::DRAM_START, esp32s3_map::DRAM_END), + ("s3 iram", esp32s3_map::IRAM_START, esp32s3_map::IRAM_END), + ("s3 flash", esp32s3_map::FLASH_START, esp32s3_map::FLASH_END), + ( + "classic dram", + esp32_classic_map::DRAM_START, + esp32_classic_map::DRAM_END, + ), + ( + "classic iram", + esp32_classic_map::IRAM_START, + esp32_classic_map::IRAM_END, + ), + ( + "classic flash", + esp32_classic_map::FLASH_START, + esp32_classic_map::FLASH_END, + ), + ] { + assert!(start < end, "{name}: empty or inverted window"); + } + } + + /// Running the S3 map on classic silicon rejects **everything** — which is + /// the miscalibration that actually happens, and the reason + /// `xt-map-esp32-classic` exists. + /// + /// ⚠️ The converse is NOT true and must not be asserted: the classic's IROM + /// window is 11 MB (`0x400D_0000..0x40C0_0000`) and architecturally + /// **contains** the S3's IRAM (`0x4037_0000..0x403E_0000`). A build that ran + /// the classic map on an S3 would therefore accept some genuine S3 IRAM + /// addresses and report a partially-plausible walk. There is no window + /// arithmetic that removes that — the address spaces really do overlap — so + /// the protection against it is the feature being opt-in and named for one + /// chip, not a bound anyone should try to tighten here. + #[test] + fn the_s3_map_accepts_no_classic_text_address() { + for (name, addr) in [ + ("classic iram start", esp32_classic_map::IRAM_START), + ("classic iram end", esp32_classic_map::IRAM_END - 4), + ("classic irom start", esp32_classic_map::FLASH_START), + ("classic .text (~1.7 MB image)", 0x4020_0000), + ] { + assert!( + !(esp32s3_map::IRAM_START..esp32s3_map::IRAM_END).contains(&addr) + && !(esp32s3_map::FLASH_START..esp32s3_map::FLASH_END).contains(&addr), + "{name} ({addr:#010x}) would pass the S3 walker's bounds" + ); + } + } + + /// The classic's I-bus window must contain the JIT's code region. + /// + /// `lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT` installs compiled + /// shader code at I-bus `0x400A_1000..0x400B_8000`. A shader that faults is + /// one of the likelier things to want a backtrace for on this chip, and it + /// is the one address range that is *not* a linker-placed section — so if + /// the JIT region ever moves out from under this window, the walk would drop + /// exactly the frames that matter without saying anything. + #[test] + fn the_classic_iram_window_covers_the_jit_code_region() { + const JIT_IBUS_START: u32 = 0x400A_1000; + const JIT_IBUS_END: u32 = 0x400B_8000; + + assert!(esp32_classic_map::IRAM_START <= JIT_IBUS_START); + assert!(JIT_IBUS_END <= esp32_classic_map::IRAM_END); } } diff --git a/lp-core/lpc-shared/src/display_pipeline/lut.rs b/lp-core/lpc-shared/src/display_pipeline/lut.rs deleted file mode 100644 index 0cffd2192..000000000 --- a/lp-core/lpc-shared/src/display_pipeline/lut.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Lookup table for output white-point correction - -/// Number of LUT entries (index 0..256) -pub const LUT_LEN: usize = 257; - -/// Build LUT for one channel -/// -/// Formula: lut[i] = clamp(round((i/256) * white_point * 0xFFFF), 0, 0xFFFF) -pub fn build_lut(lut: &mut [u32; LUT_LEN], white_point: f32) { - for i in 0..LUT_LEN { - let normal = (i as f32 / 256.0) * white_point; - let output = normal * 65535.0; - let rounded = (output + 0.5) as i64; - let clamped = rounded.clamp(0, 65535); - lut[i] = clamped as u32; - } -} - -/// Interpolate 16-bit value through LUT -/// -/// value is 16-bit (0..65535). index = value >> 8, alpha = value & 0xFF -/// Result: (lut[index] * (0x100 - alpha) + lut[index + 1] * alpha) >> 8 -pub fn lut_interpolate(value: u32, lut: &[u32; LUT_LEN]) -> u32 { - let value = value.min(65535); - let index = (value >> 8) as usize; - let alpha = value & 0xFF; - let inv_alpha = 0x100 - alpha; - (lut[index] * inv_alpha + lut[index.saturating_add(1).min(LUT_LEN - 1)] * alpha) >> 8 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_lut_identity() { - let mut lut = [0u32; LUT_LEN]; - build_lut(&mut lut, 1.0); - // Linear: output should roughly follow input - assert_eq!(lut[0], 0); - assert_eq!(lut[256], 65535); - } - - #[test] - fn lut_interpolate_zero() { - let mut lut = [0u32; LUT_LEN]; - build_lut(&mut lut, 1.0); - assert_eq!(lut_interpolate(0, &lut), 0); - } - - #[test] - fn lut_interpolate_max() { - let mut lut = [0u32; LUT_LEN]; - build_lut(&mut lut, 1.0); - assert!(lut_interpolate(0xFFFF, &lut) >= 65530); - } -} diff --git a/lp-core/lpc-shared/src/display_pipeline/mod.rs b/lp-core/lpc-shared/src/display_pipeline/mod.rs index b18183319..7af40859b 100644 --- a/lp-core/lpc-shared/src/display_pipeline/mod.rs +++ b/lp-core/lpc-shared/src/display_pipeline/mod.rs @@ -1,10 +1,9 @@ //! Triple-buffered display pipeline for LED output //! -//! Converts 16-bit RGB frames to 8-bit with optional white-point LUT, +//! Converts 16-bit RGB frames to 8-bit with optional white-point balancing, //! dithering, and frame interpolation. mod dither; -mod lut; mod options; mod pipeline; diff --git a/lp-core/lpc-shared/src/display_pipeline/options.rs b/lp-core/lpc-shared/src/display_pipeline/options.rs index a6c3e6e86..ba8e83d7b 100644 --- a/lp-core/lpc-shared/src/display_pipeline/options.rs +++ b/lp-core/lpc-shared/src/display_pipeline/options.rs @@ -13,7 +13,15 @@ pub struct DisplayPipelineOptions { pub interpolation_enabled: bool, /// Enable temporal dithering pub dithering_enabled: bool, - /// Enable white point LUT + /// Apply [`Self::white_point`] to each channel. + /// + /// ⚠️ The name is historical and the wire format is stuck with it. It once + /// selected a 257-entry lookup table; that table was measured to compute + /// nothing but `value * white_point` and was replaced by the multiply + /// (`docs/defects/2026-08-01-classic-rmt-open-fault.md`). What the flag has + /// always actually gated is whether the white point is applied **at all** — + /// `false` means channels pass through unbalanced, not "balance by a + /// cheaper method". Renaming it would break every project file on disk. pub lut_enabled: bool, } diff --git a/lp-core/lpc-shared/src/display_pipeline/pipeline.rs b/lp-core/lpc-shared/src/display_pipeline/pipeline.rs index 71372b702..cd0f1a619 100644 --- a/lp-core/lpc-shared/src/display_pipeline/pipeline.rs +++ b/lp-core/lpc-shared/src/display_pipeline/pipeline.rs @@ -2,7 +2,6 @@ use alloc::vec::Vec; -use crate::display_pipeline::lut::{LUT_LEN, build_lut, lut_interpolate}; use crate::display_pipeline::options::DisplayPipelineOptions; use crate::error::DisplayPipelineError; use core::cmp; @@ -32,10 +31,41 @@ pub struct DisplayPipeline { has_next: bool, prev_current_delta_us: u64, dither_overflow: Vec<[i8; 3]>, - lut: [[u32; LUT_LEN]; 3], + /// Per-channel white point as Q16.16, derived once from + /// [`DisplayPipelineOptions::white_point`] (which is immutable for the + /// life of the pipeline). See [`white_point_scale`]. + white_scale: [u32; 3], options: DisplayPipelineOptions, } +/// White point as a Q16.16 multiplier. +/// +/// Saturates rather than wrapping: a white point of `NaN` or a negative value +/// becomes 0 (channel off) instead of an enormous scale, and the `as u32` on a +/// huge float saturates rather than being UB. +fn white_point_scale(white_point: f32) -> u32 { + if !(white_point > 0.0) { + // Also catches NaN, since every NaN comparison is false. + return 0; + } + (white_point * 65536.0 + 0.5) as u32 +} + +/// Scale a 16-bit channel value by a Q16.16 white point, rounding to nearest. +/// +/// Widened to `u64` deliberately. A white point above 1.0 is not physically +/// meaningful — balancing scales channels *down* — but the previous LUT +/// implementation permitted it (building a boosted table and clamping at +/// 65535), so the arithmetic keeps that behaviour rather than quietly +/// redefining it. At `white_point == 1.0` the `u32` product would fit with +/// 32,767 to spare; anything above overflows, and one widening multiply is +/// cheaper than the three heap loads this replaced. +#[inline] +fn apply_white_point(value: u32, scale: u32) -> u32 { + let value = value.min(65535) as u64; + (((value * scale as u64 + 0x8000) >> 16) as u32).min(65535) +} + impl DisplayPipeline { /// Allocate pipeline pub fn new( @@ -54,10 +84,11 @@ impl DisplayPipeline { next.resize(size, 0); let mut dither_overflow = Vec::with_capacity(num_leds as usize); dither_overflow.resize(num_leds as usize, [0i8; 3]); - let mut lut = [[0u32; LUT_LEN]; 3]; - build_lut(&mut lut[0], options.white_point[0]); - build_lut(&mut lut[1], options.white_point[1]); - build_lut(&mut lut[2], options.white_point[2]); + let white_scale = [ + white_point_scale(options.white_point[0]), + white_point_scale(options.white_point[1]), + white_point_scale(options.white_point[2]), + ]; Ok(Self { num_leds, prev, @@ -71,7 +102,7 @@ impl DisplayPipeline { has_next: false, prev_current_delta_us: 1, dither_overflow, - lut, + white_scale, options, }) } @@ -179,7 +210,7 @@ impl DisplayPipeline { let r = self.current[i * 3] as u32; let g = self.current[i * 3 + 1] as u32; let b = self.current[i * 3 + 2] as u32; - let (or, og, ob) = self.apply_lut_dither(r, g, b, i); + let (or, og, ob) = self.apply_white_point_dither(r, g, b, i); out[i * 3] = or; out[i * 3 + 1] = og; out[i * 3 + 2] = ob; @@ -201,26 +232,26 @@ impl DisplayPipeline { let ir = ((pr * inv_progress16 as u32) + (cr * frame_progress16 as u32)) >> 16; let ig = ((pg * inv_progress16 as u32) + (cg * frame_progress16 as u32)) >> 16; let ib = ((pb * inv_progress16 as u32) + (cb * frame_progress16 as u32)) >> 16; - let (or, og, ob) = self.apply_lut_dither(ir, ig, ib, i); + let (or, og, ob) = self.apply_white_point_dither(ir, ig, ib, i); out[i * 3] = or; out[i * 3 + 1] = og; out[i * 3 + 2] = ob; } } - fn apply_lut_dither(&mut self, r: u32, g: u32, b: u32, pixel: usize) -> (u8, u8, u8) { + fn apply_white_point_dither(&mut self, r: u32, g: u32, b: u32, pixel: usize) -> (u8, u8, u8) { let ir = if self.options.lut_enabled { - lut_interpolate(r, &self.lut[0]) + apply_white_point(r, self.white_scale[0]) } else { r }; let ig = if self.options.lut_enabled { - lut_interpolate(g, &self.lut[1]) + apply_white_point(g, self.white_scale[1]) } else { g }; let ib = if self.options.lut_enabled { - lut_interpolate(b, &self.lut[2]) + apply_white_point(b, self.white_scale[2]) } else { b }; @@ -269,8 +300,88 @@ mod tests { assert!(out[0] > 0 || out[1] > 0 || out[2] > 0); } + /// The 257-entry white-point LUT this arithmetic replaced (deleted from + /// production in the same commit), kept here as a migration oracle. + /// + /// Its formula was `lut[i] = clamp(round((i/256) * white_point * 65535))` + /// — a straight line — read back by linear interpolation on the high byte. + /// Interpolating between samples of a line reproduces the line, so the + /// whole 3,084-byte-per-channel table computed `value * white_point`. + /// Keeping it executable rather than as a comment is what lets + /// [`white_point_matches_the_lut_it_replaced`] state the behaviour delta as + /// a measured number instead of a claim. + mod legacy_lut { + const LUT_LEN: usize = 257; + + pub fn build(white_point: f32) -> [u32; LUT_LEN] { + let mut lut = [0u32; LUT_LEN]; + for (i, slot) in lut.iter_mut().enumerate() { + let normal = (i as f32 / 256.0) * white_point; + let rounded = (normal * 65535.0 + 0.5) as i64; + *slot = rounded.clamp(0, 65535) as u32; + } + lut + } + + pub fn interpolate(value: u32, lut: &[u32; LUT_LEN]) -> u32 { + let value = value.min(65535); + let index = (value >> 8) as usize; + let alpha = value & 0xFF; + let inv_alpha = 0x100 - alpha; + (lut[index] * inv_alpha + lut[index.saturating_add(1).min(LUT_LEN - 1)] * alpha) >> 8 + } + } + + /// The multiply reproduces the table it replaced to within 2 counts of + /// 65,535, across every input, at every white point that matters. + /// + /// 2/65535 is a quarter of one ULP at the 8-bit wire the pipeline actually + /// drives, so no LED can resolve it. Where the two *do* diverge, the + /// multiply is the more correct of the pair: the table was a piecewise + /// approximation of a line, and this is the line. + #[test] + fn white_point_matches_the_lut_it_replaced() { + for white_point in [1.0f32, 0.9, 0.8, 0.75, 0.5, 0.25, 0.1, 0.0] { + let lut = legacy_lut::build(white_point); + let scale = white_point_scale(white_point); + let worst = (0u32..=65535) + .map(|v| apply_white_point(v, scale).abs_diff(legacy_lut::interpolate(v, &lut))) + .max() + .unwrap(); + assert!( + worst <= 2, + "white_point {white_point}: diverged by {worst} counts (max 2)" + ); + } + } + + /// A white point of 1.0 must be exactly the identity, not merely close. + /// + /// This is the overwhelmingly common case — every output node that does not + /// deliberately balance its channels — so a systematic off-by-one here + /// would dim every strip in the product by one 16-bit count forever. + #[test] + fn unit_white_point_is_the_identity() { + let scale = white_point_scale(1.0); + for v in 0u32..=65535 { + assert_eq!(apply_white_point(v, scale), v, "not identity at {v}"); + } + } + + /// Degenerate white points must not wrap into an enormous scale. + #[test] + fn degenerate_white_points_saturate() { + assert_eq!(white_point_scale(f32::NAN), 0); + assert_eq!(white_point_scale(-1.0), 0); + assert_eq!(white_point_scale(0.0), 0); + // Above 1.0 boosts and clamps, exactly as the LUT did. + let boosted = white_point_scale(2.0); + assert_eq!(apply_white_point(20_000, boosted), 40_000); + assert_eq!(apply_white_point(65535, boosted), 65535, "must clamp"); + } + #[test] - fn lut_preserves_linear_midpoint() { + fn unit_white_point_preserves_linear_midpoint() { let mut opts = DisplayPipelineOptions::default(); opts.white_point = [1.0, 1.0, 1.0]; opts.dithering_enabled = false; diff --git a/lp-fw/README.md b/lp-fw/README.md index 70ac42c48..32c39be52 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -12,7 +12,8 @@ they are not replacements for on-device shader compilation. |---|---|---| | [`fw-esp32c6`](./fw-esp32c6/) | ESP32-C6 bare metal (RISC-V) | Reference embedded firmware target. Runs `lp-server` on device, with every node kind and every driver. | | [`fw-esp32s3`](./fw-esp32s3/) | ESP32-S3 bare metal (Xtensa LX7) | Second chip. Runs `lp-server` on device, JITs GLSL to **Xtensa** machine code, and drives real WS281x strips on 4 concurrent RMT channels via `lp-ws281x`. Deliberately partial: shader + fixture nodes only. See its README for what is gated off and why. | -| [`fw-esp32-common`](./fw-esp32-common/) | chip-generic lib | Chip-generic firmware layer shared by the per-SOC ESP32 crates — both `fw-esp32c6` and `fw-esp32s3` consume it. Builds under both the pinned nightly and the Espressif fork; no esp-* HAL deps. | +| [`fw-esp32v3`](./fw-esp32v3/) | classic ESP32 bare metal (Xtensa LX6) | Third chip — the WLED-class deployment target (4 MB flash, C6-shaped partition table). Runs `lp-server` on device over **UART0** (no USB-Serial-JTAG on this chip), with the same shader + fixture node pair as the S3. On-device shader *execution* and WS281x output are still ahead of it on the classic bring-up roadmap. Desk board: DOM-Z-102. | +| [`fw-esp32-common`](./fw-esp32-common/) | chip-generic lib | Chip-generic firmware layer shared by the per-SOC ESP32 crates — `fw-esp32c6`, `fw-esp32s3` and `fw-esp32v3` all consume it. Builds under both the pinned nightly and the Espressif fork; no esp-* HAL deps. | | [`lp-ws281x`](./lp-ws281x/) | chip-agnostic `no_std` lib | Portable core of the multi-channel WS2811/WS2812 RMT driver — pulse encoding, ping-pong refill, guard-word flicker protection — behind the `RmtHw` trait a chip backend implements. Used by `fw-esp32s3` today; the C6 stays on its own legacy single-channel driver (see `docs/debt/`). | | [`fw-emu`](./fw-emu/) | RV32 bare-metal emulator | Firmware image used by emulator-oriented validation. | | [`fw-host`](./fw-host/) | Host OS | Local host runtime that can run an in-memory `LpServer` outside `lp-cli`. Useful for Studio, local services, and host deployments. | diff --git a/lp-fw/fw-esp32-common/src/output/provider.rs b/lp-fw/fw-esp32-common/src/output/provider.rs index 81e5ae590..7a93cc3d2 100644 --- a/lp-fw/fw-esp32-common/src/output/provider.rs +++ b/lp-fw/fw-esp32-common/src/output/provider.rs @@ -21,6 +21,27 @@ use lpc_shared::output::{OutputChannelHandle, OutputDriverOptions, OutputFormat, const FRAME_INTERVAL_US: u64 = 16_667; const MID_FRAME_US: u64 = 8_333; +/// Channels reserved up front in [`Esp32OutputProvider::channels`]. +/// +/// A **reservation, not a limit** — `VecMap` still grows past it. It exists so +/// that opening the Nth channel does not reallocate and memcpy the previous +/// N-1 `ChannelState`s while they are live, which is a transient peak of twice +/// the steady-state size on the one path that runs when the heap is already at +/// its high-water mark. +/// +/// 8 is the widest RMT TX slot count in the family (the classic's eight, before +/// `BLOCKS_PER_CHANNEL` halves the usable count). At today's `ChannelState` +/// size this reservation costs well under a kilobyte. +/// +/// ⚠️ Historical note worth keeping: this used to matter enormously. +/// `ChannelState` carried a 3,084-byte inline white-point LUT, so the same +/// growth asked for 12,864 contiguous bytes and OOM'd the classic ESP32 with +/// 11,228 free — `docs/defects/2026-08-01-classic-rmt-open-fault.md`. The LUT +/// is gone; this guard remains because the growth pattern is what turned a +/// tight heap into a hard fault, and it will do so again for whatever the next +/// large per-channel field is. +const RESERVED_CHANNELS: usize = 8; + struct ChannelState { output: Box, byte_count: u32, @@ -38,7 +59,7 @@ impl Esp32OutputProvider { pub fn new(hardware_system: Rc) -> Self { Self { hardware_system, - channels: RefCell::new(VecMap::new()), + channels: RefCell::new(VecMap::with_capacity(RESERVED_CHANNELS)), next_handle: RefCell::new(1), } } diff --git a/lp-fw/fw-esp32v3/.cargo/config.toml b/lp-fw/fw-esp32v3/.cargo/config.toml new file mode 100644 index 000000000..08b09335c --- /dev/null +++ b/lp-fw/fw-esp32v3/.cargo/config.toml @@ -0,0 +1,36 @@ +[build] +target = "xtensa-esp32-none-elf" + +[target.xtensa-esp32-none-elf] +# `--partition-table` and `--flash-size` are not optional, mirroring +# fw-esp32s3's runner comment: without `--partition-table`, espflash silently +# substitutes a default table whose factory partition is 1 MB; without +# `--flash-size`, espflash defaults the image header's flash-size field to +# 4 MB regardless of the physical chip; the bootloader validates the +# partition table against that header, not the chip. This board's table +# genuinely is the 4 MB shape (see `partitions.csv`), so `--flash-size 4mb` +# should match, but naming it explicitly still guards against a mis-flashed +# board reporting a confusing "partition invalid" boot-loop instead of espflash +# refusing up front. +runner = "espflash flash --chip esp32 --partition-table partitions.csv --flash-size 4mb --monitor --after hard-reset" +rustflags = [ + # The Xtensa target links through xtensa-esp32-elf-gcc (GNU ld), so linker + # scripts are passed with -Wl, same as the S3's xtensa-esp32s3-elf-gcc path. + "-C", "link-arg=-Wl,-Tlinkall.x", + "-C", "link-arg=-nostartfiles", + + # Abort-tier posture (ADR 2026-07-29-per-chip-fw-toolchains), mirroring + # fw-esp32s3: panic=abort, no unwinding, no __eh_frame machinery. P1 does + # not yet carry the lp-recovery RTC ledger fw-esp32s3 pairs this with (see + # src/main.rs) — the panic handler here is print-and-reset only — but the + # compiler-level posture is the same so a later phase can add the ledger + # without an ABI-level rewrite. + "-C", "panic=abort", +] + +[env] +ESP_LOG = "info" + +[unstable] +build-std = ["core", "alloc"] +build-std-features = ["compiler-builtins-mem", "optimize_for_size"] diff --git a/lp-fw/fw-esp32v3/Cargo.toml b/lp-fw/fw-esp32v3/Cargo.toml new file mode 100644 index 000000000..fb9ed6493 --- /dev/null +++ b/lp-fw/fw-esp32v3/Cargo.toml @@ -0,0 +1,260 @@ +# Repo-root workspace member, excluded from `default-members` — exactly the +# shape fw-esp32c6 and fw-esp32s3 take. +# +# M2-P1 stood this crate up as its OWN workspace because it then had zero +# lp2025-internal path dependencies, so nothing needed the root's +# `workspace.dependencies` or its `[patch.crates-io]` forks. M3-P1 makes it a +# real `fw-esp32-common` / `lpa-server` / `lpc-hardware` consumer, which is +# precisely the condition that comment named for revisiting the decision: +# path deps into the root workspace from outside it would resolve their own +# copies of every shared crate and silently ignore the root `[patch]` table. +# The cost is the one P1 wanted to defer — a `--exclude fw-esp32v3` entry in +# the justfile's `clippy-host` — and it is now paid (see `justfile`). +# +# The build still runs from this directory (`cd lp-fw/fw-esp32v3 && cargo +# build --profile release-esp32v3`), because `.cargo/config.toml` here is what +# selects the Xtensa target, the linker flags and the espflash runner; cargo +# reads `.cargo/config.toml` from the CWD upward, so invoking from the repo +# root would build for the host. The `[profile.release-esp32v3]` definition +# lives in the ROOT Cargo.toml next to `release-esp32s3` — profiles are only +# honoured on the workspace root manifest. + +[package] +name = "fw-esp32v3" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +build = "build.rs" +description = "LightPlayer firmware for the classic ESP32 (Xtensa LX6, WROOM-32E v3)." + +[[bin]] +name = "fw-esp32v3" +path = "src/main.rs" + +[lints] +workspace = true + +[features] +default = ["esp32", "server"] +# Chip selection, mirroring fw-esp32s3's `esp32s3` feature: crates whose chip +# feature is not named on their dependency line get it here, so there is one +# place to change. `esp-rtos?/esp32` is a weak dependency feature — it must not +# pull `esp-rtos` in on its own, because a bare `--no-default-features +# --features esp32` build has neither the server nor the probe. +esp32 = ["esp-backtrace/esp32", "esp-rtos?/esp32"] + +# The LightPlayer app: `LpServer` over the **UART0** transport (this chip has +# no USB-Serial-JTAG peripheral — see `src/serial/`), backed by a littlefs +# filesystem in the `lpfs` partition. +# +# Mirrors fw-esp32s3's feature of the same name, including its narrow +# `lpa-server` node-gate list. See the dependency section for why each entry is +# load-bearing. +server = [ + "fw-esp32-common/server", + "dep:embassy-executor", + "dep:embassy-futures", + "dep:embassy-sync", + "dep:embassy-time", + "dep:embedded-io-async", + "dep:embedded-storage", + "dep:esp-rtos", + "dep:esp-storage", + "dep:littlefs-rust", + "dep:lp-gfx-lpvm", + "dep:lpvm-native", + "dep:lpa-server", + "dep:lp-recovery", + "dep:lpc-hardware", + "dep:lpc-shared", + "dep:lpc-wire", + "dep:lpfs", + "dep:lp-ws281x", + "dep:fw-core", + "dep:ser-write-json", +] + +# Periodic WS281x telemetry over the serial link — OFF by default. +# +# Prints one `[WS281X]` line per configured channel every ~10 s from the +# frame-write path: frames/complete/trips/skips/errors, refills vs the refills +# an untruncated frame set would have needed, mean and max refill lag, and the +# 9-bucket lag histogram. Those are the numbers M4-P3's capacity sweep exists +# to collect (does 2 blocks/channel really carry four outputs? findings.md +# §12's arithmetic is marginal), and there was no app-path pattern to mirror — +# fw-esp32s3 reads the same counters only from its `test_loopback` harness. +# +# Off by default because the shipping image should not spend serial bandwidth +# or code space on it; the whole module is `cfg`'d out and the call site +# becomes an empty `#[inline(always)]` fn, so a release build pays nothing — +# not even the timer read. +# +# Build: +# cargo build --profile release-esp32v3 --features ws281x_telemetry +ws281x_telemetry = [] + +# M2-P3 RAM probe: boot with the radio stack linked AND initialised, print +# heap deltas at each stage. Build: +# cargo build --profile release-esp32v3 --no-default-features \ +# --features esp32,radio_ram_probe +# (touch src/main.rs first if flipping features — cargo does not always +# track feature-driven cfg reliably here; roadmap stale-binary lesson.) +# +# The probe REPLACES the entrypoint (`main.rs` gates the server boot on +# `not(feature = "radio_ram_probe")`), so `--no-default-features` is what keeps +# the probe image from also linking the whole server stack for code it never +# reaches. Enabling both still compiles — that is the anti-rot property the +# clippy recipe leans on — it is just a pointlessly large image. +radio_ram_probe = ["dep:esp-radio", "dep:esp-rtos", "esp-rtos/esp-radio", "esp-rtos/esp-alloc"] + +[dependencies] +# Pins below come from the experiment repo's `fw/xt-runner-esp32/Cargo.toml` +# (proven on classic ESP32 silicon 2026-07-28, FINDINGS.md C1 PASS) — the +# working classic-ESP32 dependency cohort under the `esp` toolchain, not a +# guess. `esp-hal` 1.1.1 (not fw-esp32s3's 1.1.0): fw-esp32s3 pins `"1.1.0"` +# with a caret range, and 1.1.1 is what actually resolves and is cached +# locally, so pin the version that is known to work with the `esp32` feature. +esp-hal = { version = "1.1.1", features = ["esp32", "log-04", "unstable"] } +esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32"] } +esp-alloc = { version = "0.10.0", features = ["internal-heap-stats"] } + +# UART variant, not `jtag-serial`: classic ESP32 has no USB-Serial-JTAG +# peripheral. `esp-println`'s `uart` feature only writes UART0's FIFO — it +# never programs the baud divisor itself (esp-rs/esp-hal; confirmed on this +# exact chip, experiment repo FINDINGS.md "C1"). After `esp_hal::init()` +# reclocks to `CpuClock::max()`, the ROM's divisor no longer matches, and +# output goes to garbage at every standard baud. The divisor is programmed by +# the `esp_hal::uart::Uart` this crate constructs on UART0 at boot — which as +# of M3-P1 is not a keep-alive hack but the actual server transport (see +# `src/serial/`). The S3 never hits this because USB-Serial-JTAG has no baud +# rate to program. +esp-println = { version = "0.17.0", default-features = false, features = ["esp32", "uart", "log-04"] } + +# Fault/exception handling. fw-esp32s3 depends on this with the same +# `["println"]` feature set and lets the chip feature arrive indirectly (see +# its `[features] esp32s3 = ["esp-backtrace/esp32s3", ...]`); mirrored here +# via the `esp32` default feature above rather than inline, for the same +# reason: naming the chip feature in both places would be easy to let drift. +# Not directly called from `src/main.rs` — it hooks the exception vector for +# hardware faults (illegal instruction, access violation) that never reach +# Rust's `#[panic_handler]`, which only catches explicit `panic!`. +esp-backtrace = { version = "0.19.0", features = ["println"] } + +log = { workspace = true, default-features = false } + +# The RTC-RAM crash ledger, pulled forward from M7 into M4 because it stopped +# being a nicety: `docs/defects/2026-08-01-classic-rmt-open-fault.md` records a +# fault in the WS281x open path that resets this chip after ~5 characters of +# panic output, so the serial channel cannot name it and the ledger is the only +# instrument that can. `src/recovery/` holds the classic-specific half (RTC fast +# RAM at 0x3FF8_0000, the classic `SocResetReason` variant set). +# +# Optional and gated behind `server` — unlike fw-esp32s3, which takes it +# unconditionally. The bare-hello and radio-probe entrypoints keep the plain +# print-and-reset panic handler: the probe image is M2-P3's *measured* code and +# linking a ledger it never installs would move the RAM numbers the M6 budget +# ADR is going to quote. +lp-recovery = { path = "../../lp-base/lp-recovery", default-features = false, optional = true } + +# Chip-generic firmware layer: logger, time provider, output provider, and — +# behind `server` — the server loop, transport, littlefs `LpFs`, project +# auto-load, and hardware-manifest loader. Consumed, never reimplemented; this +# crate must stay free of chip code (ADR 2026-07-29-per-chip-fw-toolchains). +fw-esp32-common = { path = "../fw-esp32-common", default-features = false } + +# MessageRouter + the SerialIo trait, for `serial::io_task`. +fw-core = { path = "../fw-core", default-features = false, optional = true } + +# The portable WS281x transmitter: pulse encoding, the ping-pong refill state +# machine, guard-word flicker protection and the per-channel counters. Chip +# knowledge stays in `src/output/rmt/v3_rmt.rs`, which implements this crate's +# `RmtHw` (ADR 2026-07-31-lp-ws281x-multi-channel-driver-adoption: new chips +# implement the trait, they do not grow a new one). `default-features = false` +# drops the host-only `mock` backend, exactly as fw-esp32s3 does. +# +# Optional and gated behind `server`, like every other app-layer dependency +# here: `src/output/` also needs `lpc-hardware`, `alloc` and the common +# provider, none of which the bare hello / radio-probe entrypoints link. +lp-ws281x = { path = "../lp-ws281x", default-features = false, optional = true } + +# Async runtime for `serial::io_task`. Mirrors fw-esp32s3's set. +embassy-executor = { version = "0.10.0", optional = true } +embassy-time = { version = "0.5.0", optional = true } +embassy-sync = { version = "0.8.0", optional = true } +embassy-futures = { version = "0.1", optional = true } +embedded-io-async = { version = "0.6", optional = true } +esp-rtos = { version = "0.3.0", default-features = false, features = ["embassy", "log-04"], optional = true } + +# Flash-backed filesystem. The lpfs partition is located by label at runtime +# (see src/flash_storage.rs), not transcribed from partitions.csv. +embedded-storage = { version = "0.3", optional = true } +# `critical-section` is esp-storage's own default and is added back +# deliberately: it wraps every flash operation so an interrupt cannot run from +# a disabled cache mid-write. `default-features = false` without it compiles +# fine and corrupts silently — exactly the trap the S3 hit twice. +esp-storage = { version = "0.9", default-features = false, features = ["critical-section", "esp32", "panic-unaligned-buffer"], optional = true } +littlefs-rust = { version = "0.1.0", default-features = false, features = ["alloc"], optional = true } + +# io_task's wire-serialization path plus the boot-time `ServerHello`. +lpc-wire = { path = "../../lp-core/lpc-wire", default-features = false, optional = true, features = ["ser-write-json"] } +ser-write-json = { version = "0.3", optional = true, default-features = false, features = ["alloc"] } + +# ⚠️ Two of eight node gates, the same pair fw-esp32s3 lists. `node-fixture` is +# not optional alongside `node-shader`: `OutputNode::consume` requires a +# *control* product, `ShaderNode` produces a *visual* one, and `FixtureNode` is +# the only runtime that converts between them. +# +# `panic-recovery` is absent on purpose: this chip is abort tier +# (ADR 2026-07-29-per-chip-fw-toolchains), and that feature pulls `unwinding`, +# which is the C6's tier and has no Xtensa backend. +lpa-server = { path = "../../lp-app/lpa-server", default-features = false, features = [ + "node-shader", + "node-fixture", +], optional = true } + +# The graphics backend. `lp-gfx-lpvm` selects the LPVM engine by +# `target_arch`, so an `xtensa-esp32-none-elf` build takes `lpvm-native` with +# `isa-xt` — the same line the S3 carries, no ISA feature to get wrong. +lp-gfx-lpvm = { path = "../../lp-gfx/lp-gfx-lpvm", default-features = false, optional = true } + +# Direct dependency, for TWO things the S3 does not need (M3-P2): +# 1. The `xt-placed-code` feature — cargo feature unification applies it to +# the `lpvm-native` inside `lp-gfx-lpvm` too, flipping the JIT engine's +# link step onto the classic fixed-region placed path. Without it a +# compiled shader executes from heap, which has no I-bus view on this +# chip: EXCCAUSE=2 on the first render tick (observed on the DOM-Z-102). +# 2. `codemem_esp32::global::install`, which boot calls to hand the engine +# its code region before the first compile. +lpvm-native = { path = "../../lp-shader/lpvm-native", default-features = false, features = [ + "isa-xt", + "xt-placed-code", +], optional = true } + +# Hardware registry/manifest, the filesystem traits the server is handed, and +# the `OutputProvider` trait object `main.rs` builds. +lpc-hardware = { path = "../../lp-core/lpc-hardware", default-features = false, optional = true } +# ⚠️ `xt-map-esp32-classic` is not cosmetic. `lpc_shared::backtrace`'s Xtensa +# walker is LX6/LX7-generic in mechanism but bounds-checks every candidate frame +# against one chip's IRAM / flash-cache / DRAM windows, and it defaults to the +# ESP32-S3's. Left at the default this walker does not fail — it silently +# rejects every frame on classic silicon and reports zero, which the panic path +# renders as "the stack was unreadable". `recovery::panic_path`'s +# `FRAME_WALKER_PRESENT` is the other half of this fact; the two move together. +lpc-shared = { path = "../../lp-core/lpc-shared", default-features = false, features = [ + "xt-map-esp32-classic", +], optional = true } +lpfs = { path = "../../lp-base/lpfs", default-features = false, optional = true } + +# The RAM probe's radio cohort (M2-P3 of the classic bring-up roadmap): +# versions from the experiment repo's led-lab-esp32 `test_stress`, the set +# proven to boot the radio on classic silicon under the esp toolchain +# (esp-rtos 0.2 does NOT work — conflicting esp-sync; findings.md §3). +# Optional so the app build stays radio-free: the radio blob costs ~390 KB of +# flash and ~44 KB of heap on this chip (measured, M2-P3), and that phase +# existed to MEASURE it, not to ship it. +[dependencies.esp-radio] +version = "0.18.0" +optional = true +features = ["esp32", "wifi", "esp-now", "esp-alloc", "unstable"] diff --git a/lp-fw/fw-esp32v3/README.md b/lp-fw/fw-esp32v3/README.md new file mode 100644 index 000000000..b4e78333f --- /dev/null +++ b/lp-fw/fw-esp32v3/README.md @@ -0,0 +1,333 @@ +# fw-esp32v3 + +LightPlayer firmware for the **classic ESP32** ("v3", WROOM-32E, Xtensa LX6). +Sibling of `fw-esp32s3` (Xtensa LX7) and `fw-esp32c6` (RISC-V). + +Runs the LightPlayer app: `LpServer` over a **UART0** transport, backed by a +littlefs filesystem in the `lpfs` partition. Built through M2 (crate, 4 MB +partitions, RAM ledger, CI) and M3-P1 (app layer) of the classic-ESP32 +bring-up roadmap +(`~/.photomancer/planning/lp2025/2026-07-31-1444-classic-esp32-bringup/`). + +WS281x output landed in M4-P1: up to four concurrent RMT channels, sourced +from the board manifest — see "WS281x output" below. + +The `lp-recovery` RTC crash ledger landed in M4-P2, pulled forward from M7 +because the WS281x fault could not report itself over serial — see +`src/recovery/`. Safe mode, boot reports and the blame ledger all work; the +**RWDT is still M7**. + +Still absent: radio (measure-only, behind `radio_ram_probe`). See "JIT status" +below for where on-device shader execution stands. + +## Workspace shape + +A repo-root workspace **member** excluded from `default-members` — the same +shape as `fw-esp32c6` and `fw-esp32s3` (see `fw-esp32s3/README.md`'s +"Workspace notes"). + +M2-P1 originally stood this crate up as its **own** workspace, because it then +had zero lp2025-internal path dependencies and so needed nothing from the root +`workspace.dependencies` or `[patch.crates-io]` tables. M3-P1 made it a real +`fw-esp32-common` / `lpa-server` / `lpc-hardware` consumer, which is exactly +the condition that decision named for revisiting it: path dependencies reaching +into the root workspace from *outside* it resolve their own copies of every +shared crate and silently ignore the root `[patch]` forks. The cost — a +`--exclude fw-esp32v3` entry in the justfile's `clippy-host`, mirroring the +other two firmware crates — is now paid. + +Build and lint commands still `cd` into this directory: `.cargo/config.toml` +here selects the Xtensa target, the linker flags and the espflash runner, and +cargo reads that file from the CWD upward. Artifacts land in the shared root +`target/`. `[profile.release-esp32v3]` lives in the **root** `Cargo.toml`, +next to `release-esp32s3` — cargo only honours profiles on the workspace root +manifest. + +## Building + +Xtensa has no upstream Rust target, so — like `fw-esp32s3` — this crate +carries its own `rust-toolchain.toml` with `channel = "esp"` (Espressif's +fork, installed by `espup`), per +`docs/adr/2026-07-29-per-chip-fw-toolchains.md`. + +```bash +just build-fw-esp32v3 # or, by hand: +cd lp-fw/fw-esp32v3 +# Put the esp toolchain's bundled GNU binutils on PATH first if +# xtensa-esp32-elf-gcc isn't already resolvable — e.g. +# export PATH="$HOME/.rustup/toolchains/esp/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin:$PATH" +cargo build --profile release-esp32v3 +``` + +Other build shapes (all covered by `just clippy-fw-esp32v3`): + +| Command | Entrypoint | +|---|---| +| default | server app over UART0 | +| `--no-default-features --features esp32` | M2-P1 boot-to-hello skeleton | +| `--no-default-features --features esp32,radio_ram_probe` | M2-P3 radio RAM ledger | +| `--features ws281x_telemetry` | app + periodic WS281x counter log (see below) | + +Measured 2026-08-01 at M6, with the app layer, WS281x output and the recovery +ledger in: **1,707,792 B** image against the 3 MB `factory` partition — +**1,437,936 B of headroom, 46 % free**. + +⚠️ **Flash is not this chip's constraint; RAM is.** At four channels the heap +has ~7 KB free where flash has 1.4 MB. LED-count claims must be quoted from +the RAM ledger, never from the RMT channel count — the measured ceiling is +**~240 LEDs comfortable / ~300 at the edge / 400 impossible**, at ≈89.5 B per +LED. Full flash and RAM ledgers, the cross-chip comparison and the +serde-surface go/no-go are in +[`docs/adr/2026-08-01-esp32v3-flash-budget.md`](../../docs/adr/2026-08-01-esp32v3-flash-budget.md). + +### Release profile + +Builds under `[profile.release-esp32v3]` (`inherits = "release"`, +`opt-level = "s"`), defined in the **repo-root** `Cargo.toml` next to +`release-esp32s3` (cargo honours profiles only on the workspace root +manifest). `"s"` rather than the repo's default `"z"` mirrors `fw-esp32s3`, +whose "z" build missed a 30 µs cold-frame WS281x deadline on the *faster* +LX7. As of M4 that reasoning applies here for real: the profile is part of +the RMT timing contract. This chip's deadline is a roomier 80 µs (64-word +halves), but the cold first frame is checked on silicon rather than assumed — +M4-P4. + +`"z"` is not merely unvalidated here — it does not build. `esp-storage`'s +build script hard-errors on the classic ESP32 below `opt-level` 2/3/s, which +is why `just clippy-fw-esp32v3` passes `--profile release-esp32v3` where +`clippy-fw-esp32s3` gets away with `--release`. + +## WS281x output + +`src/output/` — the same three-layer split `fw-esp32s3` uses: + +| Layer | File | Knows about | +|---|---|---| +| chip | `output/rmt/v3_rmt.rs` | RMT registers, RAM at `0x3FF5_6800`, the `INT_*` bit layout. Implements `lp_ws281x::RmtHw`. | +| sequencing | `output/rmt/shared_driver.rs` | the one `Ws281xDriver` static, the IRAM interrupt trampoline, the optional telemetry tap | +| seam | `output/rmt/esp32v3_rmt_ws281x_driver.rs` | `lpc-hardware` endpoints, leases, open-time pin binding | + +Ported from the experiment repo's hardware-validated classic backend +(`2026-esp32s3-experiment`, `fw/led-lab-esp32/src/esp32_rmt.rs`). All +sequencing stays in `lp-ws281x`, whose host suite (`cargo test -p lp-ws281x`) +is the regression net; this crate adds no trait surface +(ADR `2026-07-31-lp-ws281x-multi-channel-driver-adoption`). + +### Two blocks per channel, and where the four outputs come from + +`BLOCKS_PER_CHANNEL = 2` (a compile-time constant in `v3_rmt.rs`, not a +config surface). The classic has eight RMT channels of 64 words each; a +channel that takes two blocks **absorbs its neighbour's**, so exactly four +slots — `0, 2, 4, 6` — own memory, with 128-word windows halving into +64-word (80 µs) refill deadlines. + +That is not a taste call. The classic's *delivered* interrupt rate saturates +around 48 k/s regardless of demand (experiment `findings.md` §12 — this is the +root cause of the equal-start truncation defect, and staggering does not fix +it). At one block per channel each busy output demands 25 k refills/s, so the +chip runs out at **two**. Two blocks halves the demand to 12.5 k/s and should +reach four — but 4 × 12.5 k = 50 k against a ~48 k ceiling is *marginal +arithmetic, not a measurement*. Validating it on silicon is M4-P3. + +**Absorbed slots are skipped by construction.** Manifest channel `K` +(`/rmt/ws281xK`) resolves to RMT slot `K * SLOT_STRIDE` via +`v3_rmt::slot_for_index`, and the channel-creation loop only ever hands +esp-hal a slot that owns memory. The experiment harness did *not* do this — +it kept asking for channels 0,1,2,3 and got `MemoryBlockNotAvailable` for the +odd ones, which is why its `BLOCKS_PER_CHANNEL=2` configuration never ran. + +Channel **count** comes from the board manifest and nowhere else: the +DOM-Z-102 declares four `/rmt/ws281xK` resources, and the endpoints offered +are its board-labelled GPIOs (IO18/IO16/IO14/IO2 → `ws281x:rmt:IO18` and so +on). No GPIO number appears in driver logic; the pin arrives with the +endpoint and is bound at `open` under a registry lease. + +### Telemetry (`--features ws281x_telemetry`, off by default) + +`lp-ws281x` keeps per-channel counters — guard trips, guard skips, TX errors, +refill-lag sum/count/max and a 9-bucket lag histogram — and this feature +prints them over the serial link, one line per configured channel roughly +every 10 s, from the frame-write path (never from the ISR): + +``` +[WS281X] t_ms=… ch=0 half=64 frames=… complete=… trips=… skips=… errors=… \ + refills=… wanted=… lag_avg=6.9 lag_max=21 over_half=0 hist=a:b:…:i +``` + +Read **`refills` against `wanted`** first — a refill that never arrives +leaves no lag sample behind, so `lag_max` can look comfortable while a third +of the frames truncate. `trips` is the direct truncation count. `wanted` is +`frames × ceil(total_bits / half)`, i.e. what an untruncated frame set would +have needed. + +Off by default so the shipping image spends nothing on it: the module is +`cfg`'d out and the call site becomes an empty `#[inline(always)]` fn — not +even the timer read survives. `just clippy-fw-esp32v3` lints the feature on, +so it cannot rot. + +## DRAM budget + +esp-hal's `dram_seg` for this chip is `0x3FFB_0000..0x3FFE_0000` — **192 KB** +(the S3 has 341,760 B) — and `.data`, `.bss` (which holds the `esp_alloc` +arena) and `.stack` all come out of it. `.stack` gets the remainder, so the +heap constant in `src/main.rs` is one side of a zero-sum split. + +Measured on the linked app image (2026-08-01, `HEAP_SIZE = 110 KB`, WS281x +output in): + +| Section | Bytes | Δ vs M3-P1 | +|---|---|---| +| `.data` | 18,924 | +3,712 | +| `.bss` (incl. 112,640 B arena → ~21.6 KB static) | 134,280 | +24 | +| `.stack` | 43,400 | **−3,736** | +| total | 196,604 of 196,608 | — | + +⚠️ **The WS281x driver was paid for out of the stack**, because the split is +zero-sum and `HEAP_SIZE` did not move: `.data` grew by 3,712 B (the shared +`Ws281xDriver` static with its eight `ChannelState`s, plus driver constants) +and `.stack` shrank by the same. 43,400 B is now well under fw-esp32s3's +proven 52,896 B. If a deep call — the recursive GLSL parser, a windowed-ABI +spill storm during an on-device compile *while frames are going out* — +overflows, `HEAP_SIZE` in `src/main.rs` is the knob that gives the stack back, +and M3's ledger says there was 18.8 k of heap headroom at render to spend. + +Free heap at idle, read off the device heartbeat: **103,916 B free / +8,724 B used**. For scale, fw-esp32s3's first on-device GLSL compile OOM'd at +a 96 KB heap and needed 240 KB — a number this chip cannot reach at any +setting. That measurement is what gate G-M3 of the bring-up roadmap exists to +evaluate. + +Raising `HEAP_SIZE` further trades stack 1:1, and the link only fails once +`.stack` would go negative: 160 KB overshoots by 4,064 B +(`stack.x:11 cannot move location counter backwards`), putting the hard limit +at ≈155.9 KB — where the stack is zero and the board cannot run. So "as high +as it links" is *not* the real ceiling. 110 KB is the setting that keeps +`.stack` near fw-esp32s3's proven 52,896 B, which the Xtensa windowed ABI's +large frames and the recursive GLSL parser both want. + +The tempting next lever is esp-hal's `dram2_seg` (`0x3FFE_7E30`, 98,768 B) as +a second `esp_alloc` region. **It overlaps +`lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT`** +(`0x3FFE_8000..0x3FFF_F000` D-bus), so any such region must stop below +`0x3FFE_8000` or the allocator and the JIT will hand out the same bytes. + +## Flashing + +```bash +espflash flash --chip esp32 --partition-table partitions.csv --flash-size 4mb \ + --monitor --after hard-reset --port /dev/cu.wchusbserial1140 \ + target/xtensa-esp32-none-elf/release-esp32v3/fw-esp32v3 +``` + +(`.cargo/config.toml`'s `runner` does the same thing when you `cargo run`.) + +**Always pass `--port` explicitly.** The desk board is a **DOM-Z-102** +(domraem carrier, classic ESP32 rev v3.1, 4 MB flash) at +`/dev/cu.wchusbserial1140` as of 2026-07-31 — but several boards are +typically on the desk bus at once (an S3 session may be sharing it), and its +CH340K USB-UART bridge enumerates under a port name that is only stable per +physical hub location. Re-verify before flashing: + +```bash +espflash board-info --port /dev/cu.wchusbserial1140 +``` + +`Chip type: esp32` is the one you want. + +### macOS driver note + +The DOM-Z-102's CH340K bridge needs the **WCH CH34x VCP driver** installed +on macOS before it enumerates as a `/dev/cu.wchusbserial*` device at all — +unlike the S3 (USB-Serial-JTAG, no driver needed) or the C6. After +installing the driver, macOS may block the kernel extension under +**System Settings → Login Items & Extensions** until you explicitly allow +it. See the planning roadmap's notes.md and memory +`classic-esp32-ch340k-macos-driver` for the full procedure; this is a +one-time machine setup step, not a per-flash one. + +Two espflash flags are mandatory here too, for the same reasons `fw-esp32s3` +documents: `--partition-table` (without it espflash silently substitutes a +1 MB-factory default table) and `--flash-size` (espflash defaults the image +header's flash-size field to 4 MB regardless of the physical chip, and the +bootloader validates the partition table against *that header*). This +board's table genuinely is the 4 MB shape below, so `--flash-size 4mb` +should match reality — naming it explicitly still turns a mis-flashed board +into a loud espflash refusal instead of a confusing boot-loop. + +## Partitions + +`partitions.csv` copies `fw-esp32c6`'s **4 MB** shape verbatim (Q7 in the +bring-up roadmap). **Ratified at M6** — +[`docs/adr/2026-08-01-esp32v3-flash-budget.md`](../../docs/adr/2026-08-01-esp32v3-flash-budget.md) — +though the reasoning that produced it turned out to be wrong in a harmless +direction: the guess was that this chip is flash-constrained like the C6, and +it is not. The measured image is within 8,720 B of the **S3's**, not the C6's +(both are Xtensa and abort tier; the C6 is 1.15 MB larger). 3 MB is kept +because it is ample, not because it is tight, and unlike the S3 this chip +narrows supported hardware not at all — any 4 MB N4-class module runs it. + +| Partition | Offset | Size | +|---|---|---| +| `nvs` | `0x9000` | 24 KB | +| `phy_init` | `0xf000` | 4 KB | +| `factory` (app) | `0x10000` | 3 MB | +| `lpfs` (spiffs) | `0x310000` | 960 KB | + +Ends exactly at `0x400000` (4 MB) — no slack. An ADR for this table lands +with a later milestone (M6 in the roadmap), not here. + +## UART baud gotcha + +Classic ESP32 has **no USB-Serial-JTAG** peripheral (unlike the S3), so this +crate uses `esp-println`'s `uart` feature instead of `jtag-serial`. That +feature writes UART0's TX FIFO directly but **never programs the baud +divisor itself** — the ROM bootloader leaves a divisor set for its own +(pre-reclock) clock tree, and after `esp_hal::init()` reclocks to +`CpuClock::max()`, the stale divisor makes every `esp_println!` print +garbage at any standard host baud. This was diagnosed and fixed on real +classic-ESP32 hardware in the experiment repo (`fw/xt-runner-esp32`, +FINDINGS.md "C1"): construct an `esp_hal::uart::Uart` on UART0 once at boot +(115200 8N1, TX=GPIO1, RX=GPIO3). `board::esp32v3::init::init_board` does +exactly that, before the first `esp_println!` — and since M3-P1 that binding +is no longer a keep-alive hack, it is the actual server transport. + +## Host link + +The server speaks the same `M!`-prefixed line protocol as the other two +firmwares, over UART0 at 115200 8N1 instead of USB-Serial-JTAG. See +`src/serial/io_task.rs` for the two places the byte layer had to differ from +fw-esp32s3's copy (no connection monitor; RX drained between TX chunks so a +long write cannot overflow the 128-byte RX FIFO). + +Round-trip verified on the desk DOM-Z-102, 2026-07-31: + +```console +$ printf 'M!{"id":7,"msg":"hello"}\n' > /dev/cu.wchusbserial1140 +M!{"id":7,"msg":{"hello":{"proto":4,"fw":{"package":"fw-esp32v3",...}}}} +``` + +⚠️ **`lp-cli` cannot connect to this board yet** — it fails the readiness gate +with `Incompatible { FrameBeforeHello }`. `lpa_client::transport_serial`'s +`reset_after_open` performs the ESP32-C6 USB-JTAG-serial reset dance, which +does not reset a CH340K-bridged classic ESP32 (it never asserts DTR, which +that bridge's two-transistor auto-reset circuit needs); the client therefore +attaches to a still-running device that sent its unsolicited hello minutes +ago. Host-side work, tracked for M3-P2/P3, which need a project push. + +## What's deliberately not here yet + +- **No shader execution.** `lp-gfx-lpvm` is linked, so a shader *compiles* on + device — but classic DRAM has no I-bus view, so `rt_jit`'s in-place buffer + is not fetchable and the first call to compiled code faults. The placed + path (`lpvm_native::codemem_esp32` + `JitBuffer::Placed` + + `link::link_jit_at`) is wired in M3-P2. See the ⚠️ block at the + `TargetLpvmGraphics::new` call site in `src/main.rs`. +- **No `lp-recovery` RTC crash ledger and no RWDT.** The panic handler is + print-and-reset only — the compiler-level posture (`panic=abort`, no + unwinding) already matches `fw-esp32s3`'s abort tier, so adding the ledger + later is additive, not a rewrite. Consequences today: no safe-mode gate on + project auto-load (a project that crashes the boot keeps crashing it), and + no watchdog backstop for a hung frame loop. The backend needs + classic-specific RTC-fast-RAM and `SocResetReason` constants; M7. +- **No radio in the app build.** Linked only behind `radio_ram_probe`, whose + M2-P3 ledger measured 44,244 B of heap + ~390 KB flash for it. diff --git a/lp-fw/fw-esp32v3/build.rs b/lp-fw/fw-esp32v3/build.rs new file mode 100644 index 000000000..dd883b042 --- /dev/null +++ b/lp-fw/fw-esp32v3/build.rs @@ -0,0 +1,70 @@ +//! Build script for fw-esp32v3. +//! +//! Deliberately minimal, mirroring fw-esp32s3's: this chip is also abort-tier +//! (ADR 2026-07-29-per-chip-fw-toolchains), so there is no `.eh_frame` +//! patching to do — that machinery is the C6's `panic=unwind` tier only. +//! +//! Unlike fw-esp32s3's build.rs, this one does not emit the `fw_harness` cfg: +//! there are no `test_*` harness features on this crate yet. Port that half +//! from fw-esp32s3's build.rs when the first one lands. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + // Watch the whole package, not just this file. Emitting ANY + // rerun-if-changed disables cargo's default "re-run when any package file + // changes" rule, so naming only `build.rs` would pin the provenance stamp + // below to whatever commit was checked out the first time this script ran + // — the device then reports a stale commit in its wire hello, which is + // exactly the fact you reach for when asking "which build is on this + // board?". fw-esp32s3 observed precisely that during its M3 hardware walk. + println!( + "cargo:rerun-if-changed={}", + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")).display() + ); + + emit_build_provenance(); +} + +/// Emit build provenance for the wire hello (`ServerHello.fw`): +/// `LP_BUILD_COMMIT` (short git commit or "unknown"), `LP_BUILD_DIRTY` +/// ("true"/"false", false when git is absent so vendored builds still +/// compile), and `LP_BUILD_PROFILE` (the cargo profile directory name). +/// +/// Same three variables as fw-esp32s3's and fw-esp32c6's build scripts, for +/// the same reason: the server is sans-IO and never reads git or env itself, +/// so the binary has to bake them in. `main.rs` injects them into +/// `LpServer::set_hello`. +fn emit_build_provenance() { + let commit = + git_output(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".into()); + let dirty = match git_output(&["status", "--porcelain"]) { + Some(status) => !status.is_empty(), + None => false, + }; + let profile = profile_dir_name() + .or_else(|| std::env::var("PROFILE").ok()) + .unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=LP_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=LP_BUILD_DIRTY={dirty}"); + println!("cargo:rustc-env=LP_BUILD_PROFILE={profile}"); +} + +fn git_output(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// The actual profile directory name from OUT_DIR +/// (`…///build/-/out`), which preserves custom +/// profile names that the coarse `PROFILE` env collapses to "release". +fn profile_dir_name() -> Option { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").ok()?); + // out -> - -> build -> + let profile = out_dir.parent()?.parent()?.parent()?; + Some(profile.file_name()?.to_string_lossy().into_owned()) +} diff --git a/lp-fw/fw-esp32v3/partitions.csv b/lp-fw/fw-esp32v3/partitions.csv new file mode 100644 index 000000000..3b87ca93d --- /dev/null +++ b/lp-fw/fw-esp32v3/partitions.csv @@ -0,0 +1,14 @@ +# Name, Type, SubType, Offset, Size, Flags +# 4 MB table (N4-class modules) — the classic bring-up roadmap's Q7 decision: +# copy fw-esp32c6's shape verbatim (this chip's flash budget is constrained +# like the C6's, unlike the S3's 8 MB floor). See +# ~/.photomancer/planning/lp2025/2026-07-31-1444-classic-esp32-bringup/ for +# the roadmap; the ADR reference for this table lands with a later milestone +# (M6), not here. +# factory 3 MB @ 0x010000 .. 0x310000 +# lpfs 960 KB @ 0x310000 .. 0x400000 +# end 0x400000 of 0x400000 — no slack +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x300000, +lpfs, data, spiffs, 0x310000, 0xF0000, diff --git a/lp-fw/fw-esp32v3/rust-toolchain.toml b/lp-fw/fw-esp32v3/rust-toolchain.toml new file mode 100644 index 000000000..d34b8cd9f --- /dev/null +++ b/lp-fw/fw-esp32v3/rust-toolchain.toml @@ -0,0 +1,14 @@ +# fw-esp32v3, like fw-esp32s3, builds on Espressif's Rust fork (Xtensa has no +# upstream Rust target) via the rustup `esp` channel. Quarantined per-crate per +# `docs/adr/2026-07-29-per-chip-fw-toolchains.md`: the rv32 crates stay on the +# shared pinned nightly and are unaffected. +# +# `channel = "esp"` is unversioned and tracks whatever espup installed. Needs +# esp Rust >= 1.90 to satisfy the same MSRV fw-esp32s3 documents (1.90.0.0 +# shipped 2025-09); on 1.88 `lpc-model`-adjacent const-generic code genuinely +# fails to compile. `espup update` if a version error appears. This crate has +# no `lpc-model` dependency yet (P1), but the toolchain floor is a repo-wide +# fact, not a per-crate one, so it is copied verbatim from fw-esp32s3. +[toolchain] +channel = "esp" +components = ["rustfmt", "clippy", "rust-src"] diff --git a/lp-fw/fw-esp32v3/src/board/esp32v3/init.rs b/lp-fw/fw-esp32v3/src/board/esp32v3/init.rs new file mode 100644 index 000000000..3f0c37ba8 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/board/esp32v3/init.rs @@ -0,0 +1,114 @@ +//! Classic ESP32 (LX6) board initialization. +//! +//! Ported from `fw-esp32s3/src/board/esp32s3/init.rs`. The shape is the S3's; +//! the peripheral list is not: +//! +//! - **UART0 instead of `USB_DEVICE`.** This chip has no USB-Serial-JTAG +//! peripheral; the host link is UART0 through the board's CH340K bridge. +//! - **No `Rwdt`.** fw-esp32s3 hands the RTC watchdog to its recovery +//! subsystem; this crate has no `lp-recovery` backend yet (see `Cargo.toml`), +//! and arming a watchdog with nothing feeding it would reset the board on a +//! timer. M7 adds both together, the way the S3 has them. +//! - **`RMT` is handed straight back.** Unlike the S3's `init_board`, this one +//! does not construct the `Rmt` driver: the clock rate it wants belongs to +//! `output::rmt::shared_driver::RMT_CLOCK`, and `main.rs` is where a failure +//! to init it turns into "no LED output this boot" rather than "no boot". +//! Same shape as fw-esp32s3, which also returns the raw peripheral. +//! +//! ⚠️ `init_board` takes the `esp_hal` peripheral singleton, and taking it +//! twice panics. It is the app path's **only** call to `esp_hal::init`. + +use esp_hal::clock::CpuClock; +use esp_hal::interrupt::software::SoftwareInterruptControl; +use esp_hal::timer::timg::{TimerGroup, TimerGroupInstance}; +use esp_hal::uart::{Config as UartConfig, Uart}; +use esp_hal::{Blocking, uart::ConfigError}; + +/// Initialize classic-ESP32 hardware. +/// +/// Sets up the CPU clock and returns the runtime components the app layer +/// needs: the software-interrupt control and timer group for the executor, +/// UART0 for `serial::io_task`, the FLASH peripheral for `flash_storage`, and +/// the RMT peripheral for `output::rmt`. +/// +/// The UART is returned in [`Blocking`] mode; `io_task` converts it with +/// `into_async()`. Constructing it here rather than there is deliberate — see +/// the baud-divisor note below, which has to happen before the first +/// `esp_println!`. +/// +/// Unlike the C6, the heap is **not** allocated here — `main.rs` owns it. +pub fn init_board() -> ( + SoftwareInterruptControl<'static>, + TimerGroup<'static, impl TimerGroupInstance>, + Result, ConfigError>, + esp_hal::peripherals::FLASH<'static>, + esp_hal::peripherals::RMT<'static>, +) { + // `esp_hal::init` disables the RTC watchdog (RWDT) and both TIMG + // watchdogs unconditionally (esp-hal 1.1.1 `lib.rs::init`). `CpuClock::max()` + // is 240 MHz on this chip, matching the S3's `init_board`; printed and + // measured timings assume the fast clock. + let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); + let peripherals = esp_hal::init(config); + + let flash = peripherals.FLASH; + // Handed on untouched: `main.rs` builds the `Rmt` driver at the WS281x + // clock rate and registers the driver, so an RMT failure costs the board + // its LED output rather than its boot. + let rmt = peripherals.RMT; + + // ⚠️ Load-bearing twice over. + // + // 1. It is the server transport. UART0 through the CH340K bridge is this + // chip's only host link — there is no USB-Serial-JTAG. `serial::io_task` + // takes this binding, `into_async()`s it and splits RX/TX. + // + // 2. It programs the baud divisor that `esp-println` depends on but never + // sets. `esp-println`'s `uart` feature writes UART0's TX FIFO directly; + // the ROM leaves a divisor computed for its own pre-reclock clock tree, + // and `esp_hal::init` above has just moved the CPU to 240 MHz, so until + // something reprograms the divisor every `esp_println!` prints garbage + // at any standard host baud. Constructing this `Uart` is what + // reprograms it (experiment repo FINDINGS.md "C1", diagnosed on this + // exact chip). Which is why this happens HERE and not in `io_task`: + // the `[INIT]` lines `main.rs` prints on the way to spawning that task + // would otherwise be unreadable. + // + // 921600 8N1 — `lpc_model::DEFAULT_SERIAL_BAUD_RATE`, the baud every + // lp-cli/Studio serial connect opens with. On the C6/S3 that constant is + // a fiction (native USB has no baud); this chip is the first where it + // hits a real wire, and the mismatch was found the hard way: the host + // opened 921600 against a 115200 firmware and heard NOTHING (CH340 drops + // framing-error bytes), which read as `NoSerialOutput`. The CH340K is + // comfortable at 921600, and the 8x line rate is the difference between + // ~1.4 s and ~175 ms for a 16 KiB ProjectRead. Console consequence: + // `espflash monitor` needs `--baud 921600` (the flash recipes in the + // README carry it); the ROM's own boot banner still goes out at 115200 + // and will look garbled in such a monitor — that is cosmetic. + // TX=GPIO1 / RX=GPIO3 are the classic devkit's UART0 bridge pins, and + // are the two GPIOs the DOM-Z-102 board manifest reserves for exactly + // this reason. + // + // The error is returned rather than `expect`ed: a UART config failure + // costs the board its host link, not its boot, and a board that boots far + // enough to blink is more diagnosable than one that resets in a loop. + let uart0 = Uart::new( + peripherals.UART0, + UartConfig::default().with_baudrate(921_600), + ) + .map(|uart| uart.with_tx(peripherals.GPIO1).with_rx(peripherals.GPIO3)); + + // Set up software interrupt and timer for the Embassy runtime. + let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); + let timg0 = TimerGroup::new(peripherals.TIMG0); + + (sw_int, timg0, uart0, flash, rmt) +} + +/// Start the Embassy runtime with the given timer and software interrupt. +pub fn start_runtime( + timg0: TimerGroup<'static, impl TimerGroupInstance>, + sw_int: SoftwareInterruptControl<'static>, +) { + esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); +} diff --git a/lp-fw/fw-esp32v3/src/board/esp32v3/mod.rs b/lp-fw/fw-esp32v3/src/board/esp32v3/mod.rs new file mode 100644 index 000000000..93274b63a --- /dev/null +++ b/lp-fw/fw-esp32v3/src/board/esp32v3/mod.rs @@ -0,0 +1,13 @@ +//! Classic ESP32 (LX6) chip facts. +//! +//! Chip-specific values live here rather than in `fw-esp32-common`: the seam +//! rule is that shared firmware code never learns chip facts, it receives +//! them (ADR `2026-07-29-per-chip-fw-toolchains`). +//! +//! There is no `usb_connection` sibling to fw-esp32s3's: this chip has no +//! USB-Serial-JTAG peripheral, so there is no SOF signal to poll and no +//! enumeration state to track. See `crate::serial` for what replaces it. + +// The app entrypoint's sole source of the peripheral singleton. See the module +// doc for the hazard that makes it the *only* one. +pub mod init; diff --git a/lp-fw/fw-esp32v3/src/board/mod.rs b/lp-fw/fw-esp32v3/src/board/mod.rs new file mode 100644 index 000000000..abd055869 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/board/mod.rs @@ -0,0 +1,4 @@ +//! Per-board constants and helpers. One module per chip, mirroring +//! fw-esp32s3 and fw-esp32c6. + +pub mod esp32v3; diff --git a/lp-fw/fw-esp32v3/src/flash_storage.rs b/lp-fw/fw-esp32v3/src/flash_storage.rs new file mode 100644 index 000000000..1249f96ef --- /dev/null +++ b/lp-fw/fw-esp32v3/src/flash_storage.rs @@ -0,0 +1,136 @@ +//! Flash storage adapter for littlefs-rust. +//! +//! Implements `littlefs_rust::Storage` over `esp_storage::FlashStorage`, +//! translating block/offset addressing to the `lpfs` partition. +//! +//! ## Why no offset constant (ported verbatim from fw-esp32s3) +//! +//! `fw-esp32c6/src/flash_storage.rs` hardcodes `LPFS_PARTITION_OFFSET = +//! 0x310000` and `BLOCK_COUNT = 240`, transcribed by hand from that chip's +//! `partitions.csv`. This crate's 4 MB table happens to put `lpfs` at exactly +//! that offset with exactly that size (0x310000, 0xF0000 = 240 blocks — Q7 of +//! the classic bring-up roadmap copies the C6 table), so transcribing would +//! even be *correct* here today. +//! +//! It is still not done, for the reason the S3's copy gives: a transcribed +//! offset that drifts from the flashed table does not fail, it erases running +//! code. The offset and length are read from the partition table at runtime, +//! matched by the `lpfs` label. `esp-bootloader-esp-idf` is already a +//! dependency (`esp_app_desc!`), the table is the same artifact espflash +//! writes, and there is no second copy of the layout to keep in sync. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; +use esp_bootloader_esp_idf::partitions; +use littlefs_rust::{Config, Error as LfsError, Storage}; + +/// Partition label to mount as the LightPlayer filesystem. Must match +/// `partitions.csv`. +const LPFS_LABEL: &str = "lpfs"; + +/// Block size: 4KB (matches ESP32 flash sector). +const BLOCK_SIZE: u32 = 4096; +/// LittleFS read/program cache. Keep this below the erase block size so opening a +/// file does not need a transient 4KB heap allocation. +const CACHE_SIZE: u32 = 512; +/// Lookahead bitmap size. Must be a multiple of 8. +const LOOKAHEAD_SIZE: u32 = 64; + +/// Where the `lpfs` partition actually is, as read from the flashed partition +/// table rather than transcribed from `partitions.csv`. +#[derive(Debug, Clone, Copy)] +pub struct LpfsPartition { + offset: u32, + len: u32, +} + +impl LpfsPartition { + /// Locate the `lpfs` partition by label. + /// + /// Returns `None` when the table has no such entry — which means the image + /// was flashed without `--partition-table lp-fw/fw-esp32v3/partitions.csv` + /// and espflash substituted its default table. That is a flashing mistake, + /// not a runtime condition, so the caller should say so loudly rather than + /// fall back to a guessed offset. + pub fn locate(flash: &mut impl embedded_storage::Storage) -> Option { + let mut buf = [0u8; partitions::PARTITION_TABLE_MAX_LEN]; + let table = partitions::read_partition_table(flash, &mut buf).ok()?; + let entry = table.iter().find(|e| e.label_as_str() == LPFS_LABEL)?; + Some(Self { + offset: entry.offset(), + len: entry.len(), + }) + } + + /// Number of 4KB littlefs blocks in the partition. + pub fn block_count(&self) -> u32 { + self.len / BLOCK_SIZE + } +} + +/// Flash storage adapter implementing littlefs Storage over esp_storage. +/// +/// Translates littlefs block/offset addressing to absolute flash addresses +/// within the lpfs partition. +pub struct LpFlashStorage { + flash: esp_storage::FlashStorage<'static>, + partition: LpfsPartition, +} + +impl LpFlashStorage { + /// Create storage adapter for the located lpfs partition. + /// + /// Also publishes the partition's block count for [`lpfs_config`] — see + /// that function for why the geometry has to travel through a static. + pub fn new(flash: esp_storage::FlashStorage<'static>, partition: LpfsPartition) -> Self { + LPFS_BLOCK_COUNT.store(partition.block_count(), Ordering::Relaxed); + Self { flash, partition } + } + + fn block_offset(&self, block: u32, offset: u32) -> u32 { + self.partition.offset + block * BLOCK_SIZE + offset + } +} + +impl Storage for LpFlashStorage { + fn read(&mut self, block: u32, offset: u32, buf: &mut [u8]) -> Result<(), LfsError> { + let addr = self.block_offset(block, offset); + self.flash.read(addr, buf).map_err(|_| LfsError::Io) + } + + fn write(&mut self, block: u32, offset: u32, data: &[u8]) -> Result<(), LfsError> { + let addr = self.block_offset(block, offset); + self.flash.write(addr, data).map_err(|_| LfsError::Io) + } + + fn erase(&mut self, block: u32) -> Result<(), LfsError> { + let from = self.partition.offset + block * BLOCK_SIZE; + let to = from + BLOCK_SIZE; + self.flash.erase(from, to).map_err(|_| LfsError::Io) + } +} + +/// Block count published by [`LpFlashStorage::new`], read back by +/// [`lpfs_config`]. +static LPFS_BLOCK_COUNT: AtomicU32 = AtomicU32::new(0); + +/// littlefs configuration for the lpfs partition. +/// +/// `LpFsFlash::init` takes the config factory as a bare `fn() -> Config` — a +/// function pointer, with nowhere to put a captured partition — while this +/// crate reads its geometry from the flashed partition table at runtime rather than +/// from a transcribed constant (see the module docs for why). The two are +/// bridged by a static that [`LpFlashStorage::new`] fills in, which is sound +/// because the storage adapter is always constructed before being handed to +/// `init`, on the one thread that exists at boot. +/// +/// A zero block count means this ran before any adapter was built; littlefs +/// rejects it rather than mounting a zero-length filesystem, so the failure is +/// loud. +pub fn lpfs_config() -> Config { + let mut config = Config::new(BLOCK_SIZE, LPFS_BLOCK_COUNT.load(Ordering::Relaxed)); + config.cache_size = CACHE_SIZE; + config.lookahead_size = LOOKAHEAD_SIZE; + config +} diff --git a/lp-fw/fw-esp32v3/src/main.rs b/lp-fw/fw-esp32v3/src/main.rs new file mode 100644 index 000000000..ec340827b --- /dev/null +++ b/lp-fw/fw-esp32v3/src/main.rs @@ -0,0 +1,601 @@ +//! LightPlayer firmware for the classic ESP32 ("v3", WROOM-32E, Xtensa LX6). +//! +//! Boots the LightPlayer app: `LpServer` over a **UART0** transport, backed by +//! a littlefs filesystem in the `lpfs` partition. M3-P1 of the classic-ESP32 +//! bring-up roadmap +//! (`~/.photomancer/planning/lp2025/2026-07-31-1444-classic-esp32-bringup/`), +//! which replays fw-esp32s3's app-layer walk on this chip. +//! +//! ## What this build has, and what it deliberately does not +//! +//! Two of eight `lpa-server` node gates are on (see `Cargo.toml`): +//! `node-shader` and `node-fixture`, the same pair as fw-esp32s3. Every other +//! kind loads inert. +//! +//! The graphics backend is the real `TargetLpvmGraphics`, so a GLSL shader +//! pushed here *compiles* to Xtensa machine code on the board — but it cannot +//! yet *execute*. See the comment at its construction site in +//! [`boot_firmware`]; that wiring is M3-P2. +//! +//! Output is real: `output::rmt` drives WS281x strips from the RMT +//! peripheral, up to four at once — two RMT memory blocks per channel, so the +//! chip's eight slots become four 128-word windows on slots 0/2/4/6, and the +//! channel count comes from the board manifest's `/rmt/ws281xK` resources +//! (M4-P1; ADR `2026-07-31-lp-ws281x-multi-channel-driver-adoption`). +//! +//! Crash recovery is present as of M4-P2: `src/recovery/` installs the +//! `lp-recovery` RTC-RAM ledger, so a fault stages a breadcrumb that survives +//! the reset and is reported on the next boot. It was pulled forward from M7 +//! because the WS281x open path faults faster than this chip can print +//! (`docs/defects/2026-08-01-classic-rmt-open-fault.md`) — the ledger is the +//! only channel that can name it. There is still **no RWDT**; see +//! `recovery::mod`'s docs for why arming one belongs to M7. +//! +//! Absent on purpose: +//! +//! - **Radio.** Linked only behind `radio_ram_probe`, which replaces the +//! entrypoint entirely (M2-P3's RAM ledger). +//! +//! ## Panic posture +//! +//! Abort tier (ADR `2026-07-29-per-chip-fw-toolchains`), same tier as +//! fw-esp32s3: `panic=abort` (`.cargo/config.toml`), no `unwinding`, no +//! `.eh_frame`. + +#![no_std] +#![no_main] +// The app image installs its own `#[alloc_error_handler]` so an allocation +// failure reaches the RTC ledger as an `Oom` record carrying free/used, rather +// than as a generic panic with no heap numbers. Same reason fw-esp32c6 takes +// the feature; the esp toolchain is nightly-based, so it is available here too. +#![cfg_attr( + all(feature = "server", not(feature = "radio_ram_probe")), + feature(alloc_error_handler) +)] +#![allow( + unstable_features, + reason = "alloc_error_handler required for custom OOM handler in no_std" +)] + +// The server path is the whole LightPlayer stack. The hello and probe +// entrypoints install the allocator but never name `alloc` themselves, and +// `unused_extern_crates` is deny-by-default in this workspace's lint table. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +extern crate alloc; + +// `board::esp32v3::init` is the server path's sole `esp_hal::init` call site. +// The hello/probe entrypoint at the bottom of this file keeps its own inline +// init on purpose: it needs the `WIFI` peripheral that `init_board` does not +// hand back, and it is M2-P3's measured code, worth preserving byte for byte. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +mod board; +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +mod flash_storage; +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +mod output; +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +mod recovery; +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +mod serial; + +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +use { + alloc::{boxed::Box, rc::Rc, string::String, sync::Arc}, + board::esp32v3::init::{init_board, start_runtime}, + core::cell::RefCell, + flash_storage::{LpFlashStorage, LpfsPartition, lpfs_config}, + fw_esp32_common::hardware::manifest_loader::load_hardware_manifest, + fw_esp32_common::server_loop::run_server_loop, + fw_esp32_common::time::Esp32TimeProvider, + fw_esp32_common::{boot, logger, lp_fs, transport}, + lp_gfx_lpvm::TargetLpvmGraphics, + lpa_server::{LpGraphics, LpServer}, + lpc_hardware::{HardwareSystem, HwRegistry}, + lpc_shared::output::OutputProvider, + lpfs::LpFsMemory, + lpfs::lp_path::AsLpPath, + output::{Esp32OutputProvider, Esp32V3RmtWs281xDriver}, + serial::io_task, +}; + +esp_bootloader_esp_idf::esp_app_desc!(); + +/// Heap for the allocator. +/// +/// The zero-sum split on this chip is tighter than anywhere else in the +/// family. esp-hal's `dram_seg` is `0x3FFB_0000..0x3FFE_0000` — **192 KB**, +/// against the S3's 341,760 B — and `.data`, `.bss` (which is where this +/// arena lives) and `.stack` all come out of it, `.stack` taking whatever is +/// left (esp-hal's `stack.x`). +/// +/// 100 KB was M2's boot-skeleton figure, chosen when nothing on this board +/// allocated in anger. The server stack does: littlefs caches, the project +/// model, and — the number that matters for M3-P2 — GLSL compilation, which +/// needed a 240 KB heap on the S3 at its measured OOM. That is not reachable +/// here at any setting, which is exactly the measurement G-M3 exists to +/// evaluate. +/// +/// ⚠️ **"As high as it links" is NOT the ceiling to aim for.** Measured on +/// this image: 160 KB fails the link by 4,064 B +/// (`stack.x:11 cannot move location counter backwards`), so the hard limit is +/// ≈155.9 KB — at which `.stack` is *zero* and the board cannot run. The real +/// constraint is stack headroom. At 110 KB the linked image is `.data` 15,212 +/// + `.bss` 134,256 (incl. this 112,640 B arena) + `.stack` 47,136, which +/// keeps the stack near fw-esp32s3's proven 52,896 B — margin the Xtensa +/// windowed ABI's large frames and the recursive GLSL parser both want. +/// Booted and verified on the desk DOM-Z-102: 103,916 B free at idle. +/// +/// M3-P2 may reasonably trade some of that stack for heap once it has +/// measured what an on-device compile actually needs of each. +/// +/// The tempting next lever is `dram2_seg` (`0x3FFE_7E30`, 98,768 B) as a +/// second `esp_alloc` region — but see the ⚠️ at the graphics construction +/// site first: that segment *overlaps* +/// `lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT` +/// (`0x3FFE_8000..0x3FFF_F000`), so handing it to the allocator would hand the +/// JIT's code region to the heap. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +const HEAP_SIZE: usize = 110 * 1024; + +/// Bare hello build (`--no-default-features --features esp32`): M2-P1's +/// skeleton, kept buildable as the minimal bring-up image. +#[cfg(all(not(feature = "server"), not(feature = "radio_ram_probe")))] +const HEAP_SIZE: usize = 100 * 1024; + +/// Probe heap: the radio stack's own DRAM statics come out of the same 192 KB +/// `dram_seg` the arena does, so the arena must shrink for the image to link +/// at all. 72 KB is the experiment repo's proven radio-coexistent size on this +/// chip (led-lab-esp32 `test_stress`). +#[cfg(feature = "radio_ram_probe")] +const HEAP_SIZE: usize = 72 * 1024; + +/// Abort-tier panic handler for the **app** image: stage a breadcrumb into the +/// RTC ledger, commit it, then print and reset. +/// +/// The ordering — ledger before serial — is the whole point on this chip; see +/// `recovery::panic_path`'s module docs. There is no FIFO drain here: the drain +/// in the bare-image handler below exists to get bytes out before a reset this +/// handler controls, and once the record is already committed to RTC RAM, +/// spending 40 ms per line to protect the *serial copy* of information the next +/// boot will print anyway buys nothing. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + recovery::panic_path::stage_and_reset(info) +} + +/// Allocation failure: record it as an `Oom` with the heap counters attached. +/// +/// Rust's default handler panics, which the handler above would catch — but by +/// then the request size is only a formatted string and the free/used numbers +/// are gone. On a chip whose whole difficulty is a 110 KB arena, "how much was +/// left" is the question, so it gets its own path. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +#[alloc_error_handler] +fn on_alloc_error(layout: core::alloc::Layout) -> ! { + recovery::panic_path::stage_oom_and_reset(layout) +} + +/// Abort-tier panic handler for the bare-hello and radio-probe images, which +/// link no ledger: print what panicked, then reset so the next boot starts +/// clean. +/// +/// ⚠️ The spin before the reset is load-bearing, not politeness. `esp-println`'s +/// `uart` feature writes UART0's TX FIFO and returns; `software_reset()` +/// discards whatever has not been clocked out yet. Without the drain, a panic +/// on this board printed exactly `panicked at /` and rebooted — the message +/// that says WHICH file and WHY was still sitting in the FIFO (observed +/// 2026-08-01, M4 bring-up: a full boot-panic diagnosis was invisible). A +/// panic message you cannot read is worth nothing, and this is the only +/// channel this chip has. +/// +/// The spin is deliberately a dumb cycle count rather than a `Delay` or a +/// FIFO-empty poll: the panic path must not touch peripherals or clocks whose +/// state is exactly what may have just gone wrong. 240 MHz × ~40 ms is far +/// more than the ~1.4 ms a full 128-byte FIFO needs at 921600 baud, and the +/// cost is paid only on a boot that is already dead. +#[cfg(not(all(feature = "server", not(feature = "radio_ram_probe"))))] +#[panic_handler] +fn panic(info: &core::panic::PanicInfo) -> ! { + /// Give the TX FIFO time to clock out before the next print or the reset. + fn drain() { + for _ in 0..2_000_000u32 { + core::hint::spin_loop(); + } + } + // FIRST, before printing anything: mask interrupts. + // + // The RMT refill ISR runs continuously while strips are transmitting. If + // the panic came from inside it — or from anything it can re-enter — the + // next interrupt lands in the middle of this handler and panics again, + // and a double panic aborts mid-sentence. That is not hypothetical: it + // is why this board printed `at /U` and rebooted while a driver fault was + // being diagnosed, hiding the file and line for three flash cycles. A + // panic report is only trustworthy if nothing else can run during it. + esp_hal::xtensa_lx::interrupt::disable(); + + // ⚠️ This channel is NOT trustworthy for a fault inside the RMT path. + // + // Measured 2026-08-01 (M4-P2): a fault raised while WS281x channels are + // opening resets the chip in well under a millisecond, no matter what + // this handler does — masking interrupts, draining the FIFO first, + // printing in 4-byte chunks, and trading 46 KB of heap for stack all + // yielded the same ~5 characters. That is the signature of a second + // exception taken inside exception context (window overflow or a + // flash-mapped read with `PS.EXCM` set), which vectors straight to reset + // and cannot be out-run from Rust. + // + // The app image's answer is the RTC ledger above, which does not depend on + // this channel at all. These images have no ledger and no RMT driver, so + // the limitation is recorded rather than fixed: print-and-reset is the + // whole diagnostic budget of a hello image, and it is adequate for one. + // `docs/defects/2026-08-01-classic-rmt-open-fault.md`. + drain(); + esp_println::println!("\n\n====================== PANIC ======================"); + drain(); + if let Some(loc) = info.location() { + esp_println::println!("at {}:{}", loc.file(), loc.line()); + } else { + esp_println::println!("at "); + } + drain(); + esp_println::println!("msg: {}", info.message()); + drain(); + esp_hal::system::software_reset() +} + +/// Heap free/used for the heartbeat. A chip fact `fw-esp32-common` must not +/// know, so it is injected. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +fn esp32_memory_stats() -> Option<(u32, u32)> { + Some(( + esp_alloc::HEAP.free().min(u32::MAX as usize) as u32, + esp_alloc::HEAP.used().min(u32::MAX as usize) as u32, + )) +} + +/// Everything `main` needs to hand to the server loop. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +struct FirmwareApp { + server: LpServer, + transport: transport::StreamingMessageRouterTransport, + time_provider: Esp32TimeProvider, +} + +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +#[inline(never)] +fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { + // ⚠️ `init_board` takes the `esp_hal` peripheral singleton, and taking it + // twice panics. This is the app path's ONLY call to `esp_hal::init`. + let (sw_int, timg0, uart0, flash, rmt_peripheral) = init_board(); + // The heap is main.rs's, not the board's — mirroring fw-esp32s3. + esp_alloc::heap_allocator!(size: HEAP_SIZE); + esp_println::println!("[INIT] fw-esp32v3 boot"); + esp_println::println!("[INIT] chip=esp32 arch=xtensa heap={HEAP_SIZE}"); + + // Crash recovery first, before anything crash-prone runs: this both reports + // the previous run and gives everything after it somewhere to leave a + // breadcrumb. It cannot run before `heap_allocator!` — `init_and_report` + // leaks its instance into a `&'static mut`. + // + // No RWDT is armed alongside it (contrast fw-esp32s3, which starts a + // `WatchdogFeeder` on the next line); see `recovery`'s module docs. + let boot_assessment = recovery::boot_report::init_and_report(); + let boot_guard = lp_recovery::enter(lp_recovery::FrameKind::Boot, "boot").ok(); + + start_runtime(timg0, sw_int); + esp_println::println!("[INIT] runtime started"); + + match uart0 { + Ok(uart) => { + spawner.spawn(io_task(uart).unwrap()); + esp_println::println!("[INIT] I/O task spawned (uart0 921600 8N1)"); + } + Err(error) => { + // The board keeps booting: `esp_println` writes UART0's FIFO + // directly and still reaches a monitor, so a reachable-but-mute + // device that says why beats a reset loop. + esp_println::println!( + "[ERROR] UART0 config failed ({error:?}); no host link this boot" + ); + } + } + + // From here on `log::*` reaches the host over the same serial link; the + // `esp_println!` lines above are the pre-transport ones. + logger::init(serial::io_task::log_write_to_outgoing); + + let (incoming, _) = serial::io_task::get_message_channels(); + let (write_request, write_result) = serial::io_task::get_server_write_channels(); + let transport = + transport::StreamingMessageRouterTransport::new(incoming, write_request, write_result); + + let base_fs = mount_filesystem(flash); + + // The compiled-in fallback is the DOM-Z-102 profile — the desk board and + // the roadmap's WLED-class exemplar. An `/hardware.json` on the device + // overrides it, which is how a different classic carrier gets described. + let hardware_manifest = load_hardware_manifest( + base_fs.as_ref(), + lpc_hardware::default_esp32v3_hardware_manifest, + ); + log::info!( + "[fw-esp32v3] hardware manifest: {} ({})", + hardware_manifest.board_id(), + hardware_manifest.board_name() + ); + let hardware_registry = Rc::new(HwRegistry::new(hardware_manifest)); + let mut hardware_system = HardwareSystem::new(Rc::clone(&hardware_registry)); + + // The RMT peripheral becomes the WS281x driver's, clock and all. The + // classic's RMT runs off APB and esp-hal's `validate_clock` for this chip + // accepts only the source frequency itself, so 80 MHz is not a preference + // — with the per-channel divider of 1 it gives the 12.5 ns tick + // `lp_ws281x::PulseCodes` assumes. A failure here is a clock-tree problem, + // and it costs the board its output rather than its boot, so it is logged + // and not fatal. + // + // How many outputs appear is decided in two places and nowhere else: the + // board manifest's `/rmt/ws281xK` resources (four on the DOM-Z-102), and + // `output::rmt::v3_rmt::BLOCKS_PER_CHANNEL` = 2, which turns the chip's + // eight RMT slots into four usable ones (0/2/4/6 — a two-block channel + // absorbs its neighbour's memory). Manifest channel K drives slot + // K * SLOT_STRIDE; absorbed slots are never configured. + match esp_hal::rmt::Rmt::new(rmt_peripheral, output::rmt::shared_driver::RMT_CLOCK) { + Ok(rmt) => { + hardware_system.add_ws281x_driver(Box::new(Esp32V3RmtWs281xDriver::new( + Rc::clone(&hardware_registry), + rmt, + ))); + } + Err(error) => { + esp_println::println!("[ERROR] RMT init failed ({error:?}); no LED output this boot"); + } + } + // No button and no radio driver: neither is ported, and `LpServer` takes + // both services as `Option`, so they are simply absent rather than stubbed. + let hardware_system = Rc::new(hardware_system); + + // The provider itself is chip-agnostic and comes from fw-esp32-common + // untouched; only the driver registered above is chip-side. + let output_provider: Rc> = + Rc::new(RefCell::new(Esp32OutputProvider::new(hardware_system))); + + // Stamped device identity: read the fs-root `/.lp/device.json` once at boot + // for the hello (missing file → unstamped, `None`). + let device_uid = lpa_server::device_identity::read_device_uid(base_fs.as_ref()); + + // `TargetLpvmGraphics` resolves to `lpvm-native`'s `NativeJitEngine` on + // Xtensa: GLSL pushed here compiles to Xtensa machine code ON THE BOARD. + // On this chip the engine's link step takes the **placed** path + // (`lpvm-native` feature `xt-placed-code`, see Cargo.toml): the heap has + // no I-bus view, so every module is linked at a span of the fixed SRAM1 + // code region and installed through the word-mirrored D-bus walk. The + // install below hands the engine that region; without it the first + // compile fails with a clean "arena not installed" error. + // + // Region facts (measured; see `lpvm_native::codemem_esp32`): + // * `CodeRegion::ESP32_DEFAULT` = D-bus `0x3FFE_8000..0x3FFF_F000`, + // I-bus image `0x400A_1000..0x400B_8000` (92 KiB of JIT code). + // * The linker cannot collide with it — esp-hal's `dram_seg` ends at + // `0x3FFE_0000` — but it DOES overlap esp-hal's `dram2_seg` + // (`0x3FFE_7E30`, 98,768 B). If anyone adds dram2_seg as a second + // `esp_alloc` region to buy heap headroom, it must stop below + // `0x3FFE_8000` or the allocator and the JIT will hand out the same + // bytes. + // * The frontend is passed, never defaulted: `LpGraphics::glsl_frontend` + // has no default impl so every host states its choice, and the device + // ships `LpsGlsl`. + lpvm_native::codemem_esp32::global::install( + lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT, + ); + esp_println::println!( + "[INIT] JIT code region: ibus {:#010x}..{:#010x} (92 KiB, placed)", + lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT.ibus_base(), + lpvm_native::codemem_esp32::CodeRegion::ESP32_DEFAULT.ibus_end(), + ); + let graphics: Arc = + Arc::new(TargetLpvmGraphics::new(lpa_server::DEVICE_SHADER_FRONTEND)); + + let time_provider_rc = Rc::new(Esp32TimeProvider::new()); + let mut server = LpServer::new_with_hardware_services( + output_provider, + base_fs, + "projects/".as_path(), + Some(esp32_memory_stats), + Some(time_provider_rc), + None, + None, + graphics, + ); + server.set_hello(lpc_wire::ServerHello { + proto: lpc_wire::WIRE_PROTO_VERSION, + fw: lpc_wire::FwProvenance { + package: String::from("fw-esp32v3"), + commit: String::from(env!("LP_BUILD_COMMIT")), + dirty: env!("LP_BUILD_DIRTY") == "true", + profile: String::from(env!("LP_BUILD_PROFILE")), + }, + device_uid, + }); + + // Auto-load a project at boot — unless repeated incomplete boots put us in + // safe mode, in which case the server comes up reachable but nothing + // crash-prone is loaded. + // + // On this chip that branch is not a formality. `quad-strips-v3` currently + // reset-loops the board from inside the WS281x open path + // (`docs/defects/2026-08-01-classic-rmt-open-fault.md`), and before the + // ledger landed the loop was unbreakable from the host: the board never + // stayed up long enough to accept a delete, and clearing it meant erasing + // the lpfs partition with espflash. Safe mode is what turns that into a + // board you can talk to. + if boot_assessment.safe_mode { + let incomplete_boots = lp_recovery::snapshot() + .map(|s| s.consecutive_incomplete_boots) + .unwrap_or(0); + log::error!( + "[RECOVERY] SAFE MODE: {incomplete_boots} consecutive incomplete boots — skipping project auto-load" + ); + } else { + boot::auto_load_project(&mut server); + } + + // Boot frame ends here. The boot-complete milestone is NOT marked here: the + // server loop marks it after the first successfully served frame, which is + // a far stronger claim than "reached the end of boot". + drop(boot_guard); + + FirmwareApp { + server, + transport, + time_provider: Esp32TimeProvider::new(), + } +} + +/// Mount the `lpfs` partition, falling back to RAM so an unformattable or +/// mis-flashed board still comes up reachable and can say so over the wire. +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +fn mount_filesystem(flash: esp_hal::peripherals::FLASH<'static>) -> Box { + let mut flash_storage = esp_storage::FlashStorage::new(flash); + let Some(partition) = LpfsPartition::locate(&mut flash_storage) else { + // Not a runtime condition: it means the image was flashed without + // `--partition-table lp-fw/fw-esp32v3/partitions.csv` and espflash + // silently substituted its own default. Say so rather than guess an + // offset and erase running code. + esp_println::println!( + "[ERROR] no `lpfs` partition in the flashed table — reflash with \ + --partition-table lp-fw/fw-esp32v3/partitions.csv; using memory FS" + ); + return Box::new(LpFsMemory::new()); + }; + match lp_fs::LpFsFlash::init(LpFlashStorage::new(flash_storage, partition), lpfs_config) { + Ok(fs) => { + esp_println::println!("[INIT] flash filesystem mounted"); + Box::new(fs) + } + Err(e) => { + esp_println::println!("[WARN] flash FS failed: {e}, falling back to memory"); + Box::new(LpFsMemory::new()) + } + } +} + +#[cfg(all(feature = "server", not(feature = "radio_ram_probe")))] +#[esp_rtos::main] +async fn main(spawner: embassy_executor::Spawner) { + let app = boot_firmware(spawner); + + // ⚠️ The substring "fw-esp32 initialized, starting server loop" is matched + // literally by `lpa_link::device_session::device_readiness` (and by lp-cli + // fwcheck through it). It is chip-agnostic on purpose. + esp_println::println!( + "[INIT] fw-esp32 initialized, starting server loop... proto={} commit={} dirty={}", + lpc_wire::WIRE_PROTO_VERSION, + env!("LP_BUILD_COMMIT"), + env!("LP_BUILD_DIRTY"), + ); + + // The watchdog feed is a no-op: no RWDT is armed (see the module docs), + // and the server loop takes the feeder as a closure precisely so a chip + // without one pays nothing. + run_server_loop( + app.server, + app.transport, + app.time_provider, + esp32_memory_stats, + |_now_ms| {}, + ) + .await; +} + +/// Boot-to-hello entrypoint: the M2-P1 skeleton (bare build) and the M2-P3 +/// radio RAM probe. Both replace the server app rather than extending it. +#[cfg(any( + feature = "radio_ram_probe", + all(not(feature = "server"), not(feature = "radio_ram_probe")) +))] +#[esp_hal::main] +fn main() -> ! { + // `esp_hal::init` disables the RTC super watchdog (where present), the RTC + // watchdog (RWDT), and both TIMG watchdogs unconditionally (esp-hal 1.1.1 + // `lib.rs::init`). `CpuClock::max()` is 240 MHz on this chip; printed and + // measured timings assume the fast clock. + let peripherals = + esp_hal::init(esp_hal::Config::default().with_cpu_clock(esp_hal::clock::CpuClock::max())); + + // ⚠️ Load-bearing, not decorative — the baud-divisor fix. See the same + // comment (at length) in `board::esp32v3::init::init_board`, which is the + // server path's copy: `esp-println`'s `uart` feature writes UART0's FIFO + // but never programs the divisor, and `esp_hal::init` above has just + // moved the clock. Constructing this `Uart` is what makes every + // `esp_println!` below legible. It must stay bound, not dropped. + let _uart0 = esp_hal::uart::Uart::new(peripherals.UART0, esp_hal::uart::Config::default()) + .expect("uart0 config") + .with_tx(peripherals.GPIO1) + .with_rx(peripherals.GPIO3); + + esp_alloc::heap_allocator!(size: HEAP_SIZE); + + esp_println::println!("[INIT] fw-esp32v3 boot"); + esp_println::println!( + "[INIT] chip=esp32 arch=xtensa heap_free={}", + esp_alloc::HEAP.free() + ); + + // M2-P3 RAM probe: bring the radio stack all the way up to an initialised + // STA controller — the deployment-shaped memory layout — printing the heap + // ledger at each stage. `[PROBE]` lines are the phase deliverable; the + // stage traces make a wedged boot attributable. + #[cfg(feature = "radio_ram_probe")] + { + esp_println::println!( + "[PROBE] stage=pre_rtos heap_size={HEAP_SIZE} heap_free={} heap_used={}", + esp_alloc::HEAP.free(), + esp_alloc::HEAP.used() + ); + let timg0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG0); + let sw_int = + esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); + esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); + esp_println::println!( + "[PROBE] stage=rtos_started heap_free={} heap_used={}", + esp_alloc::HEAP.free(), + esp_alloc::HEAP.used() + ); + let (mut controller, _interfaces) = + esp_radio::wifi::new(peripherals.WIFI, Default::default()) + .expect("radio probe: wifi init"); + esp_println::println!( + "[PROBE] stage=wifi_new heap_free={} heap_used={}", + esp_alloc::HEAP.free(), + esp_alloc::HEAP.used() + ); + let station_config = esp_radio::wifi::sta::StationConfig::default(); + controller + .set_config(&esp_radio::wifi::Config::Station(station_config)) + .expect("radio probe: sta config"); + esp_println::println!( + "[PROBE] stage=sta_started heap_free={} heap_used={}", + esp_alloc::HEAP.free(), + esp_alloc::HEAP.used() + ); + // Keep the controller alive so the heartbeat below reports the + // steady-state radio-on ledger, not a post-drop one. + core::mem::forget(controller); + esp_println::println!("[PROBE] done — heartbeat shows steady-state radio-on heap"); + } + + esp_println::println!("[INIT] ready"); + + let delay = esp_hal::delay::Delay::new(); + let mut uptime_s: u32 = 0; + loop { + delay.delay_millis(1000); + uptime_s += 1; + esp_println::println!( + "[HEARTBEAT] uptime_s={uptime_s} heap_free={}", + esp_alloc::HEAP.free() + ); + } +} diff --git a/lp-fw/fw-esp32v3/src/output/mod.rs b/lp-fw/fw-esp32v3/src/output/mod.rs new file mode 100644 index 000000000..bd190548f --- /dev/null +++ b/lp-fw/fw-esp32v3/src/output/mod.rs @@ -0,0 +1,13 @@ +//! Output for the classic-ESP32 app layer. +//! +//! The provider itself is chip-agnostic and comes from `fw-esp32-common` +//! unchanged; only the *driver* below it is chip-side. That driver is +//! [`rmt::Esp32V3RmtWs281xDriver`]: real RMT output on up to four channels at +//! once (two RMT memory blocks each — see +//! [`rmt::v3_rmt::BLOCKS_PER_CHANNEL`]), backed by the portable `lp-ws281x` +//! transmitter. + +pub mod rmt; + +pub use fw_esp32_common::output::provider::Esp32OutputProvider; +pub use rmt::Esp32V3RmtWs281xDriver; diff --git a/lp-fw/fw-esp32v3/src/output/rmt/esp32v3_rmt_ws281x_driver.rs b/lp-fw/fw-esp32v3/src/output/rmt/esp32v3_rmt_ws281x_driver.rs new file mode 100644 index 000000000..acf79a25a --- /dev/null +++ b/lp-fw/fw-esp32v3/src/output/rmt/esp32v3_rmt_ws281x_driver.rs @@ -0,0 +1,651 @@ +//! The registry-facing WS281x driver: four concurrent RMT outputs on one +//! classic ESP32. +//! +//! This is the seam between `lpc-hardware`'s endpoint/lease vocabulary and +//! [`lp_ws281x`]'s transmitter. It owns no sequencing and no register +//! knowledge — those are [`super::shared_driver::DRIVER`] and +//! [`super::v3_rmt`] respectively — only the mapping from *authored* endpoint +//! names to hardware. +//! +//! Structurally this is `fw-esp32s3`'s `Esp32S3RmtWs281xDriver` with one extra +//! indirection, described next. +//! +//! # Manifest channel → RMT slot: the absorbed-slot fix +//! +//! On the S3 a manifest resource `/rmt/ws281xK` *is* RMT channel `K`, because +//! every channel gets one memory block. The classic ships two blocks per +//! channel ([`super::v3_rmt::BLOCKS_PER_CHANNEL`], and see the §12 reasoning +//! there), and a channel that takes two blocks absorbs its neighbour's — so +//! only slots **0, 2, 4, 6** own memory and only they can be configured. +//! +//! The mapping is therefore `slot = K * SLOT_STRIDE`, resolved once by +//! [`super::v3_rmt::slot_for_index`], and the channel-creation loop below +//! *never offers an absorbed slot to `configure_tx` in the first place*. That +//! is the difference from the experiment harness, whose +//! `BLOCKS_PER_CHANNEL=2` configuration failed at `configure_tx`: it kept +//! asking esp-hal for channels 0,1,2,3, and esp-hal correctly answered +//! `MemoryBlockNotAvailable` for 1 and 3 because channel 0's and channel 2's +//! two-block windows already owned their RAM. +//! +//! [`ChannelSlot`] consequently carries both identities: `timing` is the +//! manifest address (`/rmt/ws281x1`), `rmt_channel` is the silicon slot (2). +//! The registry only ever sees the former; [`super::v3_rmt`] only ever sees +//! the latter. +//! +//! # Why the pin is bound at `open`, not at construction +//! +//! An endpoint is a board label (`ws281x:rmt:IO18`), and which label a project +//! drives is authored data that arrives long after boot. So the RMT channel is +//! configured up front (its memory blocks and interrupt are chip resources, +//! not project ones) and its **pin** is connected when a project opens the +//! endpoint. `init_board` hands out no GPIO tokens, so the pad is recreated +//! with [`AnyPin::steal`] under the registry lease that has just granted +//! exclusive use of that address — see the SAFETY note at the call site. +//! +//! # Timing +//! +//! Every channel is opened WS2812-class (GRB, 300 µs latch). The strip's own +//! colour order is the fixture node's `color_order`, applied above this +//! boundary; the driver stays GRB exactly as the S3's and the C6's do. + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::format; +use alloc::rc::Rc; +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; +use core::cell::RefCell; + +use esp_hal::Blocking; +use esp_hal::gpio::{AnyPin, Level}; +use esp_hal::rmt::{Channel, Rmt, Tx, TxChannelConfig, TxChannelCreator}; +use esp_hal::time::Instant; +use lp_ws281x::{ChannelTiming, StartError}; +use lpc_hardware::{ + HardwareEndpointError, HardwareLease, HwAddress, HwCapability, HwClaim, HwDriver, HwEndpoint, + HwEndpointId, HwEndpointKind, HwEndpointSpec, HwEndpointStatus, HwRegistry, OutputError, + Ws281xConfig, Ws281xDriver, Ws281xOutput, +}; + +use crate::output::rmt::shared_driver::{ + DRIVER, FRAME_TIMEOUT, install_isr, report_telemetry_if_due, +}; +use crate::output::rmt::v3_rmt::{ + self, BLOCKS_PER_CHANNEL, CHANNEL_WORDS, SLOT_STRIDE, TX_BLOCKS, USABLE_CHANNELS, + slot_for_index, +}; + +const DRIVER_ID: &str = "esp32v3-rmt-ws281x"; +const DISPLAY_LABEL: &str = "ESP32 RMT WS281x"; + +/// One RMT TX channel this driver can hand out. +struct ChannelSlot { + /// The manifest resource backing this channel (`/rmt/ws281xK`), claimed + /// alongside the GPIO for as long as an output holds it. `K` is the + /// *manifest* index, which on this chip is not the RMT slot number — see + /// the module docs. + timing: HwAddress, + /// The silicon RMT channel the manifest index resolved to (`K * + /// SLOT_STRIDE`). Everything below `lp_ws281x` speaks this number. + rmt_channel: u8, + /// The configured esp-hal channel. `Option` only because + /// [`Channel::with_pin`] consumes and returns it; it is `Some` except + /// inside that swap. + tx: Option>, + /// Held by an open [`Esp32V3RmtWs281xOutput`]. + in_use: bool, +} + +/// The channels, shared between the driver and every output it hands out. +/// +/// Indexed by **manifest** channel `K`, not by RMT slot: a board that declares +/// only some `/rmt/ws281xK` resources leaves the others `None` rather than +/// shifting the numbering. +type ChannelTable = Rc; USABLE_CHANNELS]>>; + +/// WS281x driver over the classic-ESP32 RMT, one endpoint per board-labelled +/// GPIO and up to one live output per declared `/rmt/ws281xK` resource. +pub struct Esp32V3RmtWs281xDriver { + registry: Rc, + channels: ChannelTable, +} + +impl Esp32V3RmtWs281xDriver { + /// Bind the RMT interrupt and configure one TX channel per `/rmt/ws281xK` + /// resource the manifest declares, on the slot the block plan gives it. + /// + /// Channels are configured (memory blocks, divider, idle level) but not + /// connected to any pin: that happens in [`Ws281xDriver::open`]. A resource + /// the chip cannot back — an index past [`USABLE_CHANNELS`], or one whose + /// slot the [`BlockPlan`](lp_ws281x::BlockPlan) absorbed — is skipped + /// silently at the `slot_for_index` step rather than offered and then + /// failing to open. + pub fn new(registry: Rc, mut rmt: Rmt<'static, Blocking>) -> Self { + install_isr(&mut rmt); + + let config = TxChannelConfig::default() + .with_clk_divider(1) + .with_idle_output(true) + .with_idle_output_level(Level::Low) + .with_carrier_modulation(false) + .with_memsize(BLOCKS_PER_CHANNEL); + + let mut slots: [Option; USABLE_CHANNELS] = [const { None }; USABLE_CHANNELS]; + + // The eight creators are distinct types (`ChannelCreator<_, _, K>`), so + // they cannot be iterated; each is named once and consumed only when + // the block plan says its slot owns memory AND the manifest declares + // the timing resource that maps to it. An absorbed slot never reaches + // `configure_tx` — that is the fix this port exists to make. + macro_rules! adopt_slot { + ($($slot:literal => $creator:ident),+ $(,)?) => { + $( + if let Some(index) = manifest_index_for_slot($slot) { + if declares_timing_resource(®istry, index) { + slots[index] = adopt_channel( + index, + $slot, + rmt.$creator.configure_tx(&config), + ); + } + } + )+ + }; + } + adopt_slot!( + 0 => channel0, + 1 => channel1, + 2 => channel2, + 3 => channel3, + 4 => channel4, + 5 => channel5, + 6 => channel6, + 7 => channel7, + ); + + // The wrap bit is global on this chip and load-bearing (findings §11.5 + // — without it ping-pong refill does not work at all), so it is set + // once here, after esp-hal has finished touching `APB_CONF` in + // `configure_tx`. + v3_rmt::init_tx(); + + log::info!( + "Esp32V3RmtWs281xDriver: {} of {} usable RMT TX channels available for WS281x \ + output (blocks/channel={} slot_stride={} window_words={} half_words={})", + slots.iter().flatten().count(), + USABLE_CHANNELS, + BLOCKS_PER_CHANNEL, + SLOT_STRIDE, + CHANNEL_WORDS, + CHANNEL_WORDS / 2, + ); + + Self { + registry, + channels: Rc::new(RefCell::new(slots)), + } + } + + fn endpoint_id(&self, spec: &HwEndpointSpec) -> HwEndpointId { + HwEndpointId::for_driver_spec(self.driver_id(), spec) + } + + /// The lowest manifest channel that is configured, unclaimed, and whose + /// timing resource the registry still reports as free. + fn free_channel(&self) -> Option { + let slots = self.channels.borrow(); + slots.iter().enumerate().find_map(|(index, slot)| { + let slot = slot.as_ref()?; + if slot.in_use + || !self + .registry + .endpoint_status_for(&slot.timing) + .is_available() + { + return None; + } + Some(index) + }) + } + + /// How many RMT channels this board offers at all — the denominator in the + /// "all channels in use" message, and zero on a board whose manifest + /// declares no WS281x timing resource. + fn offered_channels(&self) -> usize { + self.channels.borrow().iter().flatten().count() + } + + fn endpoint_status(&self, gpio_address: &HwAddress) -> HwEndpointStatus { + let gpio_status = self.registry.endpoint_status_for(gpio_address); + if !gpio_status.is_available() { + return gpio_status; + } + match self.free_channel() { + Some(_) => HwEndpointStatus::Available, + None => HwEndpointStatus::Unavailable { + reason: format!( + "all {} RMT WS281x channels are in use", + self.offered_channels() + ), + }, + } + } + + fn gpio_for_endpoint( + &self, + endpoint_id: &HwEndpointId, + ) -> Result { + for endpoint in self.endpoints() { + if endpoint.id() == endpoint_id { + return Ok(endpoint.address().clone()); + } + } + + Err(HardwareEndpointError::UnknownEndpoint { + kind: HwEndpointKind::Ws281x, + endpoint_id: endpoint_id.clone(), + }) + } + + /// Connect `gpio` to manifest channel `index` and arm its wire timing. + /// + /// Called with the registry lease for both addresses already held. Returns + /// the RMT slot the channel actually drives, which is what the output + /// handle then talks to. + fn bind_channel(&self, index: usize, gpio: u8) -> Result { + let mut slots = self.channels.borrow_mut(); + let Some(slot) = slots[index].as_mut() else { + return Err(HardwareEndpointError::Other { + message: format!("RMT WS281x channel {index} is not configured"), + }); + }; + let Some(tx) = slot.tx.take() else { + return Err(HardwareEndpointError::Other { + message: format!("RMT WS281x channel {index} has no transmitter"), + }); + }; + let ch = slot.rmt_channel; + + // SAFETY: `init_board` drops the concrete HAL GPIO tokens after + // startup, so the pad has to be recreated here. Exclusivity is the + // registry's: the caller holds a lease on this GPIO address for the + // lifetime of the output handle, and every path to a pin in this + // firmware goes through such a lease, so no second token for this pad + // can exist while this one does. `gpio` was checked against the chip's + // pin list by `gpio_number`, so the panicking branch of `steal` is + // unreachable. + let pin = unsafe { AnyPin::steal(gpio) }; + slot.tx = Some(tx.with_pin(pin)); + + // Leave the window all-STOP until the first frame prefills it, so a + // spurious start can only transmit nothing. + v3_rmt::clear_ram(&TX_BLOCKS, ch); + + DRIVER + .configure_default_clock(ch, &ChannelTiming::WS2812) + .map_err(|error| HardwareEndpointError::Other { + message: format!("RMT channel {ch} timing configuration failed: {error:?}"), + })?; + slot.in_use = true; + Ok(ch) + } +} + +impl HwDriver for Esp32V3RmtWs281xDriver { + fn driver_id(&self) -> &str { + DRIVER_ID + } + + fn display_label(&self) -> &str { + DISPLAY_LABEL + } +} + +impl Ws281xDriver for Esp32V3RmtWs281xDriver { + fn endpoints(&self) -> Vec { + if self.offered_channels() == 0 { + return Vec::new(); + } + + let mut endpoints = Vec::new(); + for resource in self.registry.manifest().resources() { + if !resource.supports(HwCapability::GpioOutput) + || !has_board_assigned_label(resource.address(), resource.display_label()) + { + continue; + } + let address = resource.address().clone(); + let spec = ws281x_rmt_spec(resource.display_label()); + endpoints.push(HwEndpoint::new( + self.endpoint_id(&spec), + spec, + HwEndpointKind::Ws281x, + self.driver_id(), + address, + resource.display_label(), + self.endpoint_status(resource.address()), + )); + } + endpoints + } + + fn open( + &self, + endpoint_id: &HwEndpointId, + config: Ws281xConfig, + ) -> Result, HardwareEndpointError> { + validate_byte_count(config.byte_count())?; + let gpio_address = self.gpio_for_endpoint(endpoint_id)?; + let gpio = gpio_number(&gpio_address)?; + + let endpoint = self + .endpoints() + .into_iter() + .find(|endpoint| endpoint.id() == endpoint_id) + .ok_or_else(|| HardwareEndpointError::UnknownEndpoint { + kind: HwEndpointKind::Ws281x, + endpoint_id: endpoint_id.clone(), + })?; + if !endpoint.is_available() { + return Err(HardwareEndpointError::EndpointUnavailable { + endpoint_id: endpoint_id.clone(), + reason: endpoint + .status() + .unavailable_reason() + .unwrap_or("endpoint unavailable") + .into(), + }); + } + + let index = + self.free_channel() + .ok_or_else(|| HardwareEndpointError::EndpointUnavailable { + endpoint_id: endpoint_id.clone(), + reason: format!( + "all {} RMT WS281x channels are in use", + self.offered_channels() + ), + })?; + let timing_address = HwAddress::rmt_ws281x(index as u8); + + self.registry + .ensure_capability(&gpio_address, HwCapability::GpioOutput)?; + self.registry + .ensure_capability(&timing_address, HwCapability::Rmt)?; + self.registry + .ensure_capability(&timing_address, HwCapability::Ws281xOutput)?; + let lease = self.registry.claim_bundle(HwClaim::new( + self.driver_id(), + vec![gpio_address.clone(), timing_address], + ))?; + + let ch = match self.bind_channel(index, gpio) { + Ok(ch) => ch, + Err(error) => { + let _ = self.registry.release(&lease); + return Err(error); + } + }; + + log::info!( + "Esp32V3RmtWs281xDriver::open: endpoint={endpoint_id} gpio={} ws281x_ch={index} \ + rmt_slot={ch} bytes={}", + gpio_address.as_str(), + config.byte_count(), + ); + + Ok(Box::new(Esp32V3RmtWs281xOutput { + registry: Rc::clone(&self.registry), + channels: Rc::clone(&self.channels), + lease: Some(lease), + index, + channel: ch, + byte_count: config.byte_count(), + })) + } +} + +/// One opened WS281x output: an RMT channel, its registry lease, and the frame +/// size writes must match. +/// +/// Never named outside this module — callers get a `Box`. +struct Esp32V3RmtWs281xOutput { + registry: Rc, + channels: ChannelTable, + lease: Option, + /// Manifest channel index — the slot table's key. + index: usize, + /// RMT slot — what `lp_ws281x` and the register backend address. + channel: u8, + byte_count: u32, +} + +impl Ws281xOutput for Esp32V3RmtWs281xOutput { + fn write(&mut self, data: &[u8]) -> Result<(), OutputError> { + let expected_len = byte_len_for_byte_count(self.byte_count); + if data.len() != expected_len { + return Err(OutputError::DataLengthMismatch { + expected: expected_len as u32, + actual: data.len(), + }); + } + + // Blocking send: the borrow of `data` provably outlives the + // transmission because `send_blocking` does not return until the + // channel reports complete, and it aborts the channel on every other + // exit path. The spin callback is the hang detector — a frame that + // outlives its deadline is aborted and reported rather than wedging + // the render loop forever. + let started = Instant::now(); + let mut timed_out = false; + let result = DRIVER.send_blocking(self.channel, data, || { + if !timed_out && started.elapsed() > FRAME_TIMEOUT { + timed_out = true; + DRIVER.abort(self.channel); + } + }); + + // After the frame, never during it, and a no-op unless the + // `ws281x_telemetry` feature is on. + report_telemetry_if_due(); + + if let Err(error) = result { + return Err(start_error_to_output_error(self.channel, error)); + } + if timed_out { + return Err(OutputError::Other { + message: format!( + "RMT channel {} frame did not complete within {} ms", + self.channel, + FRAME_TIMEOUT.as_millis(), + ), + }); + } + Ok(()) + } + + fn resize(&mut self, config: Ws281xConfig) -> Result<(), OutputError> { + validate_byte_count(config.byte_count()).map_err(endpoint_error_to_output_error)?; + self.byte_count = config.byte_count(); + Ok(()) + } +} + +impl Drop for Esp32V3RmtWs281xOutput { + /// Stop anything in flight, hand the RMT channel back to the free list, and + /// release the lease — in that order, so the channel is never offered to a + /// new open while its transmitter is still running. + fn drop(&mut self) { + DRIVER.abort(self.channel); + if let Some(slot) = self.channels.borrow_mut()[self.index].as_mut() { + slot.in_use = false; + } + if let Some(lease) = self.lease.take() { + if let Err(error) = self.registry.release(&lease) { + log::warn!("Esp32V3RmtWs281xOutput: failed to release hardware lease: {error}"); + } + } + } +} + +/// The manifest channel index an RMT slot backs, or `None` when the slot is +/// absorbed by a lower channel's window (or is past the usable range). +/// +/// The inverse of [`slot_for_index`], and the guard that keeps the +/// channel-creation macro from ever handing an absorbed slot to +/// `configure_tx`. +const fn manifest_index_for_slot(slot: usize) -> Option { + if slot % SLOT_STRIDE != 0 { + return None; + } + let index = slot / SLOT_STRIDE; + if index >= USABLE_CHANNELS { + return None; + } + match slot_for_index(index) { + Some(back) if back as usize == slot => Some(index), + _ => None, + } +} + +/// Does the manifest declare `/rmt/ws281x` with both WS281x +/// capabilities? +/// +/// The block-plan half of the question is already answered by +/// [`manifest_index_for_slot`], which is why — unlike the S3's version of this +/// function — there is no `is_available` check here. +fn declares_timing_resource(registry: &HwRegistry, index: usize) -> bool { + let address = HwAddress::rmt_ws281x(index as u8); + registry + .ensure_capability(&address, HwCapability::Rmt) + .is_ok() + && registry + .ensure_capability(&address, HwCapability::Ws281xOutput) + .is_ok() +} + +/// Turn a freshly configured esp-hal channel into a slot, or log why not. +fn adopt_channel( + index: usize, + slot: usize, + configured: Result, esp_hal::rmt::ConfigError>, +) -> Option { + let ch = slot as u8; + match configured { + Ok(tx) => { + v3_rmt::enable_tx_interrupts(ch); + Some(ChannelSlot { + timing: HwAddress::rmt_ws281x(index as u8), + rmt_channel: ch, + tx: Some(tx), + in_use: false, + }) + } + Err(error) => { + log::error!( + "Esp32V3RmtWs281xDriver: RMT slot {slot} (ws281x{index}) configure_tx failed: \ + {error:?}" + ); + None + } + } +} + +fn validate_byte_count(byte_count: u32) -> Result<(), HardwareEndpointError> { + if byte_count < 3 { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: "WS281x byte_count must be at least 3".into(), + }); + } + Ok(()) +} + +fn byte_len_for_byte_count(byte_count: u32) -> usize { + ((byte_count / 3) as usize) * 3 +} + +fn start_error_to_output_error(channel: u8, error: StartError) -> OutputError { + match error { + StartError::Busy => OutputError::Other { + message: format!("RMT channel {channel} is still transmitting the previous frame"), + }, + other => OutputError::InvalidConfig { + reason: format!("RMT channel {channel} cannot start a frame: {other:?}"), + }, + } +} + +fn endpoint_error_to_output_error(error: HardwareEndpointError) -> OutputError { + match error { + HardwareEndpointError::Hardware { error } => OutputError::Hardware { error }, + other => OutputError::InvalidConfig { + reason: other.to_string(), + }, + } +} + +fn gpio_number(address: &HwAddress) -> Result { + let Some(raw) = address.as_str().strip_prefix("/gpio/") else { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("WS281x endpoint address is not a GPIO: {address}"), + }); + }; + let gpio = raw + .parse::() + .map_err(|_| HardwareEndpointError::UnsupportedConfig { + reason: format!("invalid classic-ESP32 GPIO address: {address}"), + })?; + if !gpio_exists(gpio) { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("classic ESP32 has no GPIO {gpio}"), + }); + } + if !gpio_can_output(gpio) { + return Err(HardwareEndpointError::UnsupportedConfig { + reason: format!("classic-ESP32 GPIO {gpio} is input-only and cannot drive WS281x data"), + }); + } + Ok(gpio) +} + +/// Does this chip have GPIO `pin` at all? +/// +/// The classic ESP32 numbers **GPIO0-5, GPIO12-19, GPIO21-23, GPIO25-27 and +/// GPIO32-39**: 6-11 are the SPI flash pins and are not bonded out on +/// WROOM-class modules, and 20, 24 and 28-31 do not exist at all. Taken from +/// `esp-metadata-generated`'s `for_each_gpio!` table for `esp32` — the same +/// table [`AnyPin::steal`] asserts against, and it *panics* on a number the +/// chip does not have. This is not a policy filter (reserving pins is the +/// manifest's job); a hand-written `/hardware.json` naming `/gpio/20` must +/// fail the open, not the boot. +fn gpio_exists(pin: u8) -> bool { + matches!(pin, 0..=5 | 12..=19 | 21..=23 | 25..=27 | 32..=39) +} + +/// Can this chip's GPIO `pin` drive an output? +/// +/// **GPIO34-39 are input-only** on the classic ESP32 (no output driver in the +/// pad at all — `for_each_gpio!` lists an empty output-signal set for each). +/// The RMT would happily route its signal there and nothing would ever appear +/// on the wire, which is a much worse failure than a rejected open: it looks +/// like a driver bug on a board that is simply miswired. The S3 needs no +/// equivalent check — every S3 GPIO can output. +fn gpio_can_output(pin: u8) -> bool { + !matches!(pin, 34..=39) +} + +/// A resource whose display label is still `GPIO` has no board label, and a +/// pin the board does not name is not one a project should be offered. +fn has_board_assigned_label(address: &HwAddress, display_label: &str) -> bool { + let Some(raw) = address.as_str().strip_prefix("/gpio/") else { + return false; + }; + !display_label.eq_ignore_ascii_case(&format!("GPIO{raw}")) +} + +fn ws281x_rmt_spec(config: &str) -> HwEndpointSpec { + HwEndpointSpec::parse(format!("ws281x:rmt:{config}")) + .expect("manifest display label should form a valid endpoint spec") +} diff --git a/lp-fw/fw-esp32v3/src/output/rmt/mod.rs b/lp-fw/fw-esp32v3/src/output/rmt/mod.rs new file mode 100644 index 000000000..f2a632e94 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/output/rmt/mod.rs @@ -0,0 +1,20 @@ +//! WS281x output over the classic-ESP32 RMT peripheral. +//! +//! Three layers, deliberately separated — the same split fw-esp32s3 uses: +//! +//! * [`v3_rmt`] — the chip. Seven register operations implementing +//! [`lp_ws281x::RmtHw`], and the only place in this firmware that knows a +//! classic-ESP32 RMT address. +//! * [`shared_driver`] — the one [`lp_ws281x::Ws281xDriver`] instance, the RMT +//! interrupt trampoline that feeds it, and the optional telemetry tap. All +//! sequencing lives in the core crate, tested on the host. +//! * [`esp32v3_rmt_ws281x_driver`] — the `lpc-hardware` seam: endpoints, +//! leases, open-time pin binding, and the manifest-index → RMT-slot mapping +//! that two blocks per channel makes necessary. + +pub mod shared_driver; +pub mod v3_rmt; + +mod esp32v3_rmt_ws281x_driver; + +pub use esp32v3_rmt_ws281x_driver::Esp32V3RmtWs281xDriver; diff --git a/lp-fw/fw-esp32v3/src/output/rmt/shared_driver.rs b/lp-fw/fw-esp32v3/src/output/rmt/shared_driver.rs new file mode 100644 index 000000000..4bff6f298 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/output/rmt/shared_driver.rs @@ -0,0 +1,229 @@ +//! The one [`lp_ws281x::Ws281xDriver`] instance on this chip, the RMT +//! interrupt that feeds it, and the optional telemetry tap. +//! +//! There is exactly one RMT peripheral, one RMT interrupt line, and therefore +//! one driver: the transmitting channels are *channels of it*, not several +//! drivers. Every per-channel decision (timing, frame in flight, statistics) +//! already lives inside [`lp_ws281x::ChannelState`], so nothing here needs a +//! second layer of per-channel state — which is why this module holds a +//! `static` and the endpoint-facing driver holds none. +//! +//! `Ws281xDriver::with_blocks` is `const` and every field of `ChannelState` is +//! an atomic, so this needs neither `static mut` nor a `StaticCell`: the +//! handler and thread context share a `&'static`. +//! +//! Note the driver is sized to [`TX_CHANNELS`] = 8, the chip's RMT slot count, +//! not to the four outputs this build offers. The absorbed slots simply never +//! get configured (see [`super::v3_rmt::slot_for_index`]); giving them a +//! `ChannelState` each costs a few hundred bytes of `.bss` and keeps a +//! channel number meaning the same thing everywhere. + +use core::sync::atomic::{AtomicBool, Ordering}; + +use esp_hal::interrupt::{InterruptHandler, Priority}; +use esp_hal::rmt::Rmt; +use esp_hal::time::{Duration, Rate}; +use lp_ws281x::Ws281xDriver; + +use super::v3_rmt::{TX_BLOCKS, TX_CHANNELS, V3Rmt}; + +/// RMT source clock. The classic ESP32's RMT runs off APB, and esp-hal's +/// classic `validate_clock` accepts **only** the source frequency itself +/// (`frequency != source.freq()` is an error), so this must be exactly 80 MHz; +/// the per-channel divider of 1 then makes one tick 12.5 ns, which is what +/// [`lp_ws281x::PulseCodes::DEFAULT_CLOCK_HZ`] assumes. +pub const RMT_CLOCK: Rate = Rate::from_mhz(80); + +/// A frame that has not completed within this long has hung; abort it and +/// report rather than spinning forever. The longest frame the output provider +/// can ask for (256 LEDs) is ~7.7 ms on the wire. +pub const FRAME_TIMEOUT: Duration = Duration::from_millis(50); + +/// The driver, shared between thread context and the interrupt handler. +pub static DRIVER: Ws281xDriver = + Ws281xDriver::with_blocks(V3Rmt::new(TX_BLOCKS), TX_BLOCKS); + +/// Set once the RMT interrupt handler has been bound. +/// +/// fw-esp32c6 re-registers its handler on every channel construction and says +/// in a comment that it should not. Here it does not: with several endpoints +/// opening independently, rebinding is not a rare accident but the normal +/// case, and a handler swapped while a frame is in flight loses that frame's +/// refills. +static ISR_INSTALLED: AtomicBool = AtomicBool::new(false); + +/// The RMT interrupt entry point: a trampoline and nothing else. +/// +/// Placed in IRAM with `#[ram]` — a flash-cache miss here is exactly the +/// latency the guard word exists to survive, so it should not be +/// self-inflicted. No logging, no allocation: the core documents +/// [`Ws281xDriver::on_interrupt`] as the whole of the handler's work. That +/// matters more on this chip than on any other in the family: the classic's +/// delivered interrupt rate saturates around 48 k/s (findings.md §12), and +/// that ceiling is what decides how many outputs run clean. +/// +/// One entry services every channel. With 64-word halves the four transmitters +/// cross their half boundaries within microseconds of one another, so +/// coincident causes are the rule rather than the exception — and dispatching +/// them in one pass is `on_interrupt`'s job, not this trampoline's. +#[esp_hal::ram] +extern "C" fn rmt_isr() { + DRIVER.on_interrupt(); +} + +/// Bind [`rmt_isr`] at the highest priority esp-hal can dispatch, exactly once. +pub fn install_isr(rmt: &mut Rmt<'_, esp_hal::Blocking>) { + if ISR_INSTALLED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + rmt.set_interrupt_handler(InterruptHandler::new(rmt_isr, Priority::max())); +} + +/// Emit the per-channel WS281x counters, at most once per +/// [`telemetry::PERIOD`], from the frame-write path. +/// +/// A no-op — and not even a timer read — unless the `ws281x_telemetry` feature +/// is on. See [`telemetry`] for what it prints and why the numbers are the +/// ones P3's capacity sweep needs. +#[cfg(not(feature = "ws281x_telemetry"))] +#[inline(always)] +pub fn report_telemetry_if_due() {} + +#[cfg(feature = "ws281x_telemetry")] +pub use telemetry::report_telemetry_if_due; + +/// Runtime telemetry over the serial link, off by default. +/// +/// The counters `lp-ws281x` keeps (`guard_trips`, `guard_skips`, `errors`, the +/// refill-lag sum/count/max and the 9-bucket lag histogram) are the only +/// evidence there is about whether the classic's interrupt budget is actually +/// carrying four channels. The S3 firmware reads them **only** from its +/// `test_loopback` harness, so there was no app-path pattern to mirror; this +/// is that pattern, kept deliberately small: +/// +/// * one `[WS281X]` line per configured channel, at most every +/// [`PERIOD`] — a period long enough that the print cost cannot itself +/// perturb the thing being measured; +/// * integer formatting only (mean lag is reported in tenths of a word), so +/// no float-formatting machinery is linked; +/// * emitted from the *frame-write* path, never from the ISR; +/// * compiled out entirely when the feature is off, so the shipping image +/// pays nothing — not even the `Instant::now()`. +/// +/// The line carries `refills` (the refills that happened) next to `wanted` +/// (the refills an untruncated frame set would have needed). Those two are the +/// starvation signal: a refill that never arrives leaves no lag sample behind +/// at all, so `lag_max` can look comfortable while a third of the frames +/// truncate. `trips` is the direct truncation count. Read `refills` vs +/// `wanted` first, `trips` second, `lag_*` last. +#[cfg(feature = "ws281x_telemetry")] +pub mod telemetry { + use core::sync::atomic::{AtomicU32, Ordering}; + + use esp_hal::time::{Duration, Instant}; + use lp_ws281x::LAG_BUCKETS; + + use super::DRIVER; + use crate::output::rmt::v3_rmt::TX_CHANNELS; + + /// How often the report line is emitted. Long enough not to perturb the + /// measurement, short enough that a capacity sweep gets several samples + /// per cell. + pub const PERIOD: Duration = Duration::from_secs(10); + + /// Millisecond timestamp of the last report. + /// + /// **32-bit, not 64**: Xtensa LX6 has no 64-bit atomics, and this is + /// shared between however many output handles are writing frames. Wrapping + /// arithmetic below keeps it correct across the ~49.7-day rollover; the + /// initial `0` simply means the first report lands one [`PERIOD`] into + /// uptime. + static LAST_REPORT_MS: AtomicU32 = AtomicU32::new(0); + + /// Print one line per configured channel if [`PERIOD`] has elapsed. + /// + /// Cheap to call on every frame: one timer read and one relaxed load in + /// the common case. + pub fn report_telemetry_if_due() { + let now_ms = Instant::now().duration_since_epoch().as_millis() as u32; + let last = LAST_REPORT_MS.load(Ordering::Relaxed); + if now_ms.wrapping_sub(last) < PERIOD.as_millis() as u32 { + return; + } + // Relaxed compare-exchange: two channels racing here would at worst + // print the block twice, and losing the race must not print at all. + if LAST_REPORT_MS + .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return; + } + + for ch in 0..TX_CHANNELS as u8 { + let Some(state) = DRIVER.channel(ch) else { + continue; + }; + let half = state.half_words(); + if half == 0 { + // Never configured: an absorbed slot, or a channel this board + // does not offer. Nothing to say about it. + continue; + } + let stats = DRIVER.stats(ch); + // Refills an untruncated frame needs: one per half boundary it + // crosses. `total_bits` is the current frame's pixel bits. + let wanted_per_frame = state.total_bits().div_ceil(half) as u64; + let (lag_int, lag_tenths) = + mean_lag_tenths(stats.refill_lag_sum, stats.refill_lag_count); + esp_println::println!( + "[WS281X] t_ms={} ch={} half={} frames={} complete={} trips={} skips={} \ + errors={} refills={} wanted={} lag_avg={}.{} lag_max={} over_half={} \ + hist={}", + now_ms, + ch, + half, + stats.frames, + stats.complete_frames(), + stats.guard_trips, + stats.guard_skips, + stats.errors, + stats.refill_lag_count.max(0) as u64, + stats.frames as u64 * wanted_per_frame, + lag_int, + lag_tenths, + stats.refill_lag_max, + stats.lag_over_half(), + HistFmt(stats.lag_hist), + ); + } + } + + /// Mean refill lag as `(whole_words, tenths)` — integer arithmetic so no + /// float formatter is linked into the image. + fn mean_lag_tenths(sum: i32, count: i32) -> (i32, i32) { + if count <= 0 { + return (0, 0); + } + let tenths = (sum as i64 * 10) / count as i64; + ((tenths / 10) as i32, (tenths % 10).unsigned_abs() as i32) + } + + /// `a:b:c:…` rendering of the lag histogram, so one field carries all nine + /// buckets without an allocation. + struct HistFmt([u32; LAG_BUCKETS]); + + impl core::fmt::Display for HistFmt { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + for (i, v) in self.0.iter().enumerate() { + if i > 0 { + f.write_str(":")?; + } + write!(f, "{v}")?; + } + Ok(()) + } + } +} diff --git a/lp-fw/fw-esp32v3/src/output/rmt/v3_rmt.rs b/lp-fw/fw-esp32v3/src/output/rmt/v3_rmt.rs new file mode 100644 index 000000000..80330476c --- /dev/null +++ b/lp-fw/fw-esp32v3/src/output/rmt/v3_rmt.rs @@ -0,0 +1,526 @@ +//! Classic ESP32 (LX6) RMT backend for [`lp_ws281x::RmtHw`]. +//! +//! All the chip knowledge in this firmware lives here: the RMT RAM address, +//! the `CHnCONF1` start/stop dance, the interrupt-register bit layout, and the +//! fact that `MEM_RADDR_EX` is an offset into the *whole* RMT RAM rather than +//! into the channel's own window. Everything else — what to write and when — +//! is [`lp_ws281x::Ws281xDriver`], which never sees a register name. +//! +//! # Provenance +//! +//! Ported from this project's own classic-ESP32 experiment firmware +//! (`2026-esp32s3-experiment`, `fw/led-lab-esp32/src/esp32_rmt.rs` @ `b1dff8f`), +//! which ran the loopback, soak and channel-count-sweep suites on this exact +//! chip. The adaptation is packaging — crate paths, a fixed +//! [`BLOCKS_PER_CHANNEL`] instead of the harness's `option_env!`, and the +//! removal of the harness-only RAM probe — not redesign. +//! +//! Register derivation sources (license-safe, in the order they settled each +//! question): esp-hal 1.1.1 `src/rmt.rs` `chip_specific` module for +//! `any(esp32, esp32s2)` (MIT/Apache-2.0), the `esp32` PAC 0.40.2 field docs, +//! and `esp-metadata-generated` (`rmt.*` properties for `esp32`). No GPL +//! source was consulted; see `AGENTS.md`. +//! +//! # Where the RAM is +//! +//! The classic ESP32 RMT RAM sits at **`RMT_BASE + 0x800` = `0x3FF5_6800`** +//! (`esp-metadata-generated` `rmt.ram_start` = `1073047552`; PAC RMT base = +//! `0x3FF5_6000`). That is the *same* `+0x800` offset as the ESP32-S3 — +//! coincidence, not shared layout: the classic has eight 64-word blocks +//! (512 words) against the S3's eight 48-word ones, and a smaller register +//! file in front of them. The C6's `+0x400` remains the odd one out. The +//! experiment firmware proved the constant on silicon with a FIFO-port probe +//! at every demo boot; that probe is not carried over here because this crate +//! has no harness entrypoint to run it from, and the app path proves the +//! offset end to end anyway (a wrong offset cannot transmit a single frame). +//! +//! # Structural differences from the S3 backend (each verified in the sources +//! above, none recalled from memory) +//! +//! * **8 channels, each TX-or-RX.** There is no fixed TX/RX split; a channel +//! transmits because `CHnCONF1.tx_start` was set and receives because +//! `rx_en` was. This backend drives channels `0..TX_CHANNELS` as +//! transmitters and never touches the rest. +//! * **64-word blocks** (`rmt.channel_ram_size` = 64) → 32-word halves at one +//! block per channel, 64-word halves at the two this firmware ships. The +//! bit-cursor core handles either split untouched. +//! * **Per-channel `CHnCONF0`/`CHnCONF1`** instead of the S3's single +//! `CH_TX_CONF0`: divider/memsize/idle-threshold live in CONF0; start/reset +//! /owner/idle-output bits in CONF1. There is **no `conf_update`** — writes +//! take effect immediately (esp-hal's `update()` is a no-op here). +//! * **`INT_*` bits interleave by channel**: `chN_tx_end` = bit `3N`, +//! `chN_rx_end` = `3N+1`, `chN_err` = `3N+2`, and `chN_tx_thr_event` = bit +//! `24+N` (PAC `int_raw` field docs). The S3 groups by event +//! (`tx_end` 0..=3, `tx_err` 4..=7, `tx_thr_event` 8..=11). Note `ch_err` +//! is a *combined* TX/RX error bit — there is no separate `tx_err`. RX +//! errors cannot reach [`RmtHw::take_interrupts`] because this firmware +//! never sets `int_ena` for channels it does not transmit on, and the +//! snapshot reads `INT_ST` (= raw & ena). +//! * **Wrap enable is global**: `APB_CONF.mem_tx_wrap_en` (bit 1), not a +//! per-channel `mem_tx_wrap_en` in the channel's own conf register. Set +//! once by [`init_tx`]; every transmitting channel here wants wrap. +//! ⚠️ Without this bit ping-pong refill does not work **at all** on this +//! chip (experiment findings.md §11.5) — the transmitter runs off the end +//! of the window instead of wrapping back onto the refilled half. +//! * **No immediate TX stop.** `rmt.has_tx_immediate_stop` = false: there is +//! no `tx_stop` bit anywhere on this chip. esp-hal stops a channel by +//! filling its whole RAM window with end markers, and [`RmtHw::stop_tx`] +//! here does the same — the transmitter halts at the next word boundary +//! (≤ 1.25 µs for WS2812 timing) and raises `tx_end`. +//! * **`mem_owner` exists** (`CHnCONF1` bit 5, 1 = receiver owns the RAM): +//! [`RmtHw::start_tx`] clears it for the channel and for every extra block +//! the window extends into, exactly as esp-hal's classic `start_tx` does. +//! * `CHnSTATUS.mem_raddr_ex` is 10 bits (max 1023 — wide enough for all 512 +//! words, the tell that it is an absolute offset) and `CH_TX_LIM.tx_lim` is +//! 9 bits (max 511; a full 8-block window of 512 words would not fit, so an +//! all-blocks-on-one-channel plan is rejected by the width mask — not a +//! configuration this firmware uses). + +use esp_hal::peripherals::RMT; +use lp_ws281x::{BlockPlan, InterruptFlags, RmtHw}; + +/// RMT channels the classic ESP32 has, all of which can transmit. +/// +/// This is the **hardware** count, not the output count: how many of these +/// slots are usable at once is [`BLOCKS_PER_CHANNEL`]'s decision, and how many +/// are *offered* is the board manifest's (ADR +/// `2026-07-31-lp-ws281x-multi-channel-driver-adoption`). See +/// [`USABLE_CHANNELS`]. +pub const TX_CHANNELS: usize = 8; + +/// Words in one classic-ESP32 RMT memory block (`rmt.channel_ram_size`). +pub const BLOCK_WORDS: usize = 64; + +/// Memory blocks given to each transmitting channel — **two**, i.e. 128-word +/// windows halving into 64-word ping-pong halves. +/// +/// This is the compile-time answer to the classic's ISR-throughput ceiling and +/// the reason this firmware offers four outputs rather than eight. +/// +/// From the experiment's `findings.md` §12 (the root cause of the "equal-start" +/// defect): the delivered RMT interrupt rate on this chip flatlines at roughly +/// **46–55 k/s regardless of demand** — it is a throughput ceiling, not a +/// latency problem, and staggering frame starts does not move it. A +/// continuously transmitting channel demands `800_000 / half_words` refills per +/// second, so +/// +/// ```text +/// blocks half_words demand/channel channels under a ~48 k/s ceiling +/// 1 32 25 000/s 2 (measured: truncation from ch 3 up) +/// 2 64 12 500/s ~4 (arithmetic; MARGINAL — 50 k vs 48 k) +/// 4 128 6 250/s ~2 (only two slots exist at 4 blocks) +/// ``` +/// +/// Two blocks is the only setting that reaches four outputs at all, and its +/// four-channel margin is thin enough that it is a *prediction* until silicon +/// says otherwise — which is exactly what P3 of this milestone measures. The +/// experiment's `BLOCKS_PER_CHANNEL` was an `option_env!` because a sweep +/// harness varied it; here it is a plain constant, because firmware ships one +/// value and an env-driven constant is a stale-binary trap (`option_env!` does +/// not make cargo rebuild — the roadmap lost a run to that once already). +/// +/// Raising it trades outputs for headroom, lowering it trades headroom for +/// outputs that then truncate; either way [`TX_BLOCKS`] and +/// [`USABLE_CHANNELS`] follow automatically and the driver offers a different +/// number of endpoints. See [`lp_ws281x::BlockPlan`]. +pub const BLOCKS_PER_CHANNEL: u8 = 2; + +/// The TX-side allocation of RMT memory blocks, validated at compile time. +/// +/// The *same* value is handed to the driver and to [`V3Rmt`], so window sizes +/// can never disagree. At the shipped [`BLOCKS_PER_CHANNEL`] this is +/// `[2, 0, 2, 0, 2, 0, 2, 0]`. +pub const TX_BLOCKS: BlockPlan = match BlockPlan::uniform(BLOCKS_PER_CHANNEL) { + Ok(plan) => plan, + Err(_) => panic!("BLOCKS_PER_CHANNEL does not divide the classic ESP32's RMT memory blocks"), +}; + +/// Spacing between usable channel slots. +/// +/// A channel given `n` blocks absorbs the next `n-1` channels' blocks, so with +/// two blocks each only slots **0, 2, 4, 6** can be configured — slots 1, 3, 5 +/// and 7 have no memory of their own and asking esp-hal to configure one fails +/// with `ConfigError::MemoryBlockNotAvailable` (the experiment harness did +/// exactly that, which is why its `BLOCKS_PER_CHANNEL=2` config never ran). +/// Manifest channel `k` therefore drives RMT slot `k * SLOT_STRIDE`; see +/// [`slot_for_index`]. +pub const SLOT_STRIDE: usize = BLOCKS_PER_CHANNEL as usize; + +/// How many RMT slots the block plan leaves transmittable — the ceiling on the +/// number of WS281x endpoints this chip can back, whatever the manifest says. +pub const USABLE_CHANNELS: usize = TX_CHANNELS / SLOT_STRIDE; + +/// The RMT slot backing manifest channel `index` (`/rmt/ws281x`), or +/// `None` when the block plan has no slot for it. +/// +/// This is the whole of the absorbed-slot fix: callers enumerate *manifest* +/// channels `0..USABLE_CHANNELS` and get back only slots the plan marks +/// available, so an absorbed slot is never handed to `configure_tx` in the +/// first place. The `is_available` check is belt-and-braces — with a uniform +/// plan the stride already lands on owners — and keeps this honest under a +/// hand-written non-uniform plan. +pub const fn slot_for_index(index: usize) -> Option { + let slot = index * SLOT_STRIDE; + if slot >= TX_CHANNELS { + return None; + } + let slot = slot as u8; + if TX_BLOCKS.is_available(slot) { + Some(slot) + } else { + None + } +} + +/// RAM words a channel owns under [`TX_BLOCKS`], for the channels that have +/// any. Reported in the driver's boot line so a serial log says what refill +/// deadline the run was measured against (`CHANNEL_WORDS / 2` words per half, +/// 1.25 µs per word at 800 kHz). +pub const CHANNEL_WORDS: usize = BLOCK_WORDS * BLOCKS_PER_CHANNEL as usize; + +/// Total RAM claimed by the TX-side plan, in words — the bound every pointer +/// here respects. All eight blocks belong to transmitters on this chip (there +/// are no receive-only channels), so this is the whole 512-word RMT RAM. +const TX_RAM_WORDS: usize = BLOCK_WORDS * TX_CHANNELS; + +/// Byte offset from the RMT peripheral base to the start of RMT RAM. +/// +/// **`0x800` on the classic ESP32** — numerically the same as the S3's, per +/// the module docs. +pub const RAM_OFFSET: usize = 0x800; + +/// Absolute address of the classic ESP32 RMT RAM (`0x3FF5_6800`). +pub const RAM_BASE: usize = 0x3FF5_6000 + RAM_OFFSET; + +/// Widest value `CH_TX_LIM.tx_lim` (bits 0..=8) can hold. +const TX_LIM_MAX: u16 = 0x1FF; + +/// Bit position of `chN_tx_end` in the `INT_*` registers: three bits per +/// channel, interleaved by channel (see module docs). +const fn int_tx_end_bit(ch: u8) -> u32 { + 1u32 << (3 * ch) +} + +/// Bit position of `chN_err` (combined TX/RX error) in the `INT_*` registers. +const fn int_err_bit(ch: u8) -> u32 { + 1u32 << (3 * ch + 2) +} + +/// Bit position of `chN_tx_thr_event` in the `INT_*` registers. +const fn int_thr_bit(ch: u8) -> u32 { + 1u32 << (24 + ch) +} + +/// The `INT_*` bits belonging to TX channel `ch` (end, err, thr_event). +/// +/// `chN_rx_end` (bit `3N+1`) is deliberately excluded: it is an RX cause and +/// this backend never enables or clears causes for a role it does not drive. +const fn tx_event_mask(ch: u8) -> u32 { + int_tx_end_bit(ch) | int_err_bit(ch) | int_thr_bit(ch) +} + +/// Pointer to word `word_idx` of channel `ch`'s RAM window under `blocks`, or +/// `None` if either index is out of range. +/// +/// The bounds check costs one compare per word on the refill path and buys a +/// handler that can never scribble outside the RMT RAM, whatever the caller +/// does. With `blocks_per_channel > 1` the window spans several blocks, which +/// is why the size comes from the plan rather than from a constant. +#[inline(always)] +fn ram_word(blocks: &BlockPlan, ch: u8, word_idx: usize) -> Option<*mut u32> { + if word_idx >= blocks.window_words(ch, BLOCK_WORDS) { + // Covers an out-of-range or absorbed channel too: both have no window. + return None; + } + let index = blocks.window_start(ch, BLOCK_WORDS) + word_idx; + if index >= TX_RAM_WORDS { + return None; + } + // SAFETY: `RAM_BASE` is the RMT peripheral's memory window, at least + // `TX_RAM_WORDS` u32 words long (the peripheral has exactly 512); `index` + // was just bounded to that range, so the result stays inside one allocated + // MMIO object and the byte offset (< 2 KiB) cannot overflow an `isize`. + Some(unsafe { (RAM_BASE as *mut u32).add(index) }) +} + +/// The seven register operations `lp-ws281x` needs, on the classic ESP32. +/// +/// Carries only the memory-block plan: everything else it addresses is +/// memory-mapped, so it is `const`-constructible and can live in a `static` +/// shared with the interrupt handler. +#[derive(Debug, Clone, Copy)] +pub struct V3Rmt { + blocks: BlockPlan, +} + +impl V3Rmt { + /// A backend handle for `blocks`. Touches no hardware. + /// + /// Pass the same plan to [`lp_ws281x::Ws281xDriver::with_blocks`]. + pub const fn new(blocks: BlockPlan) -> Self { + Self { blocks } + } +} + +impl Default for V3Rmt { + fn default() -> Self { + Self::new(TX_BLOCKS) + } +} + +impl RmtHw for V3Rmt { + #[inline] + fn ram_words(&self, ch: u8) -> usize { + self.blocks.window_words(ch, BLOCK_WORDS) + } + + #[inline] + fn write_ram(&self, ch: u8, word_idx: usize, value: u32) { + let Some(ptr) = ram_word(&self.blocks, ch, word_idx) else { + return; + }; + // SAFETY: `ram_word` returned an in-range, naturally aligned pointer + // into the RMT RAM window. The write must be volatile: the transmitter + // reads this memory behind the compiler's back, so the store can be + // neither elided nor reordered with the surrounding register writes. + unsafe { ptr.write_volatile(value) }; + } + + #[inline] + fn set_tx_threshold(&self, ch: u8, words: u16) { + if ch as usize >= TX_CHANNELS { + return; + } + // **The classic's `tx_lim` is a repeating count, not a position.** + // + // The PAC puts it plainly: "when channel N sends more than + // `tx_lim` datas then channel N produces the relative interrupt" — a + // count of *entries sent*, which re-arms itself, so a fixed value + // fires once per that many words for the whole frame. esp-hal's own + // classic driver relies on exactly that: it programs + // `memsize.codes() / 2` once at the start of a transmission and never + // touches the register again. + // + // The driver core, written against the S3 where the threshold names a + // *word offset in the window*, alternates its request between the half + // size and the full window so the event lands at each half boundary in + // turn. Passing that alternation through unchanged asks this chip for + // an event every 128 words instead of every 64 — the second refill then + // arrives a whole half late and the transmitter walks into the guard + // word planted by the first. Measured before this clamp: `guard_trips` + // exactly equal to `frames` on every channel whose frame outgrew one + // RAM window (8/16/100/256 LEDs), with `refill_lag_avg_words` a + // comfortable 7.0 — the refills were not late, they were *asked for* + // late. + // + // So the request is clamped to the channel's half size, which is the + // only period that produces the boundary events the core expects. A + // request smaller than a half (the core never makes one today) is + // passed through, so the test hook that suppresses a threshold still + // behaves as written. + let half = (self.blocks.window_words(ch, BLOCK_WORDS) / 2) as u16; + let period = if half == 0 { words } else { words.min(half) }; + // A note on what was tried here, so it is not tried again blind. + // Writing `CH_TX_LIM` restarts the channel's entry counter, so the + // core's once-per-refill call re-arms mid-frame — a plausible cause of + // the multi-channel truncation `sweep_channels` measures (see the + // README). Caching the value and skipping the redundant writes was + // tried on silicon, both with and without a per-frame re-arm in + // `start_tx`. **Neither fixed it**: channel 2 went clean and channel 1 + // got *worse* (10.0 -> 4.0 refills per frame, a guard skip on every + // frame), so the rewrite shifts the failure without causing it. The + // unconditional write is kept because it is the form the loopback + // suite and the golden vectors were validated against. + // SAFETY (register): `tx_lim` is a plain 9-bit field; the value is + // masked to that width, so no reserved bits are disturbed. The PAC + // marks the setter unsafe only because it cannot check the width. + RMT::regs() + .ch_tx_lim(ch as usize) + .modify(|_, w| unsafe { w.tx_lim().bits(period & TX_LIM_MAX) }); + } + + #[inline] + fn read_pos(&self, ch: u8) -> u16 { + let window = self.blocks.window_words(ch, BLOCK_WORDS); + if window == 0 { + return 0; + } + let absolute = RMT::regs() + .chstatus(ch as usize) + .read() + .mem_raddr_ex() + .bits(); + // `mem_raddr_ex` counts from the start of the *whole* RMT RAM — same + // absolute-offset semantics as the S3, different register name + // (`CHnSTATUS` here, `CH_TX_STATUS` there); esp-hal's classic + // `hw_offset()` subtracts the same term. The modulo keeps a reading + // taken mid-wrap inside the window instead of panicking or aliasing. + let base = self.blocks.window_start(ch, BLOCK_WORDS) as u16; + absolute.wrapping_sub(base) % window as u16 + } + + fn start_tx(&self, ch: u8) { + if ch as usize >= TX_CHANNELS { + return; + } + let rmt = RMT::regs(); + let idx = ch as usize; + + // Drop causes left over from the previous frame so the first event of + // this one is genuinely this one's. + // SAFETY (register): `int_clr` is write-1-to-clear across its whole + // width; the mask only names this channel's TX event bits. + rmt.int_clr() + .write(|w| unsafe { w.bits(tx_event_mask(ch)) }); + + // Reset the channel clock divider so the first pulse is a full tick + // rather than the remainder of one already in progress. Unlike the + // S3's shared `REF_CNT_RST` bitmask register, this is a per-channel + // bit in CHnCONF1 (bit 16, PAC `ref_cnt_rst`). + rmt.chconf1(idx).modify(|_, w| w.ref_cnt_rst().set_bit()); + rmt.chconf1(idx).modify(|_, w| w.ref_cnt_rst().clear_bit()); + + // A window wider than one block extends into the *following* channels' + // blocks; each of those blocks is owned via its own channel's + // `mem_owner` bit, so hand every one of them to the transmitter — + // esp-hal's classic `start_tx` does exactly this dance. + for extra in 1..self.blocks.blocks(ch) { + rmt.chconf1(idx + extra as usize) + .modify(|_, w| w.mem_owner().clear_bit()); + } + + // Single-shot, wrapping around the window (wrap enable itself is the + // global `APB_CONF` bit set once by `init_tx`): the driver keeps + // refilling behind the read pointer and ends the frame with a STOP + // word. `mem_rd_rst` rewinds the transmitter to word 0 of the window, + // `apb_mem_rst` the APB side; both are write-to-trigger, there is no + // `conf_update` on this chip, and `mem_owner` clear = transmitter owns + // the RAM. + rmt.chconf1(idx).modify(|_, w| { + w.tx_conti_mode().clear_bit(); + w.mem_owner().clear_bit(); + w.mem_rd_rst().set_bit(); + w.apb_mem_rst().set_bit(); + w.tx_start().set_bit() + }); + } + + fn stop_tx(&self, ch: u8) { + if ch as usize >= TX_CHANNELS { + return; + } + // The classic ESP32 has no immediate-stop bit + // (`rmt.has_tx_immediate_stop` = false; the S3's `tx_stop` does not + // exist in CHnCONF1). The only way to stop a transmitter is the way + // esp-hal does it: fill the channel's whole RAM window with STOP + // markers so the wrap-mode reader lands on one within a word. An + // all-zero word is the STOP marker, so this doubles as leaving the + // channel in the safest possible state — and it is harmless on an + // already-idle channel, which the driver's cleanup path relies on. + for word in 0..self.blocks.window_words(ch, BLOCK_WORDS) { + if let Some(ptr) = ram_word(&self.blocks, ch, word) { + // SAFETY: in-range, aligned RMT RAM pointer from `ram_word`; + // volatile because the transmitter reads this memory + // concurrently — that race is the mechanism, not a hazard: an + // aligned u32 store is single-copy-atomic and every value the + // reader can observe (old word or STOP) is a valid pulse word. + unsafe { ptr.write_volatile(0) }; + } + } + } + + #[inline] + fn take_interrupts(&self) -> InterruptFlags { + let rmt = RMT::regs(); + // `int_st` is `int_raw & int_ena`, so causes this firmware never asked + // for (RX events, other channels) cannot reach the driver. + let status = rmt.int_st().read().bits(); + + let mut end = 0u32; + let mut error = 0u32; + let mut threshold = 0u32; + let mut pending = 0u32; + // The interleaved bit layout has no contiguous per-event field to mask + // out in one shift (the S3 does); eight compares are cheap next to the + // register read itself. + let mut ch = 0u8; + while (ch as usize) < TX_CHANNELS { + let one = 1u32 << ch; + if status & int_tx_end_bit(ch) != 0 { + end |= one; + pending |= int_tx_end_bit(ch); + } + if status & int_err_bit(ch) != 0 { + error |= one; + pending |= int_err_bit(ch); + } + if status & int_thr_bit(ch) != 0 { + threshold |= one; + pending |= int_thr_bit(ch); + } + ch += 1; + } + + if pending != 0 { + // Acknowledge exactly what is being reported, in the same handler + // pass — the driver's contract is that no cause is lost between + // the read and the clear. + // SAFETY (register): write-1-to-clear; `pending` is a subset of + // the bits just read from `int_st`. + rmt.int_clr().write(|w| unsafe { w.bits(pending) }); + } + + InterruptFlags { + threshold, + end, + error, + } + } +} + +/// One-time TX-side peripheral setup: enable wrap mode. +/// +/// ⚠️ **Load-bearing.** On the classic ESP32 wrap enable is the **global** +/// `APB_CONF.mem_tx_wrap_en` bit rather than a per-channel conf bit, and +/// without it the ping-pong refill scheme does not work at all (experiment +/// findings.md §11.5): the transmitter runs past the end of the window instead +/// of wrapping onto the half the driver has just refilled. Every transmitting +/// channel here wants wrap, so it is set once at init instead of read-modified +/// on every `start_tx` (which would put an unnecessary shared-register RMW on +/// the frame-start path). `apb_fifo_mask` (bit 0, 1 = direct RAM access rather +/// than FIFO) is already set by esp-hal's `Rmt::new`. +pub fn init_tx() { + RMT::regs() + .apb_conf() + .modify(|_, w| w.mem_tx_wrap_en().set_bit()); +} + +/// Enable `tx_end`, `err` and `tx_thr_event` for `ch` in `INT_ENA`. +/// +/// `chN_err` is the chip's combined TX/RX error bit; enabling it on a channel +/// this firmware transmits on cannot surface RX causes, because a channel is +/// TX-or-RX and this one is TX. +pub fn enable_tx_interrupts(ch: u8) { + if ch as usize >= TX_CHANNELS { + return; + } + RMT::regs().int_ena().modify(|_, w| { + w.ch_tx_end(ch).set_bit(); + w.ch_err(ch).set_bit(); + w.ch_tx_thr_event(ch).set_bit() + }); +} + +/// Zero every word of `ch`'s RAM window. +/// +/// An all-zero word is the STOP marker, so this leaves the channel in the +/// safest possible state: whatever happens, the transmitter stops at word 0. +pub fn clear_ram(blocks: &BlockPlan, ch: u8) { + for word in 0..blocks.window_words(ch, BLOCK_WORDS) { + if let Some(ptr) = ram_word(blocks, ch, word) { + // SAFETY: in-range, aligned RMT RAM pointer from `ram_word`; + // volatile because the transmitter also reads this memory. + unsafe { ptr.write_volatile(0) }; + } + } +} diff --git a/lp-fw/fw-esp32v3/src/recovery/boot_report.rs b/lp-fw/fw-esp32v3/src/recovery/boot_report.rs new file mode 100644 index 000000000..fca7dadbe --- /dev/null +++ b/lp-fw/fw-esp32v3/src/recovery/boot_report.rs @@ -0,0 +1,80 @@ +//! Boot-time recovery init and the forensic record of the previous run. +//! +//! This runs as early as the heap allows and before anything crash-prone, so +//! that a crash from here on has somewhere to leave a breadcrumb. The report it +//! prints is the on-wire answer to "what died last time" — on the abort tier it +//! is the *only* answer, since nothing was caught in-process. + +use alloc::boxed::Box; + +use lp_recovery::BootAssessment; + +use super::esp32v3_recovery_backend::Esp32V3RecoveryBackend; +use super::reset_cause_map::current_reset_cause; + +/// Analyze the previous run, install the recovery global, and print the +/// assessment. Call once, after the heap allocator is live. +/// +/// Returns the assessment so the caller can act on `safe_mode` — `main.rs` uses +/// it to skip auto-loading a project that has been crashing. +pub fn init_and_report() -> BootAssessment { + let reset_cause = current_reset_cause(); + let (recovery, assessment) = + lp_recovery::Recovery::init(Esp32V3RecoveryBackend::take(), reset_cause); + // Leaked deliberately: `set_global` wants a `&'static mut`, and the + // instance lives for the whole run by construction. + lp_recovery::set_global(Box::leak(Box::new(recovery))); + log_boot_assessment(&assessment); + assessment +} + +/// Print the boot assessment to serial. Uses `esp_println` because this runs +/// before the log crate has a sink. +pub fn log_boot_assessment(assessment: &BootAssessment) { + esp_println::println!( + "[RECOVERY] boot: cause={} level={} safe_mode={} prior_boot_complete={}", + assessment.cause.as_str(), + assessment.level.as_str(), + assessment.safe_mode, + assessment.prior_boot_complete, + ); + let Some(crash) = &assessment.prior_crash else { + return; + }; + esp_println::println!( + "[RECOVERY] last run crashed ({}): at {}: {}", + crash.cause.as_str(), + crash.path_display(), + crash.msg.as_str(), + ); + if crash.cause == lp_recovery::CrashCause::Oom { + let heap = crash.heap; + esp_println::println!( + "[RECOVERY] oom stats: requested={} align={} free={} used={}", + heap.requested, + heap.align, + heap.free, + heap.used, + ); + } + log_crash_pcs(crash.pc_count as usize, &crash.pc_frames); +} + +/// Print the crash's captured PCs, or state that none were captured — using +/// the same wording discipline as the panic path: a target with a walker must +/// not report an *empty* stack. See `panic_path::print_frames`. +fn log_crash_pcs(count: usize, pcs: &[u32]) { + let pcs = &pcs[..count.min(pcs.len())]; + if pcs.is_empty() { + esp_println::println!( + "[RECOVERY] no pcs recorded — the Xtensa walker ran and every candidate \ + failed its bounds checks, or the crash predates the walk" + ); + return; + } + esp_println::print!("[RECOVERY] decode: just decode-backtrace-esp32v3"); + for pc in pcs { + esp_println::print!(" 0x{pc:08x}"); + } + esp_println::println!(); +} diff --git a/lp-fw/fw-esp32v3/src/recovery/esp32v3_recovery_backend.rs b/lp-fw/fw-esp32v3/src/recovery/esp32v3_recovery_backend.rs new file mode 100644 index 000000000..08384e807 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/recovery/esp32v3_recovery_backend.rs @@ -0,0 +1,92 @@ +//! The persistent recovery region in RTC fast RAM + the software-reset hook. +//! +//! This is the classic ESP32's implementation of `lp_recovery::RecoveryBackend`. +//! The region format, its validation, and the torn-write discipline all live in +//! `lp-recovery` and are chip-agnostic; the only chip knowledge here is *where* +//! the bytes live and *how* to reset. +//! +//! ## What survives on this chip, and what does not +//! +//! RTC fast RAM is 8 KiB at D-bus `0x3FF8_0000` (I-bus alias `0x400C_0000`), +//! and esp-hal's `ld/esp32/memory.x` claims the whole segment. It retains its +//! contents across a **software reset**, a **watchdog reset** and deep sleep. +//! +//! It does **not** retain them across a power-on reset or an EN-pin (CHIP_PU) +//! reset — and on this board EN is what espflash toggles through the CH340K's +//! DTR/RTS lines. So the flash-and-watch cycle always starts with an empty +//! ledger, and the first boot after a reflash correctly reports "nothing to +//! report" rather than a stale crash from before the flash. `lp_recovery` +//! validates magic + CRCs and the reset reason before trusting anything, so +//! undefined power-up contents cannot be mistaken for a record. +//! +//! ⚠️ That retention boundary is also the one way this instrument can come back +//! empty on a fault it *did* catch: if the RMT fault turns out to reset the +//! chip through a path that cycles the RTC domain rather than through the +//! digital-core software reset, the record is gone with it. The boot report +//! prints the mapped reset cause on every boot precisely so that case is +//! distinguishable — a `power-on` cause after a crash that was not a replug is +//! the tell. +//! +//! On this chip RTC fast RAM is reachable from PRO_CPU only. The firmware runs +//! its app on PRO_CPU and never starts APP_CPU, so that is not a constraint +//! today; it would become one the moment anything is scheduled on core 1. + +use core::sync::atomic::{AtomicBool, Ordering}; + +use esp_hal::ram; +use lp_recovery::{RecoveryBackend, RecoveryRegion}; + +/// Newtype so we can promise esp-hal the region tolerates arbitrary bit +/// patterns (`Persistable` is a foreign trait; orphan rules forbid +/// implementing it for `RecoveryRegion` directly). +#[repr(transparent)] +struct PersistentRegion(RecoveryRegion); + +// SAFETY: `RecoveryRegion` contains only plain integers and fixed arrays +// thereof (no references, enums, or niches) — every bit pattern is a sound +// value, and lp-recovery validates magic + CRCs before trusting contents. +unsafe impl esp_hal::Persistable for PersistentRegion {} + +/// The breadcrumb region. `persistent` skips load-time initialization so the +/// previous run's bytes are still there on soft reset. +/// +/// `RecoveryRegion` is budgeted at <= `lp_recovery::REGION_MAX_SIZE` (1 KiB), +/// which fits the classic's 8 KiB of RTC fast RAM with room to spare. Note this +/// costs nothing from the 192 KB `dram_seg` that `.data`/`.bss`/`.stack` fight +/// over on this chip — RTC fast RAM is a separate segment, which is a rare +/// piece of good news for a build whose stack headroom is the binding +/// constraint. +#[ram(unstable(rtc_fast, persistent))] +static mut RECOVERY_REGION: PersistentRegion = PersistentRegion(RecoveryRegion::ZEROED); + +static BACKEND_TAKEN: AtomicBool = AtomicBool::new(false); + +/// [`RecoveryBackend`] over the RTC-RAM region. +pub struct Esp32V3RecoveryBackend { + _private: (), +} + +impl Esp32V3RecoveryBackend { + /// The one and only backend instance. Panics if taken twice — the region + /// must have a single owner (all shared access goes through the + /// lp-recovery global's critical section). + pub fn take() -> Self { + assert!( + !BACKEND_TAKEN.swap(true, Ordering::AcqRel), + "Esp32V3RecoveryBackend::take() called twice" + ); + Self { _private: () } + } +} + +impl RecoveryBackend for Esp32V3RecoveryBackend { + fn region(&mut self) -> &mut RecoveryRegion { + // SAFETY: exactly one backend exists (enforced by `take`), so this is + // the only path to the static; `&mut self` serializes access. + unsafe { &mut (*&raw mut RECOVERY_REGION).0 } + } + + fn request_reset(&mut self) { + esp_hal::system::software_reset() + } +} diff --git a/lp-fw/fw-esp32v3/src/recovery/mod.rs b/lp-fw/fw-esp32v3/src/recovery/mod.rs new file mode 100644 index 000000000..3527b823f --- /dev/null +++ b/lp-fw/fw-esp32v3/src/recovery/mod.rs @@ -0,0 +1,49 @@ +//! Abort-tier crash recovery for the classic ESP32. +//! +//! The classic is **abort tier** per ADR `2026-07-29-per-chip-fw-toolchains`, +//! the same tier as fw-esp32s3: `panic = "abort"` plus the `lp-recovery` RTC +//! ledger. A panic is terminal for the boot; all this subsystem can do is make +//! the *next* boot able to say what died. +//! +//! ## Why this exists on this chip in particular +//! +//! On the S3 the ledger is a convenience — its panic handler can print, and +//! usually does. On the classic it is the **only** diagnostic channel that +//! works for the fault class this bring-up is stuck on. +//! +//! Measured 2026-08-01 (`docs/defects/2026-08-01-classic-rmt-open-fault.md`): a +//! fault raised while WS281x channels are opening resets the chip in well under +//! a millisecond, no matter what the panic handler does. Masking interrupts, +//! draining the TX FIFO first, printing the line before the path, emitting the +//! path in 4-byte chunks, and trading 46 KB of heap for stack all yielded the +//! same ~5 characters. That is the signature of a second exception taken inside +//! exception context, which vectors straight to reset and cannot be out-run +//! from Rust. Printing faster is not a fix; **not needing to print** is. A +//! record staged into RTC RAM survives the reset and is read back on the next +//! boot, when the chip is healthy and the UART works. +//! +//! ## What is here, and what is not +//! +//! - [`esp32v3_recovery_backend`]: the persistent region in RTC fast RAM and +//! the software-reset hook. +//! - [`reset_cause_map`]: the classic's `SocResetReason` → platform-agnostic +//! `ResetCause`. Deliberately not the S3's map; the variant sets differ. +//! - [`panic_path`]: print if it can, stage a breadcrumb regardless, reset. +//! - [`boot_report`]: boot-time init and the "what died last run" report. +//! +//! **No watchdog module.** fw-esp32s3 pairs the ledger with an RWDT and an +//! io-task-aware feed policy; that stays M7 work here. Two reasons, and the +//! second is the load-bearing one: the RWDT needs the io-task feed contract +//! wired before it is armed or it resets the board every `BOOT_TIMEOUT_MS` +//! forever, and arming a watchdog on a board that is *currently under +//! investigation for reset-looping* would add a second reset source to a +//! diagnosis whose whole difficulty is attributing the first one. +//! +//! `lp-recovery`'s incomplete-boot counter still works without it — that +//! counter is driven by `mark_boot_complete`, not by the watchdog — so safe +//! mode still breaks a boot loop. + +pub mod boot_report; +pub mod esp32v3_recovery_backend; +pub mod panic_path; +pub mod reset_cause_map; diff --git a/lp-fw/fw-esp32v3/src/recovery/panic_path.rs b/lp-fw/fw-esp32v3/src/recovery/panic_path.rs new file mode 100644 index 000000000..56cbd8c13 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/recovery/panic_path.rs @@ -0,0 +1,231 @@ +//! The abort-tier panic path: **stage, commit, then** print and reset. +//! +//! Per ADR `2026-07-29-per-chip-fw-toolchains` the classic is **abort tier**. +//! There is no `unwinding`, no `catch_unwind`, and therefore no layer-1 +//! in-process recovery: a panic is terminal for the boot. The only job left is +//! to make the *next* boot able to say what died. +//! +//! ## Why the order differs from fw-esp32s3's +//! +//! fw-esp32s3 prints first and stages afterwards, which is the natural reading +//! order and costs it nothing — its panic handler runs to completion. +//! +//! This handler does the opposite, and the inversion is the entire point of +//! the module. Measured on this board 2026-08-01 +//! (`docs/defects/2026-08-01-classic-rmt-open-fault.md`): a fault raised while +//! WS281x channels are opening resets the chip after roughly **five characters** +//! of output, in well under a millisecond. Interrupt masking, draining the TX +//! FIFO first, printing the line before the path, chunking the path into 4-byte +//! writes and two different heap sizes all produced the same five characters. +//! Anything sequenced after a `println!` on this path does not happen. +//! +//! So the record goes into RTC RAM before a single byte is formatted for +//! output, and it is **committed** there rather than left tentative: +//! `Recovery::init` discards a tentative record on the next boot unless the +//! reset cause was a watchdog, and this fault is not a watchdog. See +//! [`lp_recovery::commit_staged_crash`] for why that is sound here and unsound +//! on the unwinding tier. +//! +//! ## Two consequences worth stating outright +//! +//! **1. No `is_esp_sync_reentrant_lock_panic` guard**, for the same reason +//! fw-esp32s3 has none: this path allocates nothing. `lp_recovery::stage_crash` +//! is zero-alloc by contract, and `esp_println` / `critical-section` are backed +//! by `esp_sync::RawMutex`, which is reentrant. The hazard the C6 guards +//! against — boxing a payload for `unwinding::begin_panic` under +//! `esp-alloc`'s `NonReentrantMutex` — cannot arise. [`PANICKING`] covers the +//! broader case of this handler panicking by some other route. +//! +//! **2. It always resets; it never hangs.** A hung board is indistinguishable +//! from a dead one, and `lp-recovery`'s incomplete-boot counter turns a genuine +//! boot loop into safe mode. Resetting unconditionally means the panic is +//! visible instead of silent. + +use core::sync::atomic::{AtomicBool, Ordering}; + +use lpc_shared::backtrace::{MAX_FRAMES, capture_frames}; + +/// Whether `lpc_shared::backtrace::capture_frames` has a real stack walker for +/// this target. +/// +/// `true`: the Xtensa arm of `capture_frames_arch` is LX6/LX7-generic (windowed +/// ABI, base-save-area chain) and this crate enables `lpc-shared`'s +/// `xt-map-esp32-classic` feature, which points its bounds checks at the +/// classic's IRAM / IROM / DRAM windows instead of the S3's. +/// +/// ⚠️ This constant and that cargo feature are one fact in two places. Without +/// the feature the walker still *runs* — it just rejects every candidate +/// against S3 addresses and returns zero, which [`print_frames`] would then +/// report as "the stack was unreadable". If the feature is ever dropped from +/// `Cargo.toml`, this must go to `false` in the same commit. +const FRAME_WALKER_PRESENT: bool = true; + +/// Set on entry to the panic path, never cleared. Guards against a panic +/// raised *by* the panic path (a panicking `Display` impl, most plausibly) +/// recursing forever — `no_std` has no double-panic detection of its own. +static PANICKING: AtomicBool = AtomicBool::new(false); + +/// Stage a breadcrumb into the RTC ledger, commit it, report on serial, reset. +/// Never returns, and never hangs. +pub fn stage_and_reset(info: &core::panic::PanicInfo) -> ! { + if PANICKING.swap(true, Ordering::AcqRel) { + // Re-entered while handling a panic. Do the absolute minimum — no + // formatting of caller-controlled values, no ledger write — and go. + esp_println::println!("\n[PANIC] recursive panic in the panic path; resetting now"); + esp_hal::system::software_reset() + } + + // Mask interrupts before anything else. The RMT refill ISR runs + // continuously while strips are transmitting; if the panic came from + // inside it — or from anything it can re-enter — the next interrupt lands + // in the middle of this handler and panics again. `PANICKING` would catch + // the recursion, but only by throwing away the record we are here to + // write. + esp_hal::xtensa_lx::interrupt::disable(); + + // ── Everything below this comment must survive the chip resetting mid-way. + // Ledger first; serial second. See the module docs. + let mut frames = [0u32; MAX_FRAMES]; + let count = capture_frames(&mut frames); + let location = info.location().map(|loc| (loc.file(), loc.line())); + let staged = lp_recovery::stage_crash( + lp_recovery::CrashCause::Panic, + &info.message(), + location, + &frames[..count], + None, + ); + if staged { + // Promote to committed *now*, not at reset time: a reset that beats us + // to `finalize_crash_and_reset` would otherwise leave a tentative + // record, and the next boot throws those away. + lp_recovery::commit_staged_crash(); + } + + // ── From here on we are spending time we may not have. Nothing after this + // point is load-bearing for the next boot's report. + esp_println::println!("\n\n====================== PANIC ======================"); + esp_println::println!("{info}"); + print_frames(&frames[..count]); + if staged { + esp_println::println!("[RECOVERY] crash committed to the RTC ledger"); + } else { + // No recovery global: a panic before `recovery::init_and_report`, or a + // build that never boots recovery. Say so, so the missing next-boot + // report is not read as a lost breadcrumb. + esp_println::println!("[RECOVERY] no ledger installed; this crash will not be reported"); + } + + esp_println::println!("[RECOVERY] resetting"); + lp_recovery::finalize_crash_and_reset(); + esp_hal::system::software_reset() +} + +/// The `#[alloc_error_handler]` path: record the heap state, then reset. +/// +/// Without this, an allocation failure reaches the ledger through Rust's +/// default handler — which panics, so it arrives as [`lp_recovery::CrashCause::Panic`] +/// with the stock `memory allocation of N bytes failed` message and **no heap +/// numbers at all**. That is the difference between "a 12,864-byte `Vec` growth +/// failed" and "a 12,864-byte `Vec` growth failed with 3 KB free", which are +/// different bugs with different fixes. +/// +/// The second thing it buys is a usable walk. Coming through the default +/// handler the first eight frames are `panic_nounwind_fmt` → +/// `__rdl_alloc_error_handler` → `handle_alloc_error` → `raw_vec::handle_error` +/// — all machinery, and the chain broke before reaching the caller that +/// actually asked for the memory (observed 2026-08-01). Capturing here starts +/// the walk at the allocation site instead. +pub fn stage_oom_and_reset(layout: core::alloc::Layout) -> ! { + if PANICKING.swap(true, Ordering::AcqRel) { + // Recursive OOM — the allocator failed again while we were reporting + // the first failure. Nothing here allocates, so this should be + // unreachable; say so rather than looping. + esp_println::println!("\n[OOM] recursive allocation failure while reporting; resetting"); + esp_hal::system::software_reset() + } + + esp_hal::xtensa_lx::interrupt::disable(); + + // Both are plain reads of the allocator's counters — no allocation, which + // matters because we are here precisely because allocation is failing. + let free = esp_alloc::HEAP.free(); + let used = esp_alloc::HEAP.used(); + + let mut frames = [0u32; MAX_FRAMES]; + let count = capture_frames(&mut frames); + + // Ledger first, exactly as in `stage_and_reset` — see the module docs. + let staged = lp_recovery::stage_crash( + lp_recovery::CrashCause::Oom, + &format_args!( + "alloc {} bytes failed (align {}) in {}", + layout.size(), + layout.align(), + lpc_shared::backtrace::oom_context().unwrap_or(""), + ), + None, + &frames[..count], + Some(lp_recovery::OomStats { + requested: layout.size() as u32, + align: layout.align() as u32, + free: free as u32, + used: used as u32, + }), + ); + if staged { + lp_recovery::commit_staged_crash(); + } + + esp_println::println!("\n\n====================== OOM ======================"); + esp_println::println!( + "allocation failed: requested={} align={} free={} used={} context={}", + layout.size(), + layout.align(), + free, + used, + lpc_shared::backtrace::oom_context().unwrap_or(""), + ); + print_frames(&frames[..count]); + if !staged { + esp_println::println!("[RECOVERY] no ledger installed; this OOM will not be reported"); + } + + esp_println::println!("[RECOVERY] resetting"); + lp_recovery::finalize_crash_and_reset(); + esp_hal::system::software_reset() +} + +/// Print captured PCs — or say plainly why there are none. +/// +/// The wording matters. "0 frames" from a target with no walker reads as "the +/// stack was empty", which is never true and would send someone hunting the +/// wrong bug. [`FRAME_WALKER_PRESENT`] is what keeps the two apart. +fn print_frames(frames: &[u32]) { + if !frames.is_empty() { + esp_println::print!("frames:"); + for frame in frames { + esp_println::print!(" 0x{frame:08x}"); + } + esp_println::println!(); + // Chip-specific on purpose: the classic's flash text lives at + // 0x400Dxxxx where the S3's and C6's live at 0x42xxxxxx, so the + // generic recipe would symbolize these against the wrong image and be + // confidently wrong. + esp_println::print!("decode: just decode-backtrace-esp32v3"); + for frame in frames { + esp_println::print!(" 0x{frame:08x}"); + } + esp_println::println!(); + } else if FRAME_WALKER_PRESENT { + esp_println::println!( + "frames: the walk found none — every candidate failed the IRAM/IROM \ + and stack bounds checks. The stack was not empty; it was unreadable." + ); + } else { + esp_println::println!( + "frames: unavailable — no Xtensa stack walker in this build. \ + This is NOT an empty stack; nothing looked at it." + ); + } +} diff --git a/lp-fw/fw-esp32v3/src/recovery/reset_cause_map.rs b/lp-fw/fw-esp32v3/src/recovery/reset_cause_map.rs new file mode 100644 index 000000000..2281cb90e --- /dev/null +++ b/lp-fw/fw-esp32v3/src/recovery/reset_cause_map.rs @@ -0,0 +1,80 @@ +//! Map classic-ESP32 SoC reset reasons to the platform-agnostic `ResetCause`. +//! +//! ⚠️ **Not a copy of the S3 map.** `SocResetReason` is a per-chip enum in +//! esp-hal (`rtc_cntl/rtc/esp32.rs`), and the classic's variant set differs from +//! the S3's in four ways that matter: +//! +//! 1. **There are no USB reset causes at all.** The classic has no +//! USB-Serial-JTAG peripheral, so `CoreUsbUart` / `CoreUsbJtag` — the S3's +//! entire [`ResetCause::UserReset`] arm — simply do not exist here. The +//! consequence is not cosmetic: on this board espflash resets the chip by +//! toggling EN through the CH340K's DTR/RTS lines, which is an external +//! *chip* reset and reports as `ChipPowerOn`. **A dev-tool reset is +//! therefore indistinguishable from a power-up on this chip**, and both are +//! reported as `power-on`. That is honest — the silicon really does not +//! distinguish them — but it means "power-on" here does not imply the board +//! lost power, and it is why no variant maps to `UserReset`. +//! 2. **CPU-scoped resets are spelled inconsistently.** esp-hal exposes +//! `CpuMwdt0`, `Cpu0Sw`, `Cpu0RtcWdt` (note: `CpuMwdt0` unprefixed but +//! `Cpu0Sw` prefixed), because ESP-IDF gives both cores the same codes and +//! the names were transcribed as-is. +//! 3. **`CoreSdio` is classic-only** — the SDIO slave peripheral can reset the +//! digital core. Nothing in this firmware uses SDIO. +//! 4. **`Cpu1Cpu0` is classic-only** — PRO_CPU resetting APP_CPU via +//! `DPORT_APPCPU_RESETTING`. +//! +//! Classification policy (unchanged from the S3 and the C6, because it is the +//! shared `lp_recovery::ResetCause` vocabulary that fixes it): +//! +//! - Every watchdog flavor (RWDT, MWDT) maps to `WatchdogReset`: the eagerly +//! maintained frame stack is the blame record. +//! - Deep-sleep wake and anything unrecognized map to `Unknown`, which by +//! `lp-recovery` policy does NOT blame the code path (explicit crash records +//! are blamed regardless). + +use esp_hal::rtc_cntl::SocResetReason; +use lp_recovery::ResetCause; + +/// The mapped reset cause for the current boot. +pub fn current_reset_cause() -> ResetCause { + map_reset_cause(esp_hal::system::reset_reason()) +} + +/// Translate the classic's SoC reset reason into the shared vocabulary. +/// +/// The two classic-only causes are classified as follows, and each is a +/// judgement call worth knowing about when reading a boot report: +/// +/// - `Cpu1Cpu0` (PRO_CPU reset APP_CPU) → [`ResetCause::SoftwareReset`]. It is +/// literally software resetting a CPU, which is what that variant means. It +/// should never appear in this firmware — the app runs on PRO_CPU and +/// APP_CPU is never started — so seeing it at all is the interesting signal, +/// and `SoftwareReset` says "something deliberately did this" rather than +/// burying it in `Unknown`. +/// - `CoreSdio` (the SDIO slave reset the digital core) → [`ResetCause::Unknown`]. +/// Nothing here uses SDIO, there is no honest slot for it in the shared +/// vocabulary, and `Unknown` is the non-blaming default by design. +pub fn map_reset_cause(reason: Option) -> ResetCause { + let Some(reason) = reason else { + return ResetCause::Unknown; + }; + match reason { + // Also what an espflash EN-line reset looks like on this board — see + // the module docs. There is no `UserReset` arm on this chip. + SocResetReason::ChipPowerOn => ResetCause::PowerOn, + SocResetReason::CoreSw | SocResetReason::Cpu0Sw | SocResetReason::Cpu1Cpu0 => { + ResetCause::SoftwareReset + } + SocResetReason::CoreRtcWdt + | SocResetReason::Cpu0RtcWdt + | SocResetReason::SysRtcWdt + | SocResetReason::CoreMwdt0 + | SocResetReason::CoreMwdt1 + | SocResetReason::CpuMwdt0 => ResetCause::WatchdogReset, + SocResetReason::SysBrownOut => ResetCause::Brownout, + // Deep-sleep wake, plus the classic-only SDIO reset with no honest + // equivalent. `Unknown` does not blame the code path, which is the + // right default for both. + SocResetReason::CoreDeepSleep | SocResetReason::CoreSdio => ResetCause::Unknown, + } +} diff --git a/lp-fw/fw-esp32v3/src/serial/io_task.rs b/lp-fw/fw-esp32v3/src/serial/io_task.rs new file mode 100644 index 000000000..b679406c5 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/serial/io_task.rs @@ -0,0 +1,441 @@ +//! I/O task for the classic ESP32's UART0 host link. +//! +//! Responsibilities are the S3's: +//! - Drain the outgoing log/message queue and write it (prefixed lines). +//! - Drain accountable server write requests, serialize to JSON, write, and +//! report the outcome back to the transport. +//! - Read available bytes, split on newlines, push `M!` lines to the incoming +//! queue. +//! +//! ## Two divergences from `fw-esp32s3/src/serial/io_task.rs` +//! +//! **1. No connection monitor.** The S3 polls the USB-Serial-JTAG SOF bit to +//! tell "cable plugged" from "host application draining", because on that chip +//! a host that stops reading makes every write time out, which stalls the task +//! and starves the watchdog. A UART has neither signal and neither problem: +//! the CH340K clocks bytes onto the wire at line rate whether or not anything +//! is listening, so a write always completes and there is nothing to latch. +//! `UsbConnectionMonitor` and the probe-write self-healing path have no +//! counterpart here; the write timeout below survives only as a backstop +//! against a wedged peripheral. +//! +//! **2. RX is drained *between* TX chunks** ([`UartLink::write_chunked`]). +//! On USB-Serial-JTAG a 16 KiB `ProjectRead` frame leaves in a few +//! milliseconds; at 115200 baud it takes ~1.4 s, and UART0's RX FIFO is 128 +//! bytes — about 11 ms of line time. Draining RX only at the top of the loop +//! would overflow the FIFO and silently lose whatever the host sent during a +//! long write. Hence [`WRITE_CHUNK_SIZE`] is sized in *line time*, not in +//! syscall overhead. +//! +//! ## Known duplication +//! +//! The JSON-serialization half below (`StackJsonWriter`, +//! `timed_write_server_msg`, `server_message_detail`) is a third copy of code +//! that is already duplicated between fw-esp32c6 and fw-esp32s3. It is +//! chip-agnostic and belongs in `fw-esp32-common`; lifting it would change +//! two shipping firmwares, which is not this phase's job. Kept diffable +//! against the S3's copy on purpose. + +extern crate alloc; + +use alloc::{format, string::String, vec::Vec}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use embassy_time::{Duration, Timer}; +use embedded_io_async::Write; +use esp_hal::Async; +use esp_hal::uart::{Uart, UartRx, UartTx}; +use fw_core::message_router::MessageRouter; +use ser_write_json::SerWrite; + +/// Static message channels for MessageRouter +static INCOMING_MSG: Channel = Channel::new(); +static OUTGOING_MSG: Channel = Channel::new(); + +/// Accountable server write requests. +/// +/// The server transport submits one message here, then waits on +/// `SERVER_WRITE_RESULT`. This keeps `ServerTransport::send().await` aligned +/// with actual write completion instead of a best-effort task handoff. +/// +/// Each request carries a wrapping `u32` generation token that `io_task` echoes +/// back on the result channel, so `transport.send()` can discard a result +/// orphaned by a cancelled send instead of trusting arrival order. +static SERVER_WRITE_REQUEST: Channel< + CriticalSectionRawMutex, + (u32, lpc_wire::WireServerMessage), + 1, +> = Channel::new(); + +static SERVER_WRITE_RESULT: Channel< + CriticalSectionRawMutex, + (u32, Result<(), lpc_wire::TransportError>), + 1, +> = Channel::new(); + +/// Write timeout per chunk. A UART TX FIFO drains at line rate unconditionally, +/// so this is not a host-liveness signal the way the S3's is — it is a +/// backstop against a wedged peripheral, sized to be far above the ~5.6 ms a +/// full chunk costs at 921600 baud. +const WRITE_TIMEOUT: Duration = Duration::from_millis(250); + +/// Chunk size for large writes, in *line time* rather than syscall overhead: +/// the invariant is "drain RX at least twice per RX-FIFO fill time". At +/// 921600 baud (the link rate `board::esp32v3::init` programs) UART0's +/// 128-byte RX FIFO holds ~1.4 ms of incoming line, and a 64-byte chunk +/// costs ~0.7 ms to clock out — the same 2x overflow margin the original +/// 115200 sizing had, it just drains 8x as often in wall time. The chunk +/// deliberately does NOT grow with the baud: line time is what protects the +/// FIFO, not bytes. +const WRITE_CHUNK_SIZE: usize = 64; + +/// RX drain buffer. One FIFO's worth, so a single `read_buffered` empties a +/// full FIFO. +const READ_CHUNK_SIZE: usize = 128; + +/// The split UART plus the partial-line buffer, held together because writing +/// and reading interleave (see the module docs). +struct UartLink { + rx: UartRx<'static, Async>, + tx: UartTx<'static, Async>, + read_buffer: Vec, +} + +impl UartLink { + fn new(uart: Uart<'static, Async>) -> Self { + let (rx, tx) = uart.split(); + Self { + rx, + tx, + read_buffer: Vec::new(), + } + } + + /// Move whatever the RX FIFO already holds into the line buffer and push + /// any complete `M!` lines to the incoming queue. + /// + /// Non-blocking by construction: `read_buffered` returns what is there and + /// never waits, which is the read semantic the C6 and S3 adapters also + /// present. It is deliberately not the async `read()` — that one is + /// cancellation-safe but parks on an RX-timeout interrupt that the classic + /// ESP32 cannot clear while the FIFO is non-empty (esp-hal notes the + /// erratum in `read_exact_async`), and polling sidesteps the question + /// entirely at 1 ms granularity. + fn poll_rx(&mut self, router: &MessageRouter) { + let mut temp = [0u8; READ_CHUNK_SIZE]; + loop { + match self.rx.read_buffered(&mut temp) { + Ok(0) => break, + Ok(n) => { + self.read_buffer.extend_from_slice(&temp[..n]); + // A short read means the FIFO is empty; anything else and + // there may be more waiting. + if n < temp.len() { + break; + } + } + Err(error) => { + // Overflow/parity/framing. The FIFO is reset by esp-hal on + // overflow; drop the partial line rather than splice two + // halves of different messages together. + log::warn!("[io_task] UART RX error: {error:?}; dropping partial line"); + self.read_buffer.clear(); + break; + } + } + } + process_read_buffer(&mut self.read_buffer, router); + } + + /// Write everything in `data`, draining RX between chunks. + /// + /// Returns false on a per-chunk timeout, which for a UART means the + /// peripheral is wedged rather than that a host went away. + async fn write_chunked(&mut self, data: &[u8], router: &MessageRouter) -> bool { + use embassy_futures::select::{Either, select}; + let mut offset = 0; + while offset < data.len() { + let chunk_end = (offset + WRITE_CHUNK_SIZE).min(data.len()); + match select( + Timer::after(WRITE_TIMEOUT), + self.tx.write_all(&data[offset..chunk_end]), + ) + .await + { + Either::First(_) => { + log::warn!( + "[io_task] UART TX timed out after {offset} of {} B", + data.len() + ); + return false; + } + Either::Second(Err(error)) => { + log::warn!("[io_task] UART TX error: {error:?}"); + return false; + } + Either::Second(Ok(())) => {} + } + offset = chunk_end; + self.poll_rx(router); + } + true + } +} + +struct StackJsonWriter<'a> { + buf: &'a mut [u8], + len: usize, +} + +#[derive(Debug)] +struct StackJsonError; + +impl core::fmt::Display for StackJsonError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("stack JSON buffer full") + } +} + +impl<'a> StackJsonWriter<'a> { + fn new(buf: &'a mut [u8]) -> Self { + Self { buf, len: 0 } + } + + fn bytes(&self) -> &[u8] { + &self.buf[..self.len] + } +} + +impl ser_write_json::SerWrite for StackJsonWriter<'_> { + type Error = StackJsonError; + + fn write(&mut self, buf: &[u8]) -> Result<(), StackJsonError> { + let end = self.len.checked_add(buf.len()).ok_or(StackJsonError)?; + if end > self.buf.len() { + return Err(StackJsonError); + } + self.buf[self.len..end].copy_from_slice(buf); + self.len = end; + Ok(()) + } +} + +/// I/O task for the UART0 host link. +/// +/// Runs independently of the server loop and converts between UART bytes and +/// `M!`-prefixed JSON lines. +/// +/// # Arguments +/// +/// * `uart` - UART0, already configured at 115200 8N1 by `init_board` (which +/// is also where the baud divisor `esp-println` piggybacks on gets set). +#[embassy_executor::task] +pub async fn io_task(uart: Uart<'static, esp_hal::Blocking>) { + let router = MessageRouter::new(&INCOMING_MSG, &OUTGOING_MSG); + let mut link = UartLink::new(uart.into_async()); + + Timer::after(Duration::from_millis(100)).await; + + loop { + drain_server_write_request(&mut link, &router).await; + drain_outgoing_messages(&router, &mut link).await; + link.poll_rx(&router); + + Timer::after(Duration::from_millis(1)).await; + } +} + +/// Drain the outgoing log/message queue. Always consumes, so the server loop +/// never blocks on a full channel. +async fn drain_outgoing_messages(router: &MessageRouter, link: &mut UartLink) { + let receiver = router.outgoing().receiver(); + while let Ok(msg) = receiver.try_receive() { + if !link.write_chunked(b"\n", router).await { + break; + } + if !link.write_chunked(msg.as_bytes(), router).await { + break; + } + } +} + +/// Drain accountable server write requests. +async fn drain_server_write_request(link: &mut UartLink, router: &MessageRouter) { + let receiver = SERVER_WRITE_REQUEST.receiver(); + let Ok((generation, msg)) = receiver.try_receive() else { + return; + }; + + let result = timed_write_server_msg(link, msg, router).await; + // Echo the request generation so `transport.send()` can discard any stale + // result left over from a cancelled send. + SERVER_WRITE_RESULT + .sender() + .send((generation, result)) + .await; +} + +async fn timed_write_server_msg( + link: &mut UartLink, + msg: lpc_wire::WireServerMessage, + router: &MessageRouter, +) -> Result<(), lpc_wire::TransportError> { + let result = timed_write_full_server_msg(link, msg, router).await; + if result.is_err() { + // If a write failed part-way through a JSON frame, separate the next + // frame so host parsers can recover instead of concatenating two `M!` + // messages. + let _ = link.write_chunked(b"\n", router).await; + } + result +} + +async fn timed_write_full_server_msg( + link: &mut UartLink, + msg: lpc_wire::WireServerMessage, + router: &MessageRouter, +) -> Result<(), lpc_wire::TransportError> { + // Same ~16.7 KiB stack buffer as fw-esp32s3, and the same TODO: it lives + // as async-fn state and is paid even for tiny acks. On this chip that is a + // materially larger share of a 192 KB DRAM budget than it is on the S3's + // ~342 KB, so it is the first place to look if `.bss` needs to shrink. + const SERVER_MSG_FRAMING_BYTES: usize = 16; + const SERVER_MSG_JSON_BUFFER_SIZE: usize = + lpc_wire::PROJECT_READ_FRAME_SERIAL_BUFFER_BYTES + SERVER_MSG_FRAMING_BYTES; + let mut buf = [0u8; SERVER_MSG_JSON_BUFFER_SIZE]; + let mut writer = StackJsonWriter::new(&mut buf); + if writer.write(b"\nM!").is_err() { + log::warn!("[io_task] server message prefix exceeded JSON buffer"); + return Err(lpc_wire::TransportError::Serialization( + "server message prefix exceeded JSON buffer".into(), + )); + } + // Erased writer: shares one serializer instantiation per wire type with the + // frame-budget measurement path (lpc_wire::ser_write_json_len), instead of + // emitting a second copy of every type's serializer for this sink. + if lpc_wire::ser_write_json_to(&mut writer, &msg).is_err() { + let detail = server_message_detail(&msg); + log::warn!( + "[io_task] server message id={} {} exceeded JSON buffer size={} frame_budget={}; write failed", + msg.id, + detail, + SERVER_MSG_JSON_BUFFER_SIZE, + lpc_wire::PROJECT_READ_FRAME_MAX_BYTES + ); + return Err(lpc_wire::TransportError::Serialization(format!( + "server message id={} {} exceeded JSON buffer", + msg.id, detail + ))); + } + if writer.write(b"\n").is_err() { + let detail = server_message_detail(&msg); + log::warn!( + "[io_task] server message id={} {} suffix exceeded JSON buffer size={}; write failed", + msg.id, + detail, + SERVER_MSG_JSON_BUFFER_SIZE + ); + return Err(lpc_wire::TransportError::Serialization(format!( + "server message id={} {} suffix exceeded JSON buffer", + msg.id, detail + ))); + } + let id = msg.id; + if link.write_chunked(writer.bytes(), router).await { + Ok(()) + } else { + Err(lpc_wire::TransportError::Other(format!( + "server message id={id} UART write timed out or failed" + ))) + } +} + +fn server_message_detail(msg: &lpc_wire::WireServerMessage) -> String { + match &msg.msg { + lpc_wire::server::ServerMsgBody::Hello(hello) => { + format!("Hello proto={}", hello.proto) + } + lpc_wire::server::ServerMsgBody::Filesystem(_) => "Filesystem".into(), + lpc_wire::server::ServerMsgBody::LoadProject { .. } => "LoadProject".into(), + lpc_wire::server::ServerMsgBody::UnloadProject => "UnloadProject".into(), + lpc_wire::server::ServerMsgBody::ProjectRead { events } => format!( + "ProjectRead seq={} fin={} events={}", + msg.seq, + msg.fin, + events.len() + ), + lpc_wire::server::ServerMsgBody::ProjectCommand { .. } => "ProjectCommand".into(), + lpc_wire::server::ServerMsgBody::ListAvailableProjects { projects } => { + format!("ListAvailableProjects projects={}", projects.len()) + } + lpc_wire::server::ServerMsgBody::ListLoadedProjects { projects } => { + format!("ListLoadedProjects projects={}", projects.len()) + } + lpc_wire::server::ServerMsgBody::StopAllProjects => "StopAllProjects".into(), + lpc_wire::server::ServerMsgBody::SetLogLevel => "SetLogLevel".into(), + lpc_wire::server::ServerMsgBody::Log { level, .. } => { + format!("Log level={level:?}") + } + lpc_wire::server::ServerMsgBody::Heartbeat { + frame_count, + loaded_projects, + .. + } => format!( + "Heartbeat frame_count={frame_count} loaded_projects={}", + loaded_projects.len() + ), + lpc_wire::server::ServerMsgBody::Error { .. } => "Error".into(), + } +} + +/// Process the read buffer and extract complete lines. +/// +/// Looks for newlines, extracts lines starting with `M!`, and pushes them to +/// the incoming queue. Non-`M!` lines are ignored (they are the device's own +/// `esp_println!` output, which shares this UART). +fn process_read_buffer(read_buffer: &mut Vec, router: &MessageRouter) { + while let Some(newline_pos) = read_buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = read_buffer.drain(..=newline_pos).collect(); + + if let Ok(line_str) = core::str::from_utf8(&line_bytes[..line_bytes.len() - 1]) + && line_str.starts_with("M!") + { + use alloc::string::ToString; + if router + .incoming() + .sender() + .try_send(line_str.to_string()) + .is_err() + { + log::warn!("[io_task] incoming queue full, dropping M! message"); + } + } + } +} + +/// Get references to the static message channels. +/// +/// Used by main.rs to create the `StreamingMessageRouterTransport`. +pub fn get_message_channels() -> ( + &'static Channel, + &'static Channel, +) { + (&INCOMING_MSG, &OUTGOING_MSG) +} + +/// Get the accountable server write channels for StreamingMessageRouterTransport. +pub fn get_server_write_channels() -> ( + &'static Channel, + &'static Channel), 1>, +) { + (&SERVER_WRITE_REQUEST, &SERVER_WRITE_RESULT) +} + +/// Write log output to the outgoing channel (serial to host). +/// +/// Lines go out without the `M!` prefix so the client prints them. When the +/// channel is full, log lines are dropped — logging that would recurse. +pub fn log_write_to_outgoing(msg: &str) { + use alloc::string::ToString; + let _ = OUTGOING_MSG.sender().try_send(msg.to_string()); +} diff --git a/lp-fw/fw-esp32v3/src/serial/mod.rs b/lp-fw/fw-esp32v3/src/serial/mod.rs new file mode 100644 index 000000000..d36974275 --- /dev/null +++ b/lp-fw/fw-esp32v3/src/serial/mod.rs @@ -0,0 +1,16 @@ +//! UART0 transport for the classic ESP32. +//! +//! The genuinely chip-shaped part of this crate. fw-esp32c6 and fw-esp32s3 +//! both speak USB-Serial-JTAG through `esp_hal::usb_serial_jtag::UsbSerialJtag`; +//! the classic ESP32 has no such peripheral, so the host link is UART0 at +//! 115200 8N1 through the board's CH340K USB bridge. +//! +//! Everything above the byte stream is unchanged: the same `M!`-prefixed line +//! framing, the same `MessageRouter` channels, and the same accountable +//! server-write request/result pair that +//! `fw_esp32_common::transport::StreamingMessageRouterTransport` consumes. See +//! [`io_task`] for the two places the byte layer had to differ. + +pub mod io_task; + +pub use io_task::io_task; diff --git a/lp-fw/lp-ws281x/README.md b/lp-fw/lp-ws281x/README.md index b58567878..40241e163 100644 --- a/lp-fw/lp-ws281x/README.md +++ b/lp-fw/lp-ws281x/README.md @@ -90,6 +90,39 @@ channels at once (`on_interrupt` is a single pass over the whole snapshot for exactly this reason). The classic ESP32's 64-word blocks give 32-bit halves — 40 µs — at eight outputs. +#### The classic ESP32 is different, and it is the interesting case + +The classic ESP32 has **eight** channels of **64-word** blocks, so the same +table reads: + +| blocks/ch | outputs | window | half | refill deadline | demand per busy output | +|-----------|---------|--------|------|-----------------|------------------------| +| 1 | 8 | 64 w | 32 bits | 40 µs | 25 000 refills/s | +| **2** | **4** | **128 w** | **64 bits** | **80 µs** | **12 500 refills/s** | +| 4 | 2 | 256 w | 128 bits | 160 µs | 6 250 refills/s | + +On the S3 the choice is about deadline margin. On the classic it is about +**how many outputs work at all**, because that chip has a hard ceiling the +others do not: its *delivered* interrupt rate flatlines at roughly +**46–55 k/s regardless of demand** (measured in the experiment repo's +`sweep_channels` harness; root-caused as ISR throughput saturation, not +latency — staggering frame starts does not move it). Multiply the demand +column by the output count and the ceiling picks the winner: + +* 1 block → 25 k/s each → saturates at **two** outputs. Measured: truncation + begins at the third channel, at every strip length tried, with `lag_max` + still comfortable — the giveaway that refills were *missing*, not late. + Read `refills` against `refills_wanted`, never `lag_max`, when diagnosing + this. +* 2 blocks → 12.5 k/s each → ~4 outputs, and the margin is thin (50 k demand + against a ~48 k ceiling). This is what `fw-esp32v3` ships; whether four + channels are genuinely clean is a silicon question, not an arithmetic one. + +Note that only slots `0, 2, 4, 6` exist at two blocks each — a backend must +skip the absorbed slots when it creates channels, not merely when it +transmits. `fw-esp32v3`'s `output/rmt/v3_rmt.rs::slot_for_index` is the +worked example. + Measured on an ESP32-S3 at 240 MHz with all four outputs running unequal strips (8/16/100/256 LEDs, the `led-lab-esp32s3` firmware in the `2026-esp32s3-experiment` repo): mean read-pointer advance across a diff --git a/lp-shader/lpvm-native/Cargo.toml b/lp-shader/lpvm-native/Cargo.toml index a0d14b27b..f3f9812bf 100644 --- a/lp-shader/lpvm-native/Cargo.toml +++ b/lp-shader/lpvm-native/Cargo.toml @@ -44,6 +44,13 @@ debug = [] # harness and the host golden test so the two cannot drift. Needs `isa-xt` to # be meaningful; `no_std` and off by default so the app firmware never links it. xt-corpus = ["isa-xt"] +# Classic-ESP32 (LX6) fixed-region JIT placement: `rt_jit` links every module +# at a span reserved in `codemem_esp32::global`'s arena and installs it +# through the word-mirrored D-bus walk, because the classic heap has no I-bus +# view (see codemem_esp32 docs). Enabled ONLY by `fw-esp32v3`; the S3 keeps +# the in-place heap path, and hosts never enable it. Compile-time per-chip +# selection per the classic bring-up plan (M1/M3). +xt-placed-code = ["isa-xt"] # Host: link with builtins ELF and run in lp-riscv-emu (pulls std). emu = [ # The emulation path's reference target is rv32 (lp-riscv-emu); Xtensa is diff --git a/lp-shader/lpvm-native/README.md b/lp-shader/lpvm-native/README.md index 10d540813..36faff36a 100644 --- a/lp-shader/lpvm-native/README.md +++ b/lp-shader/lpvm-native/README.md @@ -187,6 +187,23 @@ assembly in `rt_jit::call`, `IsaTarget::native`'s answer); the backport adds a sibling `#[cfg(target_arch = "xtensa")]` arm next to it instead. The same two spellings are used in `lp-gfx-lpvm::target_backend` and `lpc-shared::backtrace`. +**Per-chip JIT code placement** is a third axis, orthogonal to the ISA: it is +about where the *bytes* live, not what is emitted. Two placements exist +(`rt_jit::JitBuffer`): + +| Chip / target | Placement | Write→execute rule | +| -------------------- | ------------ | ---------------------------------------------------------------------- | +| host, ESP32-C6 | in place | identity (`exec_addr`) | +| ESP32-S3 (LX7) | in place | `+0x6F_0000` inside SRAM1's dual-mapped window (`exec_addr`) | +| classic ESP32 (LX6) | **placed** | none — heap has no I-bus view; code is installed into a fixed SRAM1 region through the word-mirrored D-bus walk (`codemem_esp32`), linked against its final address by `link::link_jit_at` | + +The classic path is `compile_module_jit_placed`: reserve a span in the +`codemem_esp32::CodeArena` (real `TooLarge` capacity edge), link at the span's +I-bus base, install via the descending mirrored word walk, sync. The region +constants are pinned against `lp-xt-emu`'s `BoardProfile::esp32()` and the +whole install-then-execute path runs on the host in +`tests/xt_classic_profile.rs`. + **3. Each backend is a Cargo feature, and firmware pays only for its own.** `isa-rv32` and `isa-xt` gate the modules, the `IsaTarget` variants, and every match arm. `default = ["isa-rv32", "isa-xt"]`, so host builds and tests get diff --git a/lp-shader/lpvm-native/src/codemem_esp32.rs b/lp-shader/lpvm-native/src/codemem_esp32.rs new file mode 100644 index 000000000..06a9529c4 --- /dev/null +++ b/lp-shader/lpvm-native/src/codemem_esp32.rs @@ -0,0 +1,551 @@ +//! Classic-ESP32 (LX6) JIT code memory: a fixed SRAM1 region written through +//! the word-**mirrored** D-bus view. +//! +//! Unlike the S3 (uniform `+0x6F_0000` alias — "the heap is executable", see +//! [`crate::exec_addr`]), the classic chip's heap (SRAM2/dram_seg) has **no +//! I-bus view at all** — executing a D-bus address faults with EXCCAUSE=2. +//! Dynamically written code must go to a fixed region of SRAM1, whose dual +//! mapping is word-mirrored (hardware-measured, all 5 sentinels; the linear +//! hypothesis matched none): +//! +//! ```text +//! iram = 0x400B_FFFC − (dram − 0x3FFE_0000) (word granularity) +//! ``` +//! +//! The two windows run in opposite directions: writing I-bus-contiguous code +//! means walking the D-bus **downward** word by word. Everything here is keyed +//! on the I-bus layout — byte `4*i` of an installed image is fetchable at +//! `span_base + 4*i` — and the write address is computed per word, which +//! absorbs the mirroring in one line of address math. Bytes within each +//! little-endian 32-bit word are verbatim — no byte swap. +//! +//! # Consequences for the JIT pipeline +//! +//! Code cannot execute from the staging `Vec` the linker fills (that Vec lives +//! in non-executable heap), so the classic pipeline is **link-at-base then +//! copy**: reserve a span in the region ([`CodeArena::alloc`]), patch +//! intra-module call targets against the span's I-bus base +//! ([`crate::link::link_jit_at`]), then install the staged bytes through the +//! mirrored walk ([`install`]). [`crate::rt_jit::JitBuffer`]'s `Placed` +//! variant then names the installed code by its I-bus address directly — +//! [`crate::exec_addr`]'s in-place rule is never consulted. +//! +//! The default region ([`CodeRegion::ESP32_DEFAULT`]) matches +//! `lp-xt-emu`'s `BoardProfile::esp32()` **by construction**, and the +//! emulator models the mirrored alias from the same silicon measurements, so +//! the whole install-then-execute path is testable on the host +//! (`tests/xt_classic_profile.rs`). A region change that breaks emulator +//! parity fails the pinned const-asserts below. +//! +//! The firmware crate owns the *reservation* of the region (keeping the +//! linker out of it); this module owns the address math and the write +//! discipline. Word-aligned volatile writes are the safe default on this chip +//! (mandatory on SRAM0's word-only bus; harmless on SRAM1). + +use alloc::vec::Vec; + +/// SRAM1 D-bus window base (the mirrored rule's D-bus origin). +pub const SRAM1_DRAM_BASE: u32 = 0x3FFE_0000; +/// I-bus address of the word at [`SRAM1_DRAM_BASE`] (the mirrored rule's top). +pub const SRAM1_IRAM_TOP: u32 = 0x400B_FFFC; +/// D-bus end (exclusive) of SRAM1's dual-mapped window. +pub const SRAM1_DRAM_END: u32 = 0x4000_0000; + +/// Errors from code-region placement and installation. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CodeMemError { + /// The requested span does not fit the region — the real capacity limit + /// the S3's heap-backed path does not have. `largest_free` distinguishes + /// "region genuinely full" from "full enough that fragmentation bites". + TooLarge { + requested: u32, + largest_free: u32, + capacity: u32, + }, + /// Span not word-aligned or outside the region. + BadSpan { ibus_base: u32, len: u32 }, +} + +impl core::fmt::Display for CodeMemError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CodeMemError::TooLarge { + requested, + largest_free, + capacity, + } => write!( + f, + "JIT code of {requested} B does not fit the classic code region \ + (largest free span {largest_free} B of {capacity} B)" + ), + CodeMemError::BadSpan { ibus_base, len } => { + write!(f, "bad code span {ibus_base:#x}+{len:#x}") + } + } + } +} + +/// A fixed SRAM1 code region, named by its D-bus placement. +/// +/// The firmware crate picks the region (and must keep the linker from placing +/// sections in it); everything else derives from these two numbers. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct CodeRegion { + /// D-bus base of the region (word-aligned, inside SRAM1's window). + pub dbus_base: u32, + /// Region length in bytes (word multiple). + pub len_bytes: u32, +} + +impl CodeRegion { + /// The classic-ESP32 default: D-bus `0x3FFE_8000` + 92 KiB, I-bus image + /// `0x400A_1000..0x400B_8000`. Chosen inside esp-hal's `dram2_seg` free + /// span and clear of the ROM data/stack reservations lower in SRAM1; + /// hardware-proven by the experiment repo's payload runner, and modeled + /// exactly by `lp-xt-emu`'s `BoardProfile::esp32()`. + pub const ESP32_DEFAULT: CodeRegion = CodeRegion { + dbus_base: 0x3FFE_8000, + len_bytes: 0x0001_7000, + }; + + /// I-bus address of byte 0 of the region's executable image — the + /// *lowest* I-bus address, which under the mirrored rule is the image of + /// the D-bus **last** word. + #[must_use] + pub const fn ibus_base(&self) -> u32 { + SRAM1_IRAM_TOP - ((self.dbus_base + self.len_bytes - 4) - SRAM1_DRAM_BASE) + } + + /// I-bus end (exclusive) of the executable image. + #[must_use] + pub const fn ibus_end(&self) -> u32 { + self.ibus_base() + self.len_bytes + } + + /// The D-bus address through which the word at I-bus address `ibus` is + /// written: the inverse of the mirrored rule. As `ibus` ascends, this + /// walks the D-bus **downward**. + #[must_use] + pub const fn dbus_write_addr(&self, ibus: u32) -> u32 { + SRAM1_DRAM_BASE + (SRAM1_IRAM_TOP - ibus) + } + + /// Panics unless the region is word-aligned and inside SRAM1's + /// dual-mapped window. Called by [`CodeArena::new`]; call directly when + /// constructing a custom region. + pub fn validate(&self) { + assert!( + self.dbus_base % 4 == 0 && self.len_bytes % 4 == 0 && self.len_bytes > 0, + "code region {:#x}+{:#x} not word-aligned", + self.dbus_base, + self.len_bytes + ); + assert!( + self.dbus_base >= SRAM1_DRAM_BASE + && (self.dbus_base as u64 + self.len_bytes as u64) <= SRAM1_DRAM_END as u64, + "code region {:#x}+{:#x} outside SRAM1's dual-mapped window", + self.dbus_base, + self.len_bytes + ); + } +} + +// Pin the default so a change that breaks emulator parity is loud; the +// emulator's classic profile derives 0x400A_1000 from the same numbers +// (cross-checked against `lp-xt-emu` itself in `tests/xt_classic_profile.rs`). +const _: () = assert!(CodeRegion::ESP32_DEFAULT.ibus_base() == 0x400A_1000); +const _: () = assert!(CodeRegion::ESP32_DEFAULT.ibus_end() == 0x400B_8000); +// The mirror rule round-trips at both region ends. +const _: () = { + let r = CodeRegion::ESP32_DEFAULT; + assert!(r.dbus_write_addr(r.ibus_base()) == r.dbus_base + r.len_bytes - 4); + assert!(r.dbus_write_addr(r.ibus_end() - 4) == r.dbus_base); +}; + +/// Where installed words go. The device implementation writes through the +/// mirrored D-bus addresses; tests hand in an `lp-xt-emu` memory (or a plain +/// map) so the walk is verified against the silicon-measured alias model +/// without hardware. +pub trait CodeSink { + fn write_word(&mut self, dbus_addr: u32, word: u32); + /// Make installed code visible to the fetch path. Belt-and-braces on the + /// classic chip (internal SRAM is uncached; measured), so default no-op. + fn sync(&mut self) {} +} + +/// The device sink: word-aligned volatile writes plus a full fence. +/// +/// Only meaningful on the classic chip itself; compiled for any Xtensa target +/// so the crate builds identically across firmware crates, but constructing +/// it on the S3 would be a wiring bug (the S3 pipeline is heap-in-place). +/// +/// No `isync` here, deliberately: internal SRAM is uncached on this chip and +/// the experiment repo measured (C2n) that freshly written SRAM1 code +/// executes with **no barriers at all**. The `fence` (LLVM lowers `SeqCst` +/// to `memw` on Xtensa) is kept as free insurance, but `isync` would need +/// inline asm, which is `asm_experimental_arch` on Xtensa — and this crate's +/// app path deliberately never enables that feature (see fw-esp32s3's +/// `board/esp32s3/mod.rs` for the same posture). If silicon ever proves an +/// `isync` necessary, the firmware crate supplies its own [`CodeSink`] with +/// the asm, feature-gated there, rather than this crate taking the feature. +#[cfg(target_arch = "xtensa")] +pub struct DeviceCodeSink; + +#[cfg(target_arch = "xtensa")] +impl CodeSink for DeviceCodeSink { + fn write_word(&mut self, dbus_addr: u32, word: u32) { + // SAFETY: `install` only hands out word-aligned D-bus addresses + // inside a validated region the firmware reserved for JIT code. + unsafe { (dbus_addr as *mut u32).write_volatile(word) }; + } + + fn sync(&mut self) { + core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); + } +} + +/// Install `code` at I-bus address `ibus_base` inside `region`, walking the +/// mirrored D-bus write addresses word by word. Trailing bytes of the final +/// word are zero-padded (they sit after the final instruction and are never +/// executed). Does NOT call [`CodeSink::sync`]; the caller syncs once after +/// all installs for a module. +pub fn install( + region: &CodeRegion, + ibus_base: u32, + code: &[u8], + sink: &mut impl CodeSink, +) -> Result<(), CodeMemError> { + let len = u32::try_from(code.len()).map_err(|_| CodeMemError::BadSpan { + ibus_base, + len: u32::MAX, + })?; + let padded = len.div_ceil(4) * 4; + let in_region = ibus_base % 4 == 0 + && ibus_base >= region.ibus_base() + && (ibus_base as u64 + padded as u64) <= region.ibus_end() as u64; + if !in_region { + return Err(CodeMemError::BadSpan { ibus_base, len }); + } + for i in 0..(padded / 4) { + let start = (i * 4) as usize; + let end = (start + 4).min(code.len()); + let mut w = [0u8; 4]; + w[..end - start].copy_from_slice(&code[start..end]); + sink.write_word( + region.dbus_write_addr(ibus_base + i * 4), + u32::from_le_bytes(w), + ); + } + Ok(()) +} + +/// Span allocator over a [`CodeRegion`]: first-fit over a sorted free list, +/// word-granular, with adjacent-free coalescing. This is what makes +/// "region full" a *clean error* ([`CodeMemError::TooLarge`]) instead of +/// corruption — the real capacity edge the classic chip has and the S3's +/// heap-backed path does not. +/// +/// The arena hands out I-bus base addresses; freeing is by the same +/// `(ibus_base, len)` pair. Module lifetime wiring (who frees on drop) is the +/// engine's concern, not the arena's. +pub struct CodeArena { + region: CodeRegion, + /// Sorted, non-adjacent free spans as `(ibus_base, len)`. + free: Vec<(u32, u32)>, +} + +impl CodeArena { + #[must_use] + pub fn new(region: CodeRegion) -> Self { + region.validate(); + Self { + free: alloc::vec![(region.ibus_base(), region.len_bytes)], + region, + } + } + + #[must_use] + pub fn region(&self) -> &CodeRegion { + &self.region + } + + #[must_use] + pub fn capacity(&self) -> u32 { + self.region.len_bytes + } + + /// Total free bytes (may be fragmented). + #[must_use] + pub fn available(&self) -> u32 { + self.free.iter().map(|(_, l)| l).sum() + } + + /// Largest single allocatable span. + #[must_use] + pub fn largest_free(&self) -> u32 { + self.free.iter().map(|(_, l)| *l).max().unwrap_or(0) + } + + /// Reserve a word-aligned span of at least `len` bytes; returns its I-bus + /// base. First-fit keeps the common single-module case at the region + /// base, matching the experiment runner's layout. + pub fn alloc(&mut self, len: u32) -> Result { + let need = len.max(4).div_ceil(4) * 4; + for i in 0..self.free.len() { + let (base, flen) = self.free[i]; + if flen >= need { + if flen == need { + self.free.remove(i); + } else { + self.free[i] = (base + need, flen - need); + } + return Ok(base); + } + } + Err(CodeMemError::TooLarge { + requested: len, + largest_free: self.largest_free(), + capacity: self.capacity(), + }) + } + + /// Return a span. `len` is the length passed to [`CodeArena::alloc`] + /// (re-rounded here, so callers may pass the original byte length). + pub fn free(&mut self, ibus_base: u32, len: u32) { + let need = len.max(4).div_ceil(4) * 4; + debug_assert!( + ibus_base >= self.region.ibus_base() + && (ibus_base + need) <= self.region.ibus_end() + && ibus_base % 4 == 0, + "freeing span {ibus_base:#x}+{need:#x} outside the arena" + ); + let idx = self + .free + .iter() + .position(|&(b, _)| b > ibus_base) + .unwrap_or(self.free.len()); + self.free.insert(idx, (ibus_base, need)); + // Coalesce with the next, then the previous, neighbor. + if idx + 1 < self.free.len() { + let (b, l) = self.free[idx]; + let (nb, nl) = self.free[idx + 1]; + debug_assert!(b + l <= nb, "double free / overlap at {b:#x}"); + if b + l == nb { + self.free[idx] = (b, l + nl); + self.free.remove(idx + 1); + } + } + if idx > 0 { + let (pb, pl) = self.free[idx - 1]; + let (b, l) = self.free[idx]; + debug_assert!(pb + pl <= b, "double free / overlap at {b:#x}"); + if pb + pl == b { + self.free[idx - 1] = (pb, pl + l); + self.free.remove(idx); + } + } + } +} + +/// The firmware-installed global arena behind the `xt-placed-code` feature. +/// +/// The JIT engine is constructed deep inside the graphics backend with no +/// classic-specific parameters, so the fw crate installs the arena once at +/// boot and the engine reaches it here. Single-threaded firmware is the +/// operating assumption; the busy flag turns an accidental concurrent +/// compile into a clean error instead of an aliased `&mut`. +#[cfg(feature = "xt-placed-code")] +pub mod global { + use core::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; + + use super::{CodeArena, CodeRegion}; + + static ARENA: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); + static BUSY: AtomicBool = AtomicBool::new(false); + + /// Install the classic code arena. Call once at boot, before the first + /// shader compile; the arena is leaked (it lives for the firmware's + /// lifetime by design). + pub fn install(region: CodeRegion) { + let arena = alloc::boxed::Box::leak(alloc::boxed::Box::new(CodeArena::new(region))); + let prev = ARENA.swap(arena, Ordering::SeqCst); + assert!(prev.is_null(), "classic JIT code arena installed twice"); + } + + /// True once [`install`] has run. + #[must_use] + pub fn installed() -> bool { + !ARENA.load(Ordering::SeqCst).is_null() + } + + /// Run `f` with exclusive access to the arena. + /// + /// `Err(NotInstalled)` when boot never installed one; `Err(Busy)` if a + /// second use overlaps the first (a reentrancy bug upstream, reported + /// rather than aliased). + pub(crate) fn with(f: impl FnOnce(&mut CodeArena) -> R) -> Result { + let ptr = ARENA.load(Ordering::SeqCst); + if ptr.is_null() { + return Err(GlobalArenaError::NotInstalled); + } + if BUSY.swap(true, Ordering::SeqCst) { + return Err(GlobalArenaError::Busy); + } + // SAFETY: BUSY guarantees exclusivity; the pointer is never freed + // after install. + let result = f(unsafe { &mut *ptr }); + BUSY.store(false, Ordering::SeqCst); + Ok(result) + } + + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub(crate) enum GlobalArenaError { + NotInstalled, + Busy, + } + + impl core::fmt::Display for GlobalArenaError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + GlobalArenaError::NotInstalled => f.write_str( + "classic JIT code arena not installed — firmware boot must call \ + codemem_esp32::global::install before the first shader compile", + ), + GlobalArenaError::Busy => { + f.write_str("classic JIT code arena is busy (reentrant compile?)") + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The five hardware sentinels that decided mirrored-vs-linear, replayed + /// against the pure math. (dram, iram) pairs from the measured rule. + #[test] + fn mirror_formula_matches_the_hardware_sentinels() { + // iram = 0x400B_FFFC − (dram − 0x3FFE_0000), exercised via the + // region's inverse map: dbus_write_addr(iram) == dram. + let r = CodeRegion { + dbus_base: SRAM1_DRAM_BASE, + len_bytes: 0x2_0000, + }; + for (dram, iram) in [ + (0x3FFE_0000u32, 0x400B_FFFCu32), + (0x3FFE_0004, 0x400B_FFF8), + (0x3FFE_8000, 0x400B_7FFC), + (0x3FFF_EFFC, 0x400A_1000), + (0x3FFF_FFFC, 0x400A_0000), + ] { + assert_eq!(r.dbus_write_addr(iram), dram, "iram {iram:#x}"); + } + } + + #[test] + fn default_region_is_the_runner_region() { + let r = CodeRegion::ESP32_DEFAULT; + r.validate(); + assert_eq!(r.ibus_base(), 0x400A_1000); + assert_eq!(r.ibus_end(), 0x400B_8000); + // Word 0 writes through the D-bus LAST word; the last word writes + // through the D-bus base — the descending walk. + assert_eq!(r.dbus_write_addr(r.ibus_base()), 0x3FFF_EFFC); + assert_eq!(r.dbus_write_addr(r.ibus_end() - 4), 0x3FFE_8000); + } + + struct MapSink(alloc::collections::BTreeMap); + impl CodeSink for MapSink { + fn write_word(&mut self, dbus_addr: u32, word: u32) { + assert_eq!(dbus_addr % 4, 0); + self.0.insert(dbus_addr, word); + } + } + + #[test] + fn install_walks_downward_and_pads_the_tail() { + let r = CodeRegion::ESP32_DEFAULT; + let mut sink = MapSink(Default::default()); + // 6 bytes → 2 words, second word tail-padded with zeros. + install(&r, r.ibus_base(), &[1, 2, 3, 4, 5, 6], &mut sink).unwrap(); + assert_eq!( + sink.0.get(&r.dbus_write_addr(r.ibus_base())), + Some(&u32::from_le_bytes([1, 2, 3, 4])) + ); + assert_eq!( + sink.0.get(&r.dbus_write_addr(r.ibus_base() + 4)), + Some(&u32::from_le_bytes([5, 6, 0, 0])) + ); + // Downward: word 1's write address is 4 BELOW word 0's. + assert_eq!( + r.dbus_write_addr(r.ibus_base() + 4), + r.dbus_write_addr(r.ibus_base()) - 4 + ); + } + + #[test] + fn install_rejects_out_of_region_spans() { + let r = CodeRegion::ESP32_DEFAULT; + let mut sink = MapSink(Default::default()); + // Would overhang the region end by one word. + let near_end = r.ibus_end() - 4; + assert!(matches!( + install(&r, near_end, &[0; 8], &mut sink), + Err(CodeMemError::BadSpan { .. }) + )); + // Unaligned base. + assert!(matches!( + install(&r, r.ibus_base() + 2, &[0; 4], &mut sink), + Err(CodeMemError::BadSpan { .. }) + )); + assert!(sink.0.is_empty(), "nothing written on rejection"); + } + + #[test] + fn arena_alloc_free_coalesces() { + let mut a = CodeArena::new(CodeRegion::ESP32_DEFAULT); + let base = a.region().ibus_base(); + let s1 = a.alloc(101).unwrap(); // word-rounds to 104 + let s2 = a.alloc(8).unwrap(); + let s3 = a.alloc(16).unwrap(); + assert_eq!(s1, base); + assert_eq!(s2, base + 104); + assert_eq!(s3, base + 112); + // Free the middle, then the first: they coalesce into one 112-byte + // hole at the base, while s3 still splits it from the tail. + a.free(s2, 8); + a.free(s1, 101); + assert_eq!(a.available(), a.capacity() - 16); // only s3 remains + assert_eq!(a.largest_free(), a.capacity() - 128); // the tail + // A span that fits the coalesced hole exactly lands at the base again. + let s4 = a.alloc(112).unwrap(); + assert_eq!(s4, base); + // Free everything: back to one full-capacity span. + a.free(s4, 112); + a.free(s3, 16); + assert_eq!(a.available(), a.capacity()); + assert_eq!(a.largest_free(), a.capacity()); + } + + #[test] + fn arena_full_is_a_clean_toolarge() { + let mut a = CodeArena::new(CodeRegion::ESP32_DEFAULT); + let cap = a.capacity(); + let _ = a.alloc(cap - 8).unwrap(); + let err = a.alloc(64).unwrap_err(); + match err { + CodeMemError::TooLarge { + requested, + largest_free, + capacity, + } => { + assert_eq!(requested, 64); + assert_eq!(largest_free, 8); + assert_eq!(capacity, cap); + } + other => panic!("expected TooLarge, got {other:?}"), + } + } +} diff --git a/lp-shader/lpvm-native/src/exec_addr.rs b/lp-shader/lpvm-native/src/exec_addr.rs index 8fe18cd79..dc53a39d8 100644 --- a/lp-shader/lpvm-native/src/exec_addr.rs +++ b/lp-shader/lpvm-native/src/exec_addr.rs @@ -75,14 +75,19 @@ pub(crate) fn exec_addr(write: usize) -> usize { write } -/// Xtensa sibling of [`exec_addr`] — a per-board *rule*, not a bare constant: +/// Xtensa sibling of [`exec_addr`] — the **in-place** (heap-backed) rule, +/// which among Xtensa chips only the ESP32-S3 has: /// /// - **ESP32-S3**: the dual-mapped window above. -/// - **Classic ESP32** (M7 of the backport): the heap is NOT executable and -/// SRAM1's alias is word-mirrored, so classic JIT buffers must be IRAM-side -/// allocations whose addresses are already executable — the fall-through -/// arm. A heap write address outside a known dual-mapped window is a -/// placement bug, caught by the debug assert. +/// - **Classic ESP32**: has NO in-place rule at all — the heap is not +/// executable and SRAM1's alias is word-mirrored, so no address offset can +/// make a heap buffer fetchable. Classic JIT code takes the *placed* path +/// instead ([`crate::codemem_esp32`] + `JitBuffer::Placed` + +/// [`crate::link::link_jit_at`]) and never consults this function. If +/// classic code DOES land here, it is a wiring bug (an in-place buffer on +/// a chip that cannot execute one); the fall-through arm's debug assert +/// catches the heap case, and I-bus addresses (≥ `0x4000_0000`) pass +/// through as identity, which is correct on every Xtensa chip. #[cfg(target_arch = "xtensa")] #[must_use] pub(crate) fn exec_addr(write: usize) -> usize { diff --git a/lp-shader/lpvm-native/src/lib.rs b/lp-shader/lpvm-native/src/lib.rs index 46b92b490..70fdf7ffd 100644 --- a/lp-shader/lpvm-native/src/lib.rs +++ b/lp-shader/lpvm-native/src/lib.rs @@ -36,6 +36,7 @@ extern crate alloc; pub use log; pub mod abi; +pub mod codemem_esp32; pub mod compile; pub mod config; pub mod debug; diff --git a/lp-shader/lpvm-native/src/link.rs b/lp-shader/lpvm-native/src/link.rs index 317a43324..7d52dd3c9 100644 --- a/lp-shader/lpvm-native/src/link.rs +++ b/lp-shader/lpvm-native/src/link.rs @@ -21,7 +21,9 @@ pub struct LinkedJitImage { pub entries: VecMap, } -/// Resolve all relocations and produce a JIT-ready image. +/// Resolve all relocations and produce a JIT-ready image that executes **in +/// place** — from the returned `code` Vec itself, through the target's +/// write→execute rule ([`crate::exec_addr`]). /// /// # Arguments /// * `module` - Compiled module with functions and relocations @@ -32,6 +34,40 @@ pub struct LinkedJitImage { pub fn link_jit( module: &CompiledModule, isa: IsaTarget, + resolve_symbol: F, +) -> Result +where + F: FnMut(&str) -> Option, +{ + link_jit_impl(module, isa, None, resolve_symbol) +} + +/// [`link_jit`] for an image that will be **copied elsewhere before +/// execution**: intra-module call targets are patched against `exec_base`, +/// the address byte 0 of the image will be *fetched* at after installation — +/// not against the staging Vec's own address. +/// +/// This is the classic-ESP32 path ([`crate::codemem_esp32`]): the staging Vec +/// lives in non-executable heap, so the in-place rule has no valid answer for +/// it; the caller reserves a span in the fixed code region first and links +/// against that. It is also how the host tests link images for execution at +/// an emulator's code base. +pub fn link_jit_at( + module: &CompiledModule, + isa: IsaTarget, + exec_base: u32, + resolve_symbol: F, +) -> Result +where + F: FnMut(&str) -> Option, +{ + link_jit_impl(module, isa, Some(exec_base), resolve_symbol) +} + +fn link_jit_impl( + module: &CompiledModule, + isa: IsaTarget, + exec_base: Option, mut resolve_symbol: F, ) -> Result where @@ -74,19 +110,26 @@ where // // The resolver's answers (builtins) are addresses of code // linked into the firmware, so they are already execute - // addresses. Intra-module targets are not: they are derived - // from `image_base`, which is where the linker *writes*. The - // emitted code jumps to whatever goes in here, so it must be - // the address the fetch path names — on the S3 that is the - // I-bus alias, not the D-bus address the bytes were stored - // through. See `crate::exec_addr`. + // addresses. Intra-module targets are not: the emitted code + // jumps to whatever goes in here, so it must be the address + // the fetch path will name. In-place (`exec_base == None`) + // that is the staging Vec's own address through the target's + // write→execute rule — on the S3 the I-bus alias, not the + // D-bus address the bytes were stored through (see + // `crate::exec_addr`). For a placed image it is the caller's + // `exec_base`, where the bytes will be installed. let target_offset = entries.get(&reloc.symbol).ok_or_else(|| { NativeError::Internal(format!( "unresolved symbol `{}` for JIT relocation at offset {}", reloc.symbol, reloc.offset )) })?; - crate::exec_addr::exec_addr(image_base.wrapping_add(*target_offset)) as u32 + match exec_base { + None => { + crate::exec_addr::exec_addr(image_base.wrapping_add(*target_offset)) as u32 + } + Some(base) => base.wrapping_add(*target_offset as u32), + } }; let absolute_offset = func_base + reloc.offset; @@ -99,8 +142,16 @@ where symbol: String::new(), r_type: reloc.r_type, }; + // PC-relative patching needs the address the code + // will EXECUTE at: in place that is the write base + // (identity rule on every rv32 target), for a placed + // image it is the caller's exec_base. + let pc_base = match exec_base { + None => image_base, + Some(base) => base as usize, + }; crate::isa::rv32::link::patch_call_plt( - &mut code, &abs_reloc, image_base, target, + &mut code, &abs_reloc, pc_base, target, )?; } else { return Err(NativeError::Internal(alloc::format!( diff --git a/lp-shader/lpvm-native/src/rt_jit/buffer.rs b/lp-shader/lpvm-native/src/rt_jit/buffer.rs index fae39264e..0b78fcaf0 100644 --- a/lp-shader/lpvm-native/src/rt_jit/buffer.rs +++ b/lp-shader/lpvm-native/src/rt_jit/buffer.rs @@ -1,58 +1,135 @@ -//! Executable JIT code buffer (bytes live in RAM; on ESP32-C6 DRAM is executable). +//! Executable JIT code buffer. //! -//! The buffer owns the *write* address of the emitted code — the `Vec` the -//! linker filled in and any patching writes through. [`JitBuffer::exec_ptr`] -//! turns that into the address the CPU is allowed to fetch from, applying the -//! rule in [`crate::exec_addr`]; see there for why the two can differ. +//! Two placements exist: +//! +//! - **Heap** (host, ESP32-C6, ESP32-S3): the buffer owns the *write* address +//! of the emitted code — the `Vec` the linker filled in and any patching +//! writes through. [`JitBuffer::exec_ptr`] turns that into the address the +//! CPU is allowed to fetch from, applying the rule in [`crate::exec_addr`]; +//! see there for why the two can differ. +//! - **Placed** (classic ESP32): the code was *copied out* of its staging Vec +//! into a fixed executable region ([`crate::codemem_esp32`]) because the +//! heap has no I-bus view at all. The buffer holds the installed image's +//! I-bus base directly; [`crate::exec_addr`]'s in-place rule is never +//! consulted. The span belongs to the [`crate::codemem_esp32::CodeArena`] +//! it came from — dropping the buffer does NOT free it; the engine that +//! allocated the span owns that lifetime. use alloc::vec::Vec; -/// Holds emitted RISC-V machine code for one module. +enum Inner { + /// Emitted code executing in place out of its own allocation. + Heap { code: Vec }, + /// Code installed at a fixed executable address; `exec_base` is already + /// an execute (I-bus) address. The span belongs to an EXTERNAL arena the + /// caller owns — dropping the buffer does not free it. + Placed { exec_base: usize, len: usize }, + /// As `Placed`, but the span came from `codemem_esp32::global`'s arena + /// (the `xt-placed-code` firmware path) and is returned to it on drop — + /// without this, every Studio recompile would leak a slice of the 92 KiB + /// region until `TooLarge`. + #[cfg(feature = "xt-placed-code")] + PlacedGlobal { exec_base: usize, len: usize }, +} + +#[cfg(feature = "xt-placed-code")] +impl Drop for JitBuffer { + fn drop(&mut self) { + if let Inner::PlacedGlobal { exec_base, len } = self.inner { + // NotInstalled/Busy cannot free the span; leaking is the safe + // failure (a leak is recoverable by reboot, a double-use is + // corruption). + let _ = crate::codemem_esp32::global::with(|arena| { + arena.free(exec_base as u32, len as u32); + }); + } + } +} + +/// Holds emitted machine code for one module. pub struct JitBuffer { - code: Vec, + inner: Inner, } impl JitBuffer { pub(crate) fn from_code(code: Vec) -> Self { - Self { code } + Self { + inner: Inner::Heap { code }, + } + } + + /// A buffer for code already installed at execute address `exec_base` + /// (classic ESP32 fixed-region path). The caller guarantees `len` bytes + /// of linked code are installed there and synced before the first call. + pub(crate) fn from_placed(exec_base: usize, len: usize) -> Self { + debug_assert!(exec_base % 4 == 0); + Self { + inner: Inner::Placed { exec_base, len }, + } + } + + /// [`JitBuffer::from_placed`] for a span owned by the global arena; the + /// span is freed back to it when this buffer drops. + #[cfg(feature = "xt-placed-code")] + pub(crate) fn from_placed_global(exec_base: usize, len: usize) -> Self { + debug_assert!(exec_base % 4 == 0); + Self { + inner: Inner::PlacedGlobal { exec_base, len }, + } } /// Byte length of emitted code. #[must_use] pub fn len(&self) -> usize { - self.code.len() + match &self.inner { + Inner::Heap { code } => code.len(), + Inner::Placed { len, .. } => *len, + #[cfg(feature = "xt-placed-code")] + Inner::PlacedGlobal { len, .. } => *len, + } } /// True if no code was emitted. #[must_use] pub fn is_empty(&self) -> bool { - self.code.is_empty() + self.len() == 0 } /// **Execute** address of the code at byte `offset` (must be 4-byte /// aligned, in bounds). /// - /// The bytes were stored through the `Vec`'s own address; on some targets - /// the instruction fetch path names those same bytes at a different one. - /// That rule lives in [`crate::exec_addr`] — this is one of its two - /// callers, the other being the linker, which patches intra-module call - /// targets into the image. + /// Heap placement: the bytes were stored through the `Vec`'s own address; + /// on some targets the instruction fetch path names those same bytes at a + /// different one. That rule lives in [`crate::exec_addr`] — this is one + /// of its two callers, the other being the linker, which patches + /// intra-module call targets into the image. (No explicit barrier is + /// needed on either Xtensa chip: internal SRAM is uncached, measured on + /// silicon — the placed path's sink still fences as free insurance.) /// - /// On Xtensa the rule is paired with the belt-and-braces `memw` + `isync` - /// after emission, so the fetch path observes the stores. + /// Placed placement: `exec_base` is already an execute address, so this + /// is plain offset arithmetic. /// - /// Code **writers** (linking, relocation patching) address the buffer's - /// own storage, which is already the write address — they must not route - /// *that* through here. The addresses they store *into* the code are a - /// different matter: those are jump targets, and they do need the rule. + /// Code **writers** (linking, relocation patching) address the staging + /// buffer's own storage, which is already the write address — they must + /// not route *that* through here. The addresses they store *into* the + /// code are a different matter: those are jump targets, and they do need + /// the placement's rule (`link_jit` in place, `link_jit_at` for placed + /// images). /// /// # Safety /// Same as dereferencing a function pointer into this buffer. #[must_use] pub unsafe fn exec_ptr(&self, offset: usize) -> *const u8 { - debug_assert!(offset <= self.code.len()); + debug_assert!(offset <= self.len()); debug_assert!(offset % 4 == 0); - let write = unsafe { self.code.as_ptr().add(offset) }; - crate::exec_addr::exec_addr(write as usize) as *const u8 + match &self.inner { + Inner::Heap { code } => { + let write = unsafe { code.as_ptr().add(offset) }; + crate::exec_addr::exec_addr(write as usize) as *const u8 + } + Inner::Placed { exec_base, .. } => (exec_base + offset) as *const u8, + #[cfg(feature = "xt-placed-code")] + Inner::PlacedGlobal { exec_base, .. } => (exec_base + offset) as *const u8, + } } } diff --git a/lp-shader/lpvm-native/src/rt_jit/compiler.rs b/lp-shader/lpvm-native/src/rt_jit/compiler.rs index 74a2e684d..6338bc3d3 100644 --- a/lp-shader/lpvm-native/src/rt_jit/compiler.rs +++ b/lp-shader/lpvm-native/src/rt_jit/compiler.rs @@ -11,6 +11,7 @@ use crate::compile::{CompiledModule, compile_module}; use crate::error::NativeError; use crate::isa::IsaTarget; use crate::jit_symbol_sizes::{derive_sizes, sort_by_offset}; +#[cfg(not(all(feature = "xt-placed-code", target_arch = "xtensa")))] use crate::link::link_jit; use crate::native_options::NativeCompileOptions; use lp_perf::{EVENT_SHADER_LINK, JitSymbolEntry, emit_jit_map_load}; @@ -61,30 +62,157 @@ pub(crate) fn link_compiled_module_jit( builtin_table: &BuiltinTable, isa: IsaTarget, ) -> Result<(JitBuffer, VecMap), NativeError> { - // 2. Link JIT image with builtin resolution - lp_perf::emit_begin!(EVENT_SHADER_LINK); - let link_result = link_jit(&compiled, isa, |sym| { - // First check builtins - if let Some(addr) = builtin_table.lookup(sym) { - return Some(addr as u32); + // Classic-ESP32 firmware (`xt-placed-code`): every module goes through + // the fixed-region placed path — the heap this Vec would execute from + // in place has no I-bus view on that chip. This is THE chokepoint: all + // three engine entry points (compile, compile_with_config, the async + // compile job) land here. + #[cfg(all(feature = "xt-placed-code", target_arch = "xtensa"))] + { + link_compiled_module_jit_placed_global(compiled, builtin_table, isa) + } + + #[cfg(not(all(feature = "xt-placed-code", target_arch = "xtensa")))] + { + // 2. Link JIT image with builtin resolution + lp_perf::emit_begin!(EVENT_SHADER_LINK); + let link_result = link_jit(&compiled, isa, |sym| { + // First check builtins + if let Some(addr) = builtin_table.lookup(sym) { + return Some(addr as u32); + } + // Functions are resolved during link phase + None + }); + lp_perf::emit_end!(EVENT_SHADER_LINK); + let linked = + link_result.map_err(|e| NativeError::Internal(format!("JIT link failed: {e}")))?; + + // 3. Create JitBuffer from linked code + let buffer = JitBuffer::from_code(linked.code); + + let buffer_len = u32::try_from(buffer.len()) + .map_err(|_| NativeError::Internal("JIT buffer length does not fit u32".into()))?; + // The profiler symbolizes sampled program counters against this base, + // so it is an execute address, not the write address of the buffer. + let base = unsafe { buffer.exec_ptr(0) } as usize as u32; + emit_jit_symbols(base, buffer_len, &linked.entries); + + Ok((buffer, linked.entries)) + } +} + +/// The `xt-placed-code` firmware arm of [`link_compiled_module_jit`]: link +/// at a span from the boot-installed global arena, install through the +/// device's mirrored-write sink, and return a buffer that frees its span on +/// drop. Failing to reserve or install is a clean `NativeError` — on this +/// chip "code region full" is the real capacity edge, and it must surface +/// as a compile error, never a wild write. +#[cfg(all(feature = "xt-placed-code", target_arch = "xtensa"))] +fn link_compiled_module_jit_placed_global( + compiled: CompiledModule, + builtin_table: &BuiltinTable, + isa: IsaTarget, +) -> Result<(JitBuffer, VecMap), NativeError> { + use crate::codemem_esp32::{self, DeviceCodeSink, global}; + + let total: usize = compiled.functions.iter().map(|f| f.code.len()).sum(); + let total_u32 = u32::try_from(total) + .map_err(|_| NativeError::Internal("JIT image length does not fit u32".into()))?; + + let placed = global::with( + |arena| -> Result<(JitBuffer, VecMap), NativeError> { + let exec_base = arena + .alloc(total_u32) + .map_err(|e| NativeError::Internal(format!("JIT code placement failed: {e}")))?; + + let place = |arena: &mut codemem_esp32::CodeArena| { + lp_perf::emit_begin!(EVENT_SHADER_LINK); + let link_result = crate::link::link_jit_at(&compiled, isa, exec_base, |sym| { + builtin_table.lookup(sym).map(|addr| addr as u32) + }); + lp_perf::emit_end!(EVENT_SHADER_LINK); + let linked = link_result + .map_err(|e| NativeError::Internal(format!("JIT link failed: {e}")))?; + + let mut sink = DeviceCodeSink; + codemem_esp32::install(arena.region(), exec_base, &linked.code, &mut sink) + .map_err(|e| NativeError::Internal(format!("JIT code install failed: {e}")))?; + crate::codemem_esp32::CodeSink::sync(&mut sink); + + let buffer = JitBuffer::from_placed_global(exec_base as usize, linked.code.len()); + let buffer_len = u32::try_from(buffer.len()).map_err(|_| { + NativeError::Internal("JIT buffer length does not fit u32".into()) + })?; + emit_jit_symbols(exec_base, buffer_len, &linked.entries); + Ok((buffer, linked.entries)) + }; + match place(arena) { + Ok(ok) => Ok(ok), + Err(e) => { + arena.free(exec_base, total_u32); + Err(e) + } + } + }, + ) + .map_err(|e| NativeError::Internal(format!("JIT code placement failed: {e}")))?; + placed +} + +/// [`compile_module_jit`] for the classic-ESP32 **fixed-region** placement: +/// reserve a span in `arena`, link intra-module call targets against the +/// span's I-bus base, install the staged image through the mirrored D-bus +/// walk, sync, and return a `Placed` buffer. +/// +/// The span is freed back to the arena on any post-reservation failure; on +/// success it stays reserved and the CALLER owns its lifetime (free it with +/// `(exec_ptr(0), len)` when the module is dropped — the engine wiring for +/// that lands with the classic firmware, M3 of the classic bring-up plan). +pub fn compile_module_jit_placed( + ir: &LpirModule, + sig: &LpsModuleSig, + builtin_table: &BuiltinTable, + options: &NativeCompileOptions, + isa: IsaTarget, + arena: &mut crate::codemem_esp32::CodeArena, + sink: &mut impl crate::codemem_esp32::CodeSink, +) -> Result<(JitBuffer, VecMap), NativeError> { + let compiled = compile_module(ir, sig, options.float_mode, options.clone(), isa)?; + + let total: usize = compiled.functions.iter().map(|f| f.code.len()).sum(); + let total_u32 = u32::try_from(total) + .map_err(|_| NativeError::Internal("JIT image length does not fit u32".into()))?; + let exec_base = arena + .alloc(total_u32) + .map_err(|e| NativeError::Internal(format!("JIT code placement failed: {e}")))?; + + let mut place = || -> Result<(JitBuffer, VecMap), NativeError> { + lp_perf::emit_begin!(EVENT_SHADER_LINK); + let link_result = crate::link::link_jit_at(&compiled, isa, exec_base, |sym| { + builtin_table.lookup(sym).map(|addr| addr as u32) + }); + lp_perf::emit_end!(EVENT_SHADER_LINK); + let linked = + link_result.map_err(|e| NativeError::Internal(format!("JIT link failed: {e}")))?; + + crate::codemem_esp32::install(arena.region(), exec_base, &linked.code, sink) + .map_err(|e| NativeError::Internal(format!("JIT code install failed: {e}")))?; + sink.sync(); + + let buffer = JitBuffer::from_placed(exec_base as usize, linked.code.len()); + let buffer_len = u32::try_from(buffer.len()) + .map_err(|_| NativeError::Internal("JIT buffer length does not fit u32".into()))?; + emit_jit_symbols(exec_base, buffer_len, &linked.entries); + Ok((buffer, linked.entries)) + }; + match place() { + Ok(ok) => Ok(ok), + Err(e) => { + arena.free(exec_base, total_u32); + Err(e) } - // Functions are resolved during link phase - None - }); - lp_perf::emit_end!(EVENT_SHADER_LINK); - let linked = link_result.map_err(|e| NativeError::Internal(format!("JIT link failed: {e}")))?; - - // 3. Create JitBuffer from linked code - let buffer = JitBuffer::from_code(linked.code); - - let buffer_len = u32::try_from(buffer.len()) - .map_err(|_| NativeError::Internal("JIT buffer length does not fit u32".into()))?; - // The profiler symbolizes sampled program counters against this base, so it - // is an execute address, not the write address of the buffer. - let base = unsafe { buffer.exec_ptr(0) } as usize as u32; - emit_jit_symbols(base, buffer_len, &linked.entries); - - Ok((buffer, linked.entries)) + } } /// Builds [`JitSymbolEntry`] records (names in `name_buf`) and notifies the profiler sink. diff --git a/lp-shader/lpvm-native/src/rt_jit/mod.rs b/lp-shader/lpvm-native/src/rt_jit/mod.rs index 4732f482e..52dd57f65 100644 --- a/lp-shader/lpvm-native/src/rt_jit/mod.rs +++ b/lp-shader/lpvm-native/src/rt_jit/mod.rs @@ -17,7 +17,7 @@ mod module; pub use buffer::JitBuffer; pub use builtins::BuiltinTable; pub use compile_job::NativeJitCompileJob; -pub use compiler::compile_module_jit; +pub use compiler::{compile_module_jit, compile_module_jit_placed}; pub use engine::NativeJitEngine; pub use instance::NativeJitInstance; pub use module::{NativeJitDirectCall, NativeJitModule}; diff --git a/lp-shader/lpvm-native/tests/xt_classic_profile.rs b/lp-shader/lpvm-native/tests/xt_classic_profile.rs new file mode 100644 index 000000000..f18467250 --- /dev/null +++ b/lp-shader/lpvm-native/tests/xt_classic_profile.rs @@ -0,0 +1,212 @@ +//! Classic-ESP32 (LX6) memory-model regressions, on the host. +//! +//! The Xtensa *ISA* needs no classic-specific coverage — LX6 ≡ LX7 in the +//! executed instruction set, proven on silicon (multi-board P6: zero +//! divergences, full corpus). What classic changes is the **memory system**: +//! the heap has no I-bus view, and JIT code must be *installed* into a fixed +//! SRAM1 region through a word-mirrored D-bus walk +//! ([`lpvm_native::codemem_esp32`]). +//! +//! These tests run the real compiler pipeline (LPIR → regalloc → `isa/xt` +//! emit → [`link_jit_at`]) and execute the result on `lp-xt-emu` under +//! `BoardProfile::esp32()`, whose alias model is hardware-measured. The +//! flagship test installs the image through the descending mirrored write +//! walk — the exact discipline the device uses — and executes it at the +//! I-bus base, which is the whole classic JIT story minus silicon. +//! +//! Note on the roadmap's Q6 ("run the xtn.q32 corpus under the esp32 +//! profile"): the corpus's execution engine loads a builtins base image +//! *linked for the S3 memory map*, so a full classic corpus lane needs a +//! second cross-linked builtins image — real work, not registration (the +//! same premise the 2026-07-29 roadmap's M4 got wrong). Since the ISA is +//! proven identical and only the memory model differs, THIS file is the +//! classic guard instead: emitted-code execution + the install walk under +//! the measured classic alias. Recorded as a deviation in the plan. + +use lp_collection::VecMap; +use lpir::builder::FunctionBuilder; +use lpir::{FloatMode, FuncId, IrType, LpirModule, LpirOp}; +use lps_shared::{FnParam, LpsFnKind, LpsFnSig, LpsModuleSig, LpsType, ParamQualifier}; +use lpvm_native::codemem_esp32::{CodeArena, CodeRegion, CodeSink, install}; +use lpvm_native::compile::compile_module; +use lpvm_native::isa::IsaTarget; +use lpvm_native::link::link_jit_at; +use lpvm_native::native_options::NativeCompileOptions; + +use lp_xt_emu::board::BoardProfile; +use lp_xt_emu::{CallOutcome, Emulator, RunOutcome}; + +/// The pure codemem math and the emulator's silicon-measured board model +/// must describe the SAME region — a drift here would let host tests pass +/// against a map the device does not have. +#[test] +fn code_region_matches_the_emulator_profile() { + let region = CodeRegion::ESP32_DEFAULT; + let profile = BoardProfile::esp32(); + assert_eq!(region.dbus_base, profile.code_dbus_base); + assert_eq!(region.len_bytes as usize, profile.code_region_len); + assert_eq!(region.ibus_base(), profile.code_ibus_base()); + + // The inverse write map agrees with the emulator's alias rule at every + // word of the region (both directions of the mirror, full range). + let mut ibus = region.ibus_base(); + while ibus < region.ibus_end() { + assert_eq!( + profile.alias.dbus_to_ibus(region.dbus_write_addr(ibus)), + ibus, + "alias mismatch at ibus {ibus:#x}" + ); + ibus += 4; + } +} + +/// g(x) = 3x; f(x) = g(x) + 1 — two functions so the image contains a real +/// intra-module call, whose literal slot [`link_jit_at`] must patch with the +/// callee's absolute I-bus address. +fn call_module() -> (LpirModule, LpsModuleSig) { + let mut fb = FunctionBuilder::new("g", &[IrType::I32]); + let gx = fb.add_param(IrType::I32); + let three = fb.alloc_vreg(IrType::I32); + fb.push(LpirOp::IconstI32 { + dst: three, + value: 3, + }); + fb.push(LpirOp::Imul { + dst: gx, + lhs: gx, + rhs: three, + }); + fb.push_return(&[gx]); + let g = fb.finish(); + + let mut fb = FunctionBuilder::new("f", &[IrType::I32]); + let x = fb.add_param(IrType::I32); + let call_out = fb.alloc_vreg(IrType::I32); + fb.push_call( + lpir::CalleeRef::Local(FuncId(0)), + &[lpir::VMCTX_VREG, x], + &[call_out], + ); + fb.push(LpirOp::IaddImm { + dst: call_out, + src: call_out, + imm: 1, + }); + fb.push_return(&[call_out]); + let f = fb.finish(); + + let module = LpirModule { + imports: vec![], + functions: VecMap::from([(FuncId(0), g), (FuncId(1), f)]), + }; + let int_sig = |name: &str| LpsFnSig { + name: name.to_string(), + parameters: vec![FnParam { + name: "x".to_string(), + ty: LpsType::Int, + qualifier: ParamQualifier::In, + }], + return_type: LpsType::Int, + kind: LpsFnKind::UserDefined, + }; + let sig = LpsModuleSig { + functions: vec![int_sig("g"), int_sig("f")], + uniforms_type: None, + globals_type: None, + ..Default::default() + }; + (module, sig) +} + +/// Compile [`call_module`] for Xtensa and link it at `exec_base` via the +/// real seam ([`link_jit_at`], no manual patching). Returns the linked image +/// and the entry offset of `f`. +fn compile_and_link_at(exec_base: u32) -> (Vec, u32) { + let (ir, sig) = call_module(); + let opts = NativeCompileOptions { + float_mode: FloatMode::Q32, + fuel: false, + ..Default::default() + }; + let compiled = compile_module(&ir, &sig, FloatMode::Q32, opts, IsaTarget::Xtensa) + .expect("xt compile should succeed"); + let linked = link_jit_at(&compiled, IsaTarget::Xtensa, exec_base, |_| None) + .expect("link_jit_at should succeed"); + let entry = *linked.entries.get("f").expect("entry f exists") as u32; + (linked.code, entry) +} + +/// The pipeline's output executes under the classic profile — emitted code, +/// windowed calls, and the classic code region's fetch rules all agree. +#[test] +fn pipeline_executes_under_the_classic_profile() { + let mut emu = Emulator::with_profile(BoardProfile::esp32()); + let ibus_base = emu.profile.code_ibus_base(); + let (code, entry_off) = compile_and_link_at(ibus_base); + match emu.run_with_args(&code, entry_off, &[0, 14]) { + RunOutcome::Ok(v) => assert_eq!(v as i32, 14 * 3 + 1), + RunOutcome::Trap(t) => panic!("unexpected trap under classic profile: {t:?}"), + } +} + +/// A sink that performs the install through the emulator's **D-bus** view, +/// so every write goes through the mirrored alias exactly as the device's +/// volatile-write walk does. +struct EmuDbusSink<'a> { + emu: &'a mut Emulator, +} + +impl CodeSink for EmuDbusSink<'_> { + fn write_word(&mut self, dbus_addr: u32, word: u32) { + self.emu + .mem + .write_u32(dbus_addr, word) + .unwrap_or_else(|t| panic!("D-bus store at {dbus_addr:#x} trapped: {t:?}")); + } +} + +/// The flagship classic-memory-model test: link at the arena's span base, +/// install through the DESCENDING mirrored D-bus walk, execute at the I-bus +/// base. If the codemem math and the silicon-measured alias model disagree +/// anywhere — direction, word granularity, endianness within the word, span +/// bounds — the emitted code is scrambled and this cannot pass. +#[test] +fn mirrored_install_walk_executes_real_emitted_code() { + let mut arena = CodeArena::new(CodeRegion::ESP32_DEFAULT); + let mut emu = Emulator::with_profile(BoardProfile::esp32()); + + // Reserve a span exactly as the placed pipeline would; first-fit puts it + // at the region base, matching the profile's code_ibus_base. + let probe = compile_and_link_at(0); // link once to learn the image size + let span = arena.alloc(probe.0.len() as u32).expect("span fits"); + let (code, entry_off) = compile_and_link_at(span); + + install( + arena.region(), + span, + &code, + &mut EmuDbusSink { emu: &mut emu }, + ) + .expect("install within the reserved span"); + + match emu.run_loaded_with_args(span + entry_off, &[0, 14], &mut lp_xt_emu::NoopTracer, None) { + CallOutcome::Ok { lo, .. } => assert_eq!(lo as i32, 14 * 3 + 1), + CallOutcome::Trap(t) => panic!("installed code trapped: {t:?}"), + } +} + +/// The classic capacity edge stays a clean error at real-region scale: an +/// image larger than the 92 KiB region must be a `TooLarge`, never a wild +/// write. +#[test] +fn oversized_image_is_a_clean_toolarge() { + let mut arena = CodeArena::new(CodeRegion::ESP32_DEFAULT); + let cap = arena.capacity(); + let err = arena.alloc(cap + 4).unwrap_err(); + let msg = alloc_error_to_string(err); + assert!(msg.contains("does not fit"), "unexpected error: {msg}"); +} + +fn alloc_error_to_string(e: lpvm_native::codemem_esp32::CodeMemError) -> String { + format!("{e}") +} diff --git a/projects/test/quad-equal100-v3/README.md b/projects/test/quad-equal100-v3/README.md new file mode 100644 index 000000000..5586188be --- /dev/null +++ b/projects/test/quad-equal100-v3/README.md @@ -0,0 +1,7 @@ +# Quad equal 100 v3 — the §12 capacity stress shape + +Four EQUAL 100-LED channels through the real app path on the classic +ESP32 (DOM-Z-102). The engine free-runs, so with strips this long the +RMT transmits near back-to-back — the ISR-throughput worst case from +the ws281x findings §12 (the shape that hard-capped 32-word halves at 2 +channels). M4-P3 measures trips/lag at 64-word halves against it. diff --git a/projects/test/quad-equal100-v3/clock.json b/projects/test/quad-equal100-v3/clock.json new file mode 100644 index 000000000..79834c140 --- /dev/null +++ b/projects/test/quad-equal100-v3/clock.json @@ -0,0 +1,8 @@ +{ + "kind": "Clock", + "controls": { + "running": true, + "rate": 1, + "scrub_offset_seconds": 0 + } +} diff --git a/projects/test/quad-equal100-v3/fixture1.json b/projects/test/quad-equal100-v3/fixture1.json new file mode 100644 index 000000000..88c996de3 --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture1.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 100, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch1" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture1.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture1.map2d.json b/projects/test/quad-equal100-v3/fixture1.map2d.json new file mode 100644 index 000000000..f4c154c84 --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture1.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip1", + "shape": { + "grid": { + "origin": [ + 0.5, + 12.5 + ], + "cols": 100, + "rows": 1, + "pitch": 1.0 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture2.json b/projects/test/quad-equal100-v3/fixture2.json new file mode 100644 index 000000000..04b63f68e --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture2.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 100, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch2" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture2.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture2.map2d.json b/projects/test/quad-equal100-v3/fixture2.map2d.json new file mode 100644 index 000000000..6bbf6ba0d --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture2.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip2", + "shape": { + "grid": { + "origin": [ + 0.5, + 37.5 + ], + "cols": 100, + "rows": 1, + "pitch": 1.0 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture3.json b/projects/test/quad-equal100-v3/fixture3.json new file mode 100644 index 000000000..65628b279 --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture3.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 100, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch3" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture3.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture3.map2d.json b/projects/test/quad-equal100-v3/fixture3.map2d.json new file mode 100644 index 000000000..e7e1c62dd --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture3.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip3", + "shape": { + "grid": { + "origin": [ + 0.5, + 62.5 + ], + "cols": 100, + "rows": 1, + "pitch": 1.0 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture4.json b/projects/test/quad-equal100-v3/fixture4.json new file mode 100644 index 000000000..ec0adf56a --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture4.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 100, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch4" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture4.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/fixture4.map2d.json b/projects/test/quad-equal100-v3/fixture4.map2d.json new file mode 100644 index 000000000..6aac1e71f --- /dev/null +++ b/projects/test/quad-equal100-v3/fixture4.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip4", + "shape": { + "grid": { + "origin": [ + 0.5, + 87.5 + ], + "cols": 100, + "rows": 1, + "pitch": 1.0 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/output1.json b/projects/test/quad-equal100-v3/output1.json new file mode 100644 index 000000000..9a1737895 --- /dev/null +++ b/projects/test/quad-equal100-v3/output1.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO18", + "bindings": { + "input": { + "source": "bus:control.out/ch1" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-equal100-v3/output2.json b/projects/test/quad-equal100-v3/output2.json new file mode 100644 index 000000000..e085c1f97 --- /dev/null +++ b/projects/test/quad-equal100-v3/output2.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO16", + "bindings": { + "input": { + "source": "bus:control.out/ch2" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-equal100-v3/output3.json b/projects/test/quad-equal100-v3/output3.json new file mode 100644 index 000000000..702e9ee0d --- /dev/null +++ b/projects/test/quad-equal100-v3/output3.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO14", + "bindings": { + "input": { + "source": "bus:control.out/ch3" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-equal100-v3/output4.json b/projects/test/quad-equal100-v3/output4.json new file mode 100644 index 000000000..e6aec5b9f --- /dev/null +++ b/projects/test/quad-equal100-v3/output4.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO2", + "bindings": { + "input": { + "source": "bus:control.out/ch4" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-equal100-v3/project.json b/projects/test/quad-equal100-v3/project.json new file mode 100644 index 000000000..78f43f3fb --- /dev/null +++ b/projects/test/quad-equal100-v3/project.json @@ -0,0 +1,37 @@ +{ + "kind": "Project", + "format": 2, + "name": "Quad equal 100 v3", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} \ No newline at end of file diff --git a/projects/test/quad-equal100-v3/shader.glsl b/projects/test/quad-equal100-v3/shader.glsl new file mode 100644 index 000000000..86531e31d --- /dev/null +++ b/projects/test/quad-equal100-v3/shader.glsl @@ -0,0 +1,39 @@ +// Quad-strip bring-up pattern: four horizontal bands, one per strip. +// +// Each band has its own base hue (band 1 red, 2 green, 3 blue, 4 amber) so a +// strip wired to the wrong channel — or a channel bleeding into its +// neighbour — is visible at a glance, and each band carries a bright chase +// dot moving at a band-specific speed and phase so per-channel animation +// independence is visible too. A dim base fill keeps every strip identifiably +// lit even where the dot is not. + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 bandColor(float band) { + if (band < 0.5) { + return vec3(1.0, 0.0, 0.0); + } + if (band < 1.5) { + return vec3(0.0, 1.0, 0.0); + } + if (band < 2.5) { + return vec3(0.0, 0.0, 1.0); + } + return vec3(1.0, 0.6, 0.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float band = floor(uv.y * 4.0); + + // Band-specific chase: distinct speed and phase per strip. + float speed = 0.25 + band * 0.1; + float head = fract(time * speed + band * 0.25); + float d = abs(uv.x - head); + d = min(d, 1.0 - d); + float dot_i = smoothstep(0.12, 0.0, d); + + vec3 color = bandColor(band) * (0.12 + 0.88 * dot_i); + return vec4(color, 1.0); +} diff --git a/projects/test/quad-equal100-v3/shader.json b/projects/test/quad-equal100-v3/shader.json new file mode 100644 index 000000000..e07252ce0 --- /dev/null +++ b/projects/test/quad-equal100-v3/shader.json @@ -0,0 +1,21 @@ +{ + "kind": "Shader", + "source": "shader.glsl", + "render_order": 0, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "Time", + "description": "Project clock time in seconds", + "default_bind": "bus:time" + } + }, + "float_mode": "fixed" +} diff --git a/projects/test/quad-gamma-full/README.md b/projects/test/quad-gamma-full/README.md new file mode 100644 index 000000000..fe0d894a8 --- /dev/null +++ b/projects/test/quad-gamma-full/README.md @@ -0,0 +1,6 @@ +# Quad strips v3 — classic ESP32 (DOM-Z-102) variant + +Copy of `projects/test/quad-strips` with output endpoints renamed to the +DOM-Z-102's silkscreen labels (`ws281x:rmt:IO18/IO16/IO14/IO2` instead of +the XIAO S3's `D10/D9/D8/D7`). Same band colors, same shader. The desk +bring-up project for the classic bring-up roadmap's M4/G-M4 walk. diff --git a/projects/test/quad-gamma-full/clock.json b/projects/test/quad-gamma-full/clock.json new file mode 100644 index 000000000..79834c140 --- /dev/null +++ b/projects/test/quad-gamma-full/clock.json @@ -0,0 +1,8 @@ +{ + "kind": "Clock", + "controls": { + "running": true, + "rate": 1, + "scrub_offset_seconds": 0 + } +} diff --git a/projects/test/quad-gamma-full/fixture1.json b/projects/test/quad-gamma-full/fixture1.json new file mode 100644 index 000000000..c83c6e284 --- /dev/null +++ b/projects/test/quad-gamma-full/fixture1.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch1" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture1.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 255, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-full/fixture1.map2d.json b/projects/test/quad-gamma-full/fixture1.map2d.json new file mode 100644 index 000000000..abdac29a7 --- /dev/null +++ b/projects/test/quad-gamma-full/fixture1.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip1", + "shape": { + "grid": { "origin": [1.6667, 12.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-full/fixture2.json b/projects/test/quad-gamma-full/fixture2.json new file mode 100644 index 000000000..18562ea31 --- /dev/null +++ b/projects/test/quad-gamma-full/fixture2.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch2" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture2.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 255, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-full/fixture2.map2d.json b/projects/test/quad-gamma-full/fixture2.map2d.json new file mode 100644 index 000000000..a619f854c --- /dev/null +++ b/projects/test/quad-gamma-full/fixture2.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip2", + "shape": { + "grid": { "origin": [1.6667, 37.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-full/fixture3.json b/projects/test/quad-gamma-full/fixture3.json new file mode 100644 index 000000000..4a5fc049f --- /dev/null +++ b/projects/test/quad-gamma-full/fixture3.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch3" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture3.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 255, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-full/fixture3.map2d.json b/projects/test/quad-gamma-full/fixture3.map2d.json new file mode 100644 index 000000000..6cd539743 --- /dev/null +++ b/projects/test/quad-gamma-full/fixture3.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip3", + "shape": { + "grid": { "origin": [1.6667, 62.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-full/fixture4.json b/projects/test/quad-gamma-full/fixture4.json new file mode 100644 index 000000000..71ae93565 --- /dev/null +++ b/projects/test/quad-gamma-full/fixture4.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch4" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture4.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 255, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-full/fixture4.map2d.json b/projects/test/quad-gamma-full/fixture4.map2d.json new file mode 100644 index 000000000..9cae7b0bb --- /dev/null +++ b/projects/test/quad-gamma-full/fixture4.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip4", + "shape": { + "grid": { "origin": [1.6667, 87.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-full/output1.json b/projects/test/quad-gamma-full/output1.json new file mode 100644 index 000000000..9a1737895 --- /dev/null +++ b/projects/test/quad-gamma-full/output1.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO18", + "bindings": { + "input": { + "source": "bus:control.out/ch1" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-full/output2.json b/projects/test/quad-gamma-full/output2.json new file mode 100644 index 000000000..e085c1f97 --- /dev/null +++ b/projects/test/quad-gamma-full/output2.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO16", + "bindings": { + "input": { + "source": "bus:control.out/ch2" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-full/output3.json b/projects/test/quad-gamma-full/output3.json new file mode 100644 index 000000000..702e9ee0d --- /dev/null +++ b/projects/test/quad-gamma-full/output3.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO14", + "bindings": { + "input": { + "source": "bus:control.out/ch3" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-full/output4.json b/projects/test/quad-gamma-full/output4.json new file mode 100644 index 000000000..e6aec5b9f --- /dev/null +++ b/projects/test/quad-gamma-full/output4.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO2", + "bindings": { + "input": { + "source": "bus:control.out/ch4" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-full/project.json b/projects/test/quad-gamma-full/project.json new file mode 100644 index 000000000..e2d9ce235 --- /dev/null +++ b/projects/test/quad-gamma-full/project.json @@ -0,0 +1,37 @@ +{ + "kind": "Project", + "format": 2, + "name": "Quad gamma full", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} \ No newline at end of file diff --git a/projects/test/quad-gamma-full/shader.glsl b/projects/test/quad-gamma-full/shader.glsl new file mode 100644 index 000000000..86531e31d --- /dev/null +++ b/projects/test/quad-gamma-full/shader.glsl @@ -0,0 +1,39 @@ +// Quad-strip bring-up pattern: four horizontal bands, one per strip. +// +// Each band has its own base hue (band 1 red, 2 green, 3 blue, 4 amber) so a +// strip wired to the wrong channel — or a channel bleeding into its +// neighbour — is visible at a glance, and each band carries a bright chase +// dot moving at a band-specific speed and phase so per-channel animation +// independence is visible too. A dim base fill keeps every strip identifiably +// lit even where the dot is not. + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 bandColor(float band) { + if (band < 0.5) { + return vec3(1.0, 0.0, 0.0); + } + if (band < 1.5) { + return vec3(0.0, 1.0, 0.0); + } + if (band < 2.5) { + return vec3(0.0, 0.0, 1.0); + } + return vec3(1.0, 0.6, 0.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float band = floor(uv.y * 4.0); + + // Band-specific chase: distinct speed and phase per strip. + float speed = 0.25 + band * 0.1; + float head = fract(time * speed + band * 0.25); + float d = abs(uv.x - head); + d = min(d, 1.0 - d); + float dot_i = smoothstep(0.12, 0.0, d); + + vec3 color = bandColor(band) * (0.12 + 0.88 * dot_i); + return vec4(color, 1.0); +} diff --git a/projects/test/quad-gamma-full/shader.json b/projects/test/quad-gamma-full/shader.json new file mode 100644 index 000000000..e07252ce0 --- /dev/null +++ b/projects/test/quad-gamma-full/shader.json @@ -0,0 +1,21 @@ +{ + "kind": "Shader", + "source": "shader.glsl", + "render_order": 0, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "Time", + "description": "Project clock time in seconds", + "default_bind": "bus:time" + } + }, + "float_mode": "fixed" +} diff --git a/projects/test/quad-gamma-v3/README.md b/projects/test/quad-gamma-v3/README.md new file mode 100644 index 000000000..fe0d894a8 --- /dev/null +++ b/projects/test/quad-gamma-v3/README.md @@ -0,0 +1,6 @@ +# Quad strips v3 — classic ESP32 (DOM-Z-102) variant + +Copy of `projects/test/quad-strips` with output endpoints renamed to the +DOM-Z-102's silkscreen labels (`ws281x:rmt:IO18/IO16/IO14/IO2` instead of +the XIAO S3's `D10/D9/D8/D7`). Same band colors, same shader. The desk +bring-up project for the classic bring-up roadmap's M4/G-M4 walk. diff --git a/projects/test/quad-gamma-v3/clock.json b/projects/test/quad-gamma-v3/clock.json new file mode 100644 index 000000000..79834c140 --- /dev/null +++ b/projects/test/quad-gamma-v3/clock.json @@ -0,0 +1,8 @@ +{ + "kind": "Clock", + "controls": { + "running": true, + "rate": 1, + "scrub_offset_seconds": 0 + } +} diff --git a/projects/test/quad-gamma-v3/fixture1.json b/projects/test/quad-gamma-v3/fixture1.json new file mode 100644 index 000000000..0d7455b09 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture1.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch1" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture1.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-v3/fixture1.map2d.json b/projects/test/quad-gamma-v3/fixture1.map2d.json new file mode 100644 index 000000000..abdac29a7 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture1.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip1", + "shape": { + "grid": { "origin": [1.6667, 12.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-v3/fixture2.json b/projects/test/quad-gamma-v3/fixture2.json new file mode 100644 index 000000000..4a209bd23 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture2.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch2" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture2.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-v3/fixture2.map2d.json b/projects/test/quad-gamma-v3/fixture2.map2d.json new file mode 100644 index 000000000..a619f854c --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture2.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip2", + "shape": { + "grid": { "origin": [1.6667, 37.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-v3/fixture3.json b/projects/test/quad-gamma-v3/fixture3.json new file mode 100644 index 000000000..0db5742c0 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture3.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch3" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture3.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-v3/fixture3.map2d.json b/projects/test/quad-gamma-v3/fixture3.map2d.json new file mode 100644 index 000000000..6cd539743 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture3.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip3", + "shape": { + "grid": { "origin": [1.6667, 62.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-v3/fixture4.json b/projects/test/quad-gamma-v3/fixture4.json new file mode 100644 index 000000000..d70e22675 --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture4.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch4" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture4.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": true +} \ No newline at end of file diff --git a/projects/test/quad-gamma-v3/fixture4.map2d.json b/projects/test/quad-gamma-v3/fixture4.map2d.json new file mode 100644 index 000000000..9cae7b0bb --- /dev/null +++ b/projects/test/quad-gamma-v3/fixture4.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip4", + "shape": { + "grid": { "origin": [1.6667, 87.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-gamma-v3/output1.json b/projects/test/quad-gamma-v3/output1.json new file mode 100644 index 000000000..9a1737895 --- /dev/null +++ b/projects/test/quad-gamma-v3/output1.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO18", + "bindings": { + "input": { + "source": "bus:control.out/ch1" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-v3/output2.json b/projects/test/quad-gamma-v3/output2.json new file mode 100644 index 000000000..e085c1f97 --- /dev/null +++ b/projects/test/quad-gamma-v3/output2.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO16", + "bindings": { + "input": { + "source": "bus:control.out/ch2" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-v3/output3.json b/projects/test/quad-gamma-v3/output3.json new file mode 100644 index 000000000..702e9ee0d --- /dev/null +++ b/projects/test/quad-gamma-v3/output3.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO14", + "bindings": { + "input": { + "source": "bus:control.out/ch3" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-v3/output4.json b/projects/test/quad-gamma-v3/output4.json new file mode 100644 index 000000000..e6aec5b9f --- /dev/null +++ b/projects/test/quad-gamma-v3/output4.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO2", + "bindings": { + "input": { + "source": "bus:control.out/ch4" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-gamma-v3/project.json b/projects/test/quad-gamma-v3/project.json new file mode 100644 index 000000000..342792735 --- /dev/null +++ b/projects/test/quad-gamma-v3/project.json @@ -0,0 +1,37 @@ +{ + "kind": "Project", + "format": 2, + "name": "Quad gamma v3", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} \ No newline at end of file diff --git a/projects/test/quad-gamma-v3/shader.glsl b/projects/test/quad-gamma-v3/shader.glsl new file mode 100644 index 000000000..86531e31d --- /dev/null +++ b/projects/test/quad-gamma-v3/shader.glsl @@ -0,0 +1,39 @@ +// Quad-strip bring-up pattern: four horizontal bands, one per strip. +// +// Each band has its own base hue (band 1 red, 2 green, 3 blue, 4 amber) so a +// strip wired to the wrong channel — or a channel bleeding into its +// neighbour — is visible at a glance, and each band carries a bright chase +// dot moving at a band-specific speed and phase so per-channel animation +// independence is visible too. A dim base fill keeps every strip identifiably +// lit even where the dot is not. + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 bandColor(float band) { + if (band < 0.5) { + return vec3(1.0, 0.0, 0.0); + } + if (band < 1.5) { + return vec3(0.0, 1.0, 0.0); + } + if (band < 2.5) { + return vec3(0.0, 0.0, 1.0); + } + return vec3(1.0, 0.6, 0.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float band = floor(uv.y * 4.0); + + // Band-specific chase: distinct speed and phase per strip. + float speed = 0.25 + band * 0.1; + float head = fract(time * speed + band * 0.25); + float d = abs(uv.x - head); + d = min(d, 1.0 - d); + float dot_i = smoothstep(0.12, 0.0, d); + + vec3 color = bandColor(band) * (0.12 + 0.88 * dot_i); + return vec4(color, 1.0); +} diff --git a/projects/test/quad-gamma-v3/shader.json b/projects/test/quad-gamma-v3/shader.json new file mode 100644 index 000000000..e07252ce0 --- /dev/null +++ b/projects/test/quad-gamma-v3/shader.json @@ -0,0 +1,21 @@ +{ + "kind": "Shader", + "source": "shader.glsl", + "render_order": 0, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "Time", + "description": "Project clock time in seconds", + "default_bind": "bus:time" + } + }, + "float_mode": "fixed" +} diff --git a/projects/test/quad-strips-v3/README.md b/projects/test/quad-strips-v3/README.md new file mode 100644 index 000000000..fe0d894a8 --- /dev/null +++ b/projects/test/quad-strips-v3/README.md @@ -0,0 +1,6 @@ +# Quad strips v3 — classic ESP32 (DOM-Z-102) variant + +Copy of `projects/test/quad-strips` with output endpoints renamed to the +DOM-Z-102's silkscreen labels (`ws281x:rmt:IO18/IO16/IO14/IO2` instead of +the XIAO S3's `D10/D9/D8/D7`). Same band colors, same shader. The desk +bring-up project for the classic bring-up roadmap's M4/G-M4 walk. diff --git a/projects/test/quad-strips-v3/clock.json b/projects/test/quad-strips-v3/clock.json new file mode 100644 index 000000000..79834c140 --- /dev/null +++ b/projects/test/quad-strips-v3/clock.json @@ -0,0 +1,8 @@ +{ + "kind": "Clock", + "controls": { + "running": true, + "rate": 1, + "scrub_offset_seconds": 0 + } +} diff --git a/projects/test/quad-strips-v3/fixture1.json b/projects/test/quad-strips-v3/fixture1.json new file mode 100644 index 000000000..de464edc5 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture1.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch1" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture1.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} diff --git a/projects/test/quad-strips-v3/fixture1.map2d.json b/projects/test/quad-strips-v3/fixture1.map2d.json new file mode 100644 index 000000000..abdac29a7 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture1.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip1", + "shape": { + "grid": { "origin": [1.6667, 12.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-strips-v3/fixture2.json b/projects/test/quad-strips-v3/fixture2.json new file mode 100644 index 000000000..cd9bc30f8 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture2.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch2" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture2.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} diff --git a/projects/test/quad-strips-v3/fixture2.map2d.json b/projects/test/quad-strips-v3/fixture2.map2d.json new file mode 100644 index 000000000..a619f854c --- /dev/null +++ b/projects/test/quad-strips-v3/fixture2.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip2", + "shape": { + "grid": { "origin": [1.6667, 37.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-strips-v3/fixture3.json b/projects/test/quad-strips-v3/fixture3.json new file mode 100644 index 000000000..115b7ac92 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture3.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch3" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture3.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} diff --git a/projects/test/quad-strips-v3/fixture3.map2d.json b/projects/test/quad-strips-v3/fixture3.map2d.json new file mode 100644 index 000000000..6cd539743 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture3.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip3", + "shape": { + "grid": { "origin": [1.6667, 62.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-strips-v3/fixture4.json b/projects/test/quad-strips-v3/fixture4.json new file mode 100644 index 000000000..bebde6064 --- /dev/null +++ b/projects/test/quad-strips-v3/fixture4.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 30, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch4" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture4.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} diff --git a/projects/test/quad-strips-v3/fixture4.map2d.json b/projects/test/quad-strips-v3/fixture4.map2d.json new file mode 100644 index 000000000..9cae7b0bb --- /dev/null +++ b/projects/test/quad-strips-v3/fixture4.map2d.json @@ -0,0 +1,18 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip4", + "shape": { + "grid": { "origin": [1.6667, 87.5], "cols": 30, "rows": 1, "pitch": 3.3333 } + } + } + ] +} diff --git a/projects/test/quad-strips-v3/output1.json b/projects/test/quad-strips-v3/output1.json new file mode 100644 index 000000000..9a1737895 --- /dev/null +++ b/projects/test/quad-strips-v3/output1.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO18", + "bindings": { + "input": { + "source": "bus:control.out/ch1" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-strips-v3/output2.json b/projects/test/quad-strips-v3/output2.json new file mode 100644 index 000000000..e085c1f97 --- /dev/null +++ b/projects/test/quad-strips-v3/output2.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO16", + "bindings": { + "input": { + "source": "bus:control.out/ch2" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-strips-v3/output3.json b/projects/test/quad-strips-v3/output3.json new file mode 100644 index 000000000..702e9ee0d --- /dev/null +++ b/projects/test/quad-strips-v3/output3.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO14", + "bindings": { + "input": { + "source": "bus:control.out/ch3" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-strips-v3/output4.json b/projects/test/quad-strips-v3/output4.json new file mode 100644 index 000000000..e6aec5b9f --- /dev/null +++ b/projects/test/quad-strips-v3/output4.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO2", + "bindings": { + "input": { + "source": "bus:control.out/ch4" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad-strips-v3/project.json b/projects/test/quad-strips-v3/project.json new file mode 100644 index 000000000..503e83d2e --- /dev/null +++ b/projects/test/quad-strips-v3/project.json @@ -0,0 +1,37 @@ +{ + "kind": "Project", + "format": 2, + "name": "Quad strips v3", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} diff --git a/projects/test/quad-strips-v3/shader.glsl b/projects/test/quad-strips-v3/shader.glsl new file mode 100644 index 000000000..86531e31d --- /dev/null +++ b/projects/test/quad-strips-v3/shader.glsl @@ -0,0 +1,39 @@ +// Quad-strip bring-up pattern: four horizontal bands, one per strip. +// +// Each band has its own base hue (band 1 red, 2 green, 3 blue, 4 amber) so a +// strip wired to the wrong channel — or a channel bleeding into its +// neighbour — is visible at a glance, and each band carries a bright chase +// dot moving at a band-specific speed and phase so per-channel animation +// independence is visible too. A dim base fill keeps every strip identifiably +// lit even where the dot is not. + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 bandColor(float band) { + if (band < 0.5) { + return vec3(1.0, 0.0, 0.0); + } + if (band < 1.5) { + return vec3(0.0, 1.0, 0.0); + } + if (band < 2.5) { + return vec3(0.0, 0.0, 1.0); + } + return vec3(1.0, 0.6, 0.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float band = floor(uv.y * 4.0); + + // Band-specific chase: distinct speed and phase per strip. + float speed = 0.25 + band * 0.1; + float head = fract(time * speed + band * 0.25); + float d = abs(uv.x - head); + d = min(d, 1.0 - d); + float dot_i = smoothstep(0.12, 0.0, d); + + vec3 color = bandColor(band) * (0.12 + 0.88 * dot_i); + return vec4(color, 1.0); +} diff --git a/projects/test/quad-strips-v3/shader.json b/projects/test/quad-strips-v3/shader.json new file mode 100644 index 000000000..e07252ce0 --- /dev/null +++ b/projects/test/quad-strips-v3/shader.json @@ -0,0 +1,21 @@ +{ + "kind": "Shader", + "source": "shader.glsl", + "render_order": 0, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "Time", + "description": "Project clock time in seconds", + "default_bind": "bus:time" + } + }, + "float_mode": "fixed" +} diff --git a/projects/test/quad60-v3/README.md b/projects/test/quad60-v3/README.md new file mode 100644 index 000000000..fe0d894a8 --- /dev/null +++ b/projects/test/quad60-v3/README.md @@ -0,0 +1,6 @@ +# Quad strips v3 — classic ESP32 (DOM-Z-102) variant + +Copy of `projects/test/quad-strips` with output endpoints renamed to the +DOM-Z-102's silkscreen labels (`ws281x:rmt:IO18/IO16/IO14/IO2` instead of +the XIAO S3's `D10/D9/D8/D7`). Same band colors, same shader. The desk +bring-up project for the classic bring-up roadmap's M4/G-M4 walk. diff --git a/projects/test/quad60-v3/clock.json b/projects/test/quad60-v3/clock.json new file mode 100644 index 000000000..79834c140 --- /dev/null +++ b/projects/test/quad60-v3/clock.json @@ -0,0 +1,8 @@ +{ + "kind": "Clock", + "controls": { + "running": true, + "rate": 1, + "scrub_offset_seconds": 0 + } +} diff --git a/projects/test/quad60-v3/fixture1.json b/projects/test/quad60-v3/fixture1.json new file mode 100644 index 000000000..62cac707e --- /dev/null +++ b/projects/test/quad60-v3/fixture1.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch1" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture1.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture1.map2d.json b/projects/test/quad60-v3/fixture1.map2d.json new file mode 100644 index 000000000..8f5663d54 --- /dev/null +++ b/projects/test/quad60-v3/fixture1.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip1", + "shape": { + "grid": { + "origin": [ + 1.6667, + 12.5 + ], + "cols": 60, + "rows": 1, + "pitch": 1.6667 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture2.json b/projects/test/quad60-v3/fixture2.json new file mode 100644 index 000000000..f1aafe25d --- /dev/null +++ b/projects/test/quad60-v3/fixture2.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch2" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture2.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture2.map2d.json b/projects/test/quad60-v3/fixture2.map2d.json new file mode 100644 index 000000000..184fbaf14 --- /dev/null +++ b/projects/test/quad60-v3/fixture2.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip2", + "shape": { + "grid": { + "origin": [ + 1.6667, + 37.5 + ], + "cols": 60, + "rows": 1, + "pitch": 1.6667 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture3.json b/projects/test/quad60-v3/fixture3.json new file mode 100644 index 000000000..6da7e71b3 --- /dev/null +++ b/projects/test/quad60-v3/fixture3.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch3" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture3.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture3.map2d.json b/projects/test/quad60-v3/fixture3.map2d.json new file mode 100644 index 000000000..80c550f60 --- /dev/null +++ b/projects/test/quad60-v3/fixture3.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip3", + "shape": { + "grid": { + "origin": [ + 1.6667, + 62.5 + ], + "cols": 60, + "rows": 1, + "pitch": 1.6667 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture4.json b/projects/test/quad60-v3/fixture4.json new file mode 100644 index 000000000..9592a2c4b --- /dev/null +++ b/projects/test/quad60-v3/fixture4.json @@ -0,0 +1,41 @@ +{ + "kind": "Fixture", + "render_size": { + "width": 60, + "height": 4 + }, + "bindings": { + "input": { + "source": "bus:visual.out" + }, + "output": { + "target": "bus:control.out/ch4" + } + }, + "sampling": "direct", + "diagnostic_mode": "off", + "mapping": { + "kind": "Map2d", + "source": "fixture4.map2d.json" + }, + "color_order": "rgb", + "transform": [ + [ + 1, + 0, + 0 + ], + [ + 0, + 1, + 0 + ], + [ + 0, + 0, + 1 + ] + ], + "brightness": 38, + "gamma_correction": false +} \ No newline at end of file diff --git a/projects/test/quad60-v3/fixture4.map2d.json b/projects/test/quad60-v3/fixture4.map2d.json new file mode 100644 index 000000000..8f8e4ac6b --- /dev/null +++ b/projects/test/quad60-v3/fixture4.map2d.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "sample_diameter": 3.0, + "canvas": [ + 0.0, + 0.0, + 100.0, + 100.0 + ], + "objects": [ + { + "name": "strip4", + "shape": { + "grid": { + "origin": [ + 1.6667, + 87.5 + ], + "cols": 60, + "rows": 1, + "pitch": 1.6667 + } + } + } + ] +} \ No newline at end of file diff --git a/projects/test/quad60-v3/output1.json b/projects/test/quad60-v3/output1.json new file mode 100644 index 000000000..9a1737895 --- /dev/null +++ b/projects/test/quad60-v3/output1.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO18", + "bindings": { + "input": { + "source": "bus:control.out/ch1" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad60-v3/output2.json b/projects/test/quad60-v3/output2.json new file mode 100644 index 000000000..e085c1f97 --- /dev/null +++ b/projects/test/quad60-v3/output2.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO16", + "bindings": { + "input": { + "source": "bus:control.out/ch2" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad60-v3/output3.json b/projects/test/quad60-v3/output3.json new file mode 100644 index 000000000..702e9ee0d --- /dev/null +++ b/projects/test/quad60-v3/output3.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO14", + "bindings": { + "input": { + "source": "bus:control.out/ch3" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad60-v3/output4.json b/projects/test/quad60-v3/output4.json new file mode 100644 index 000000000..e6aec5b9f --- /dev/null +++ b/projects/test/quad60-v3/output4.json @@ -0,0 +1,19 @@ +{ + "kind": "Output", + "endpoint": "ws281x:rmt:IO2", + "bindings": { + "input": { + "source": "bus:control.out/ch4" + } + }, + "options": { + "white_point": [ + 1, + 1, + 1 + ], + "interpolation_enabled": false, + "dithering_enabled": false, + "lut_enabled": false + } +} diff --git a/projects/test/quad60-v3/project.json b/projects/test/quad60-v3/project.json new file mode 100644 index 000000000..afd4330a9 --- /dev/null +++ b/projects/test/quad60-v3/project.json @@ -0,0 +1,37 @@ +{ + "kind": "Project", + "format": 2, + "name": "Quad 60 v3", + "nodes": { + "clock": { + "ref": "./clock.json" + }, + "shader": { + "ref": "./shader.json" + }, + "fixture1": { + "ref": "./fixture1.json" + }, + "fixture2": { + "ref": "./fixture2.json" + }, + "fixture3": { + "ref": "./fixture3.json" + }, + "fixture4": { + "ref": "./fixture4.json" + }, + "output1": { + "ref": "./output1.json" + }, + "output2": { + "ref": "./output2.json" + }, + "output3": { + "ref": "./output3.json" + }, + "output4": { + "ref": "./output4.json" + } + } +} \ No newline at end of file diff --git a/projects/test/quad60-v3/shader.glsl b/projects/test/quad60-v3/shader.glsl new file mode 100644 index 000000000..86531e31d --- /dev/null +++ b/projects/test/quad60-v3/shader.glsl @@ -0,0 +1,39 @@ +// Quad-strip bring-up pattern: four horizontal bands, one per strip. +// +// Each band has its own base hue (band 1 red, 2 green, 3 blue, 4 amber) so a +// strip wired to the wrong channel — or a channel bleeding into its +// neighbour — is visible at a glance, and each band carries a bright chase +// dot moving at a band-specific speed and phase so per-channel animation +// independence is visible too. A dim base fill keeps every strip identifiably +// lit even where the dot is not. + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 bandColor(float band) { + if (band < 0.5) { + return vec3(1.0, 0.0, 0.0); + } + if (band < 1.5) { + return vec3(0.0, 1.0, 0.0); + } + if (band < 2.5) { + return vec3(0.0, 0.0, 1.0); + } + return vec3(1.0, 0.6, 0.0); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float band = floor(uv.y * 4.0); + + // Band-specific chase: distinct speed and phase per strip. + float speed = 0.25 + band * 0.1; + float head = fract(time * speed + band * 0.25); + float d = abs(uv.x - head); + d = min(d, 1.0 - d); + float dot_i = smoothstep(0.12, 0.0, d); + + vec3 color = bandColor(band) * (0.12 + 0.88 * dot_i); + return vec4(color, 1.0); +} diff --git a/projects/test/quad60-v3/shader.json b/projects/test/quad60-v3/shader.json new file mode 100644 index 000000000..e07252ce0 --- /dev/null +++ b/projects/test/quad60-v3/shader.json @@ -0,0 +1,21 @@ +{ + "kind": "Shader", + "source": "shader.glsl", + "render_order": 0, + "bindings": { + "output": { + "target": "bus:visual.out" + } + }, + "consumed": { + "time": { + "kind": "value", + "value": "f32", + "default": 0, + "label": "Time", + "description": "Project clock time in seconds", + "default_bind": "bus:time" + } + }, + "float_mode": "fixed" +} diff --git a/schemas/hardware.schema.json b/schemas/hardware.schema.json index f5b2a632c..1385cd30d 100644 --- a/schemas/hardware.schema.json +++ b/schemas/hardware.schema.json @@ -88,12 +88,21 @@ }, "HardwareTarget": { "description": "Build or runtime target that a board manifest describes.", - "enum": [ - "esp32c6", - "esp32s3", - "rv32imac_emu" - ], - "type": "string" + "oneOf": [ + { + "enum": [ + "esp32c6", + "esp32s3", + "rv32imac_emu" + ], + "type": "string" + }, + { + "const": "esp32", + "description": "Classic ESP32 (Xtensa LX6) — `fw-esp32v3`.", + "type": "string" + } + ] }, "HwCapability": { "description": "Capability advertised by a [`crate::HwResource`].\n\nDrivers check capabilities before claiming resources. A single resource may\nexpose multiple capabilities, such as a GPIO that can be used for input or\noutput.",