Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
b81c6d5
feat(bootctl): flash-persisted boot-control sector
Yona-Appletree Jul 31, 2026
021035f
Merge remote-tracking branch 'origin/main' into claude/xenodochial-da…
Yona-Appletree Jul 31, 2026
5ba6ea2
test(bootctl): pin the on-flash record as a golden vector
Yona-Appletree Jul 31, 2026
168854c
feat(link): SetBootControl request and the host provider's writer
Yona-Appletree Jul 31, 2026
f64ea75
fix(link): make management-result replay variant-agnostic
Yona-Appletree Jul 31, 2026
d1fb1ec
fix(bootctl): write the record in one operation, not two
Yona-Appletree Jul 31, 2026
97b3da6
fix(stories): repair `just studio-story-pull`
Yona-Appletree Jul 31, 2026
db57ee3
feat(link): browser provider writes the boot-control record
Yona-Appletree Jul 31, 2026
d5c8f62
feat(studio): "start without the project" recovery action
Yona-Appletree Jul 31, 2026
50ceba5
test(link): non-destructive hardware smoke for the boot-control cycle
Yona-Appletree Jul 31, 2026
7c9dc8b
feat(link): three-way device link-mode classification
Yona-Appletree Jul 31, 2026
41b89a2
feat(link): mode-exclusive SYNC probe escalation + ADR
Yona-Appletree Jul 31, 2026
e30103e
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
ecc0463
feat(studio): chip-keyed bootloader-entry instructions
Yona-Appletree Jul 31, 2026
9569e2a
feat(studio): bootloader-entry flow with arrival-triggered confirmation
Yona-Appletree Jul 31, 2026
194a7a2
feat(studio): render real bootloader-entry steps in the troubleshoot …
Yona-Appletree Jul 31, 2026
08fec9f
feat(studio): bootloader-entry sheet with live confirmation
Yona-Appletree Jul 31, 2026
47c9966
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
2eac9c6
fix(studio): the bootloader sheet was clipped, hiding its own point
Yona-Appletree Jul 31, 2026
250d017
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
b49dde0
fix(studio): actually probe on "I've done that", and hedge the "B" label
Yona-Appletree Jul 31, 2026
c9aded2
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
f08797b
feat(studio): Recovery mode is its own card state
Yona-Appletree Jul 31, 2026
6ba39a2
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
d91aa99
fix(studio): Troubleshoot no longer asks to write firmware; Recovery …
Yona-Appletree Jul 31, 2026
3b7b263
fix(studio): a recovery flash ends on the replug, not on a failure
Yona-Appletree Jul 31, 2026
b18949e
feat(studio): recovery verbs on the face; sheets grow the card
Yona-Appletree Jul 31, 2026
0bc8239
feat(studio): "Start in safe mode" — skip + clamp with a defined prec…
Yona-Appletree Jul 31, 2026
134f6ae
fix(studio): the wayfinding row read as strikethrough
Yona-Appletree Jul 31, 2026
d3fa3fa
Merge remote-tracking branch 'origin/main' into claude/xenodochial-da…
Yona-Appletree Jul 31, 2026
223c094
feat(engine): safe-mode output clamp — dim safe mode is live
Yona-Appletree Jul 31, 2026
c27a992
test(engine): the safe clamp reaches the wire, not just the field
Yona-Appletree Jul 31, 2026
a138fa1
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Jul 31, 2026
c75807a
feat(link): ReadRawFilesystem — get the user's work off a board that …
Yona-Appletree Jul 31, 2026
9f7d80f
feat(studio): mount the image and hand back a ZIP
Yona-Appletree Jul 31, 2026
aebc45f
feat(studio): a device backup you can actually take
Yona-Appletree Jul 31, 2026
7f5231b
feat(studio-web): the backup row, on the face and in the danger zone
Yona-Appletree Jul 31, 2026
34bd6a8
style: cargo fmt after the M6 backup work
Yona-Appletree Jul 31, 2026
daaa818
docs(link): the flash-region table's key-matching rule, said accurately
Yona-Appletree Jul 31, 2026
7464d92
Merge remote-tracking branch 'origin/claude/xenodochial-darwin-b6680f…
Yona-Appletree Jul 31, 2026
9054a14
docs(adr): the device backup archive format
Yona-Appletree Jul 31, 2026
464e6fa
fix(studio-web): the backup field reached the stories a level too late
Yona-Appletree Jul 31, 2026
f7acebc
fix(studio): the safe-mode op no longer erases its own card; streams …
Yona-Appletree Jul 31, 2026
e939dfc
fix(link): esptool-js only speaks compressed writes
Yona-Appletree Jul 31, 2026
0a88bd0
fix(studio): arming safe mode always ends on an instruction
Yona-Appletree Jul 31, 2026
a008e9c
fix(studio): the awaiting op now rides the pinned anonymous card
Yona-Appletree Jul 31, 2026
c2bc100
fix(studio): the failure reason on a card op wraps instead of ellipsi…
Yona-Appletree Aug 1, 2026
57c9fe2
fix(link): failure logs reach the bridge as strings
Yona-Appletree Aug 1, 2026
f3586b9
fix(link): verify the boot-control record by readback, not flash-ID n…
Yona-Appletree Aug 1, 2026
a6d802d
fix(studio): a bootloader-mode op that lands back in the bootloader s…
Yona-Appletree Aug 1, 2026
3d25bc4
Merge remote-tracking branch 'origin/main' into claude/xenodochial-da…
Yona-Appletree Aug 1, 2026
91addcd
docs(debt): file the two open conditions from the safe-mode bench walk
Yona-Appletree Aug 1, 2026
8f074c1
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
5e47ab8
Merge remote-tracking branch 'origin/main' into claude/xenodochial-da…
Yona-Appletree Aug 1, 2026
db5211b
feat(wire): the heartbeat reports the safe-mode output clamp
Yona-Appletree Aug 1, 2026
91823cf
feat(studio): the device card calls out safe mode, on every tab
Yona-Appletree Aug 1, 2026
0702dfb
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
60dc1d0
chore(studio): auto-refresh story baselines [validate-stories]
github-actions[bot] Aug 1, 2026
4da546c
chore: trigger CI on the bot-pushed baseline commits
Yona-Appletree Aug 1, 2026
e269daa
docs(debt): retire safe-mode-dim-boot-unproven — dim boot seen on sil…
Yona-Appletree Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"lp-base/lp-perf",
"lp-base/lp-collection",
"lp-base/lp-recovery",
"lp-base/lp-bootctl",
"lp-base/lpfs",
# lp-app workspace members
"lp-core/lpc-history",
Expand Down Expand Up @@ -97,6 +98,7 @@ resolver = "2"
default-members = [
# lp-base workspace members
"lp-base/lp-recovery",
"lp-base/lp-bootctl",
"lp-base/lpfs",
# lp-app workspace members
"lp-core/lpc-history",
Expand Down Expand Up @@ -204,6 +206,7 @@ cranelift-interpreter = { git = "https://github.com/light-player/lp-cranelift",
regalloc2 = { git = "https://github.com/light-player/lp-regalloc2", branch = "lightplayer", default-features = false, features = ["std"] }
lp-collection = { path = "lp-base/lp-collection", default-features = false }
lp-recovery = { path = "lp-base/lp-recovery", default-features = false }
lp-bootctl = { path = "lp-base/lp-bootctl", default-features = false }

# Common dependencies

Expand Down
167 changes: 167 additions & 0 deletions docs/adr/2026-07-30-boot-control-sector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# ADR: Boot-control sector

- **Status:** Accepted
- **Date:** 2026-07-30
- **Deciders:** Photomancer
- **Supersedes:** None
- **Superseded by:** None

## Context

A project can make its own device unrecoverable. The originating case: a
project bright enough to brown the board out at power-up. The LEDs come on,
the rail collapses, the board resets, auto-loads the same project, and loops —
never staying up long enough for Studio to connect and fix it.

`lp-recovery` already has a boot-loop ladder that skips project auto-load
after repeated incomplete boots, but it does not fire here, for **two
independent reasons** (both left for a follow-up plan):

1. `mark_boot_complete()` fires on the **first rendered frame**, so a board
that renders one bright frame and then dies looks like a successful boot
and resets the counter every time.
2. `ResetCause::Brownout` is excluded from `blames_code()`, so brownouts never
increment the counter even when the counter is reached.

And a third problem that no amount of ladder tuning fixes: **the recovery
region lives in RTC fast RAM, which is wiped on `PowerOn`.** The instinctive
human response to a misbehaving board is to unplug it — which erases the
evidence that it was looping at all. A latch a user destroys by doing the
obvious thing is not a latch.

Separately, recovery tooling needs to tell a device "come up without loading a
project" while the device is in ROM download mode — i.e. while no firmware is
running to receive a message. The only channel available there is flash
itself.

These are the same requirement seen from two directions.

## Decision

Add a dedicated **4 KB `bootctl` flash partition** carrying a small record
that the firmware reads before it auto-loads a project. The format lives in a
new `no_std`, zero-alloc crate, `lp-base/lp-bootctl`, shared by every writer
and reader so they cannot disagree.

**Placement: the 4 KB comes out of `nvs`,** which shrinks from `0x6000` to
`0x5000`, putting `bootctl` at `0xe000`. Nothing reads NVS — no LightPlayer
code references it, and `esp-radio`'s `NVS` is a 15-word **RAM** array
(`common_adapter.rs`) plus stubbed ESP-IDF shims, not this partition. This
placement moves **no other offset**, so `lpfs` stays at `0x310000` and
existing devices' filesystem images remain valid. Both boards keep
byte-identical layouts so the offset can be a constant rather than a
partition-table lookup.

**Two writers, one format.** The host writes the record over esptool while
the device sits in ROM download mode — the path that works on a board that
cannot boot. The firmware will later write it too, to latch its own degraded
state across a power cycle (follow-up plan; today the firmware only reads and
consumes).

**Blank is safe.** A device that has never seen this feature reads `0xFF`
bytes. That, a bad magic, a bad CRC, a future format version, a short read,
and a torn write all decode to "boot normally". There is exactly one way to
get a non-default boot: a fully valid record that asks for one. A corrupt
sector can never *cause* a degraded boot — only fail to prevent one.

**Torn writes: one write, integrity by checksum.** The record is written to
an erased sector in a single operation, and its integrity rests on the magic
and CRC rather than on write ordering.

This is deliberately *not* the discipline `lp-recovery` uses. That crate
publishes RTC-RAM structures by flipping one visibility word last, and the
flash-native mirror — write the payload, then the magic — was the original
design here. **It does not work.** Every flash-write API that can reach this
sector issues the ESP ROM/stub `FLASH_BEGIN`, which *erases the sectors it is
about to write*; that is true of `espflash::write_bin_to_flash` and of
`esptool-js`'s `writeFlash` alike. A second write publishing a first would
erase it instead, leaving a valid magic over an erased payload — which fails
the CRC, boots normally, and makes the feature silently inoperative.

The CRC covers the magic, so every prefix of a partial write fails one of the
two checks. `no_partial_write_is_ever_honored` asserts that for all 16
truncation points.

**Consume on read, not on use.** The firmware erases a valid record the
moment it reads it, before anything acts on it. That makes the instruction
one-shot — the user asked for one recovery boot, not a permanent mode — and
means a crash *during* the recovery boot cannot make it sticky. The failure
mode traded for is an erase that fails, which strands the device reachable
with no project loaded: the safe direction, and itself recoverable.

**Unknown flag bits are ignored, not rejected.** A newer host asking for
something this firmware cannot apply still gets the instructions it does
understand.

**Amendment 2026-07-31 — the safe-mode clamp bits are assigned.** Bits
`8..16` now carry a safe-mode output clamp level (`0` = none, else a
brightness ceiling out of 255), with a precedence rule defined in the
format: *a firmware that implements the clamp loads the project dimmed and
ignores the skip bit* — a dim, visible board is a strictly better
degradation than a dark one. Studio's "Start in safe mode" writes BOTH the
skip and a dim clamp, so the same record means "nothing loaded" on firmware
that predates the clamp and upgrades to "project running dim" when the
clamp lands, with no format bump and no Studio change. Firmware consumption
of the clamp remains with the follow-up plan (it shares the output path
with the fixture mA limiter).

## Consequences

- **This is a breaking flash-layout change.** A device flashed with the old
table has no `bootctl` partition; the region at `0xe000` is inside its
`nvs`. Reading it yields whatever NVS had there — which decodes as `Blank`
or `Invalid`, so an un-reflashed device simply never takes a boot-control
instruction. Deployed boards must be reflashed with the new table to gain
the feature, and that is safe because nothing reads NVS.
- `lpfs` is untouched, so reflashing does **not** cost the user their files.
- The firmware gains a flash read (and conditionally a 4 KB erase) on the boot
path, before the filesystem mounts. Measured cost: image grew well within
budget — 2 866 928 B of 3 145 728 B, 278 800 B headroom against a 65 536 B
CI margin.
- `lp-bootctl` hardcodes the offset, so the two boards' partition tables must
stay identical. `lp-base/lp-bootctl/tests/partition_layout.rs` enforces
that, along with no-overlap, the 4 MB bound, and that no pre-existing
partition moved. Those guards live in `lp-bootctl` rather than `fw-esp32c6`
because the latter is RV32-only and excluded from host builds, so tests
there would never run.
- Two skip-auto-load reasons now exist (boot-control record, and the
RTC-resident incomplete-boot ladder). The boot log always names which one
applied.

## Alternatives Considered

- **Shrink `lpfs` instead of `nvs`.** Rejected: changing the partition size
changes littlefs's block count, invalidating every existing filesystem
image. Recovery tooling that costs users their files is self-defeating.
- **Append after `lpfs`.** Impossible: the layout ends at exactly `0x400000`
on a 4 MB part.
- **Store the flag in NVS proper.** Rejected: writing ESP-IDF's NVS format
from a host over esptool means implementing that format host-side, which is
far more work than a 16-byte record in a raw sector, for no benefit.
- **Store the flag in `lpfs`.** Rejected on two counts: the host would have
to build and rewrite a littlefs image just to set one flag, and the flag
would live in the same structure whose corruption is one of the things it
needs to survive.
- **Keep the latch in the RTC recovery region.** Rejected: wiped on
`PowerOn`, and unplugging the board is the single most likely user action
in the scenario this exists for.
- **A `consumed` marker word instead of erasing.** Bit-clearing a word is
cheaper than a 4 KB erase and avoids an erase cycle per recovery boot.
Rejected for simplicity: it doubles the states to reason about in a
safety-critical primitive, and the erase happens at most once per recovery
boot, where tens of milliseconds are irrelevant.
- **Numeric partition subtype `0x40`.** Rejected because it does not work:
espflash's `esp-idf-part` panics on any `data` subtype outside its enum.
Used `undefined` (`0x06`), ESP-IDF's designated user-data subtype.

## Follow-ups

- The firmware as a *writer*: latch degraded state across a power cycle after
repeated failures. Needs the boot-complete redefinition and the brownout
blame policy fixed first — both deferred to the follow-up plan.
- The graduated output clamp that reserved flag bits `8..16` exist for.
- Mirroring a last-crash summary into the sector, so post-mortem is readable
from a board that never boots (the RTC crash record is not).
- `lpfs` is declared with subtype `spiffs` but is littlefs; `esp-idf-part`
does support a `littlefs` subtype. Left alone deliberately — cosmetic, and
changing it would alter the partition-table binary for no functional gain.
101 changes: 101 additions & 0 deletions docs/adr/2026-07-30-bootloader-mode-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# ADR: Bootloader-mode detection is handshake-authoritative

- **Status:** Accepted
- **Date:** 2026-07-30
- **Deciders:** Photomancer
- **Supersedes:** None
- **Superseded by:** None

## Context

Recovery tooling has to know what is on the other end of the wire: LightPlayer
firmware running the app protocol, a ROM/stub bootloader waiting to be
flashed, or nothing useful at all. "Flash firmware", "erase", and "write a
boot-control record" only work against a bootloader; the app protocol only
works against the app.

Studio has never had to answer this directly. Readiness is granted by exactly
one thing — a proto-matching `ServerHello` — and everything else was
*diagnosis*: `BootLineClassifier` watches non-protocol serial lines and
explains why a device is not ready. That is enough to render an error, but
not to decide which operations to offer.

## Decision

**Enumeration data cannot answer this, and the code says so.** ESP32-C6 and
ESP32-S3 use the chip's native USB-Serial-JTAG peripheral, and the ROM
bootloader uses *that same peripheral*: the device enumerates as
`303A:1001` whether it is running our firmware or sitting in download mode.
Boards behind a CP2102/CH340 bridge are worse — they always report the
bridge's identifiers, never the chip's state. VID/PID matching is not merely
a weaker signal here; it is structurally incapable of distinguishing the two
states. It is recorded as rejected so it is not re-proposed.

Mode is therefore established by **talking to the device**, in three tiers of
evidence:

1. **A proto-matching `ServerHello` ⇒ `App`.** Readiness is untouched:
`DeviceLinkMode` classifies, it does not promote. The hello remains the
only thing that grants readiness.
2. **ROM download-mode boot lines ⇒ `Bootloader`, corroborating.** Strong
when present, meaningless when absent — a board *already* in download mode
printed its banner before Studio attached, so silence proves nothing. This
is why absence of the signature classifies as `Unknown`, never as "not a
bootloader".
3. **An esptool SYNC handshake ⇒ `Bootloader`, authoritative**, plus the chip
identity for free.

Anything else is `Unknown`, which means "no evidence", not "nothing is there".

**The probe is mode-exclusive because it reboots the device.** The handshake
drives DTR/RTS to enter download mode, and on USB-Serial-JTAG that reset
drops USB enumeration and invalidates the port handle. So
`DeviceSession::probe_link_mode` takes `DeviceMode::Management`, releases the
link, probes, and rebuilds — the same discipline as `manage`. It is never
part of the routine connect ladder. `DeviceLinkMode::probe_would_help()`
returns `false` for `App` for exactly this reason: probing a healthy board
costs a reboot and buys nothing.

**An unanswered probe is not proof of `Unknown`.** A device happily running
the app ignores SYNC too. On probe failure the session reports the *passive*
classification of the rebuilt link rather than asserting `Unknown` — the
honest answer instead of a guess.

## Consequences

- Studio can offer bootloader-only operations on evidence rather than hope,
and can say *which* of the three states it sees.
- Chip identity arrives free with an authoritative probe, which M8 needs to
refuse cross-chip whole-flash restores.
- The probe costs a device reboot, so it must stay behind an explicit user
action or an explicit escalation. Any future caller that runs it
speculatively will reboot healthy boards; `probe_would_help()` exists to
make the right thing easy.
- `BootLineClassifier` remains the single boot-line classifier in the app
layer. A previous duplicate in `lpa-studio-core` was demoted and deleted
(see `2026-07-15-device-session-model`); this ADR does not reintroduce one.
- Providers without a bootloader concept (host process, browser worker sim)
report the probe as unsupported rather than fabricating an answer.

## Alternatives Considered

- **Match USB VID/PID.** Rejected as impossible, not merely unreliable — see
above. This is the alternative most likely to be re-proposed by someone who
has not hit the USB-Serial-JTAG detail, which is why it is stated first.
- **Trust boot-line classification alone.** Rejected: its absence carries no
information, so it can never distinguish "bootloader" from "silent". Kept
as corroboration, where its presence is genuinely strong.
- **Probe on every connect** and cache the answer. Rejected: it reboots every
healthy device on every connect, and on USB-Serial-JTAG it also drops the
port handle mid-ladder.
- **Treat an unanswered probe as `Unknown`.** Rejected: an app-mode device
ignores SYNC, so this would report a working board as broken.

## Follow-ups

- Surfacing the mode in the UI, and the "waiting for a device in bootloader
mode" confirmation that makes the BOOT-button ritual learnable, is M5 of
the device-recovery plan.
- A flapping-device heuristic (enumeration-drop counting) is M9 and is
deliberately offer-only: it must never trigger a probe on its own, since
that would reboot a device that may just have a loose cable.
Loading
Loading