Docs: reflect the Design View redesign — EXPOSE replaces the park refusal, FX offlining moved to the reclaimer, undo repair is ours

This commit is contained in:
2026-08-06 23:56:50 -04:00
parent c9c957a3da
commit 2173263bb9
4 changed files with 106 additions and 138 deletions
+87 -129
View File
@@ -27,28 +27,22 @@ decide membership or mode rules.
lost, only parked with the mode it belongs to. A REAPPLY (target == active —
tag/untag/show-both, project load) touches solo not at all.
- **Parking a track** (inactive-mode leaf) drives `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
`TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park,
not mix-removal-only.
- **A snapshot is only ever taken from a chain no park has touched — BOTH halves
of it.** The trust test reads the four driven flags AND the FX chain, and either
reading parked alone is enough to refuse: each half is the only source of truth
for its own half of the snapshot, so one of them lying poisons the whole record.
The FX half asks "is ANY FX offline", not "every" — no read distinguishes a
park's leftover from one the user set by hand, and only "any" errs toward
committing nothing false. An absent snapshot is NOT evidence of a clean chain —
a saved project, a hand-edited chain and an undo/redo can each present one; the
Gotchas below enumerate the routes that still reach it. A park meeting that pair
REFUSES: no flag write, no FX write, no snapshot, and a
console report NAMING the refused tracks (they are hidden from both the TCP and
the mixer, so a count points at nothing the user can go and find) plus the hand
recovery, printed once per changed refused set per project rather than on every
reapply. Leaving the track as found is the only non-destructive answer once the
pre-park truth is gone. **The refusal's cost is deliberate and it is not
symmetric with "hidden forever":** because it skips the FX half too, a track
whose flags read parked but whose FX are still online keeps them online for
good. Refusing guarantees nothing false is written; it does not promise to
finish the park. Decision at `view_fx_park`'s `decidePark`.
(hide both panels), `B_MAINSEND=0` (out of mix), and `I_FXEN=0` (FX bypassed) —
flags only. Per-FX `TrackFX_SetOffline` is the reclaimer's job (`fx_reclaim`),
off this path entirely.
- **EXPOSE, not refuse.** When no record exists for a track
(`model.snapshot(guid) == nullptr`) and the live chain already reads
park-shaped (`chainReadsParked`, flags only — the FX half is excluded, see its
own comment), parking now would adopt a park with no account behind it as the
user's intent — an undo/redo or reload that dropped the snapshot, a hand/script
edit, or simply a track the user keeps hidden. Instead the tool drives the
track visible/in-mix/FX-enabled, stores no snapshot, and reports it once per
changed set per project (`reportExposedTracks`/`describeExposedTracks`, which
NAME the tracks — they were hidden from both the TCP and the mixer, so a bare
count points at nothing the user can go and find). The next apply sees an
honest chain and parks/captures it normally — self-healing, no user action
required. Daniel's ruling: expose a broken track rather than hide it forever.
Decision at `view_park`'s `parkTrack`.
- **Non-destructive restore.** For every flag the tool drives, snapshot the prior
value BEFORE parking; on toggle-back restore FROM the snapshot, never to a
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
@@ -66,29 +60,20 @@ decide membership or mode rules.
active mode — stateful plugins (convolution, loaded samplers, tail-holding
effects) re-initialize on return (load hitch, un-persisted internal state lost).
Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip).
**The hitch sits on the switch's synchronous path, deliberately.** Per-FX
offline/online runs inside `applyMode`'s own undo block, between the flag
writes and `Undo_EndBlock2`, so an action-driven switch does not return until
every plugin has moved. That cost is the price of the invariant below, and it
was accepted as such.
- **ONE mode switch is ONE undo point.** Everything the switch wrote into the
project — the driven flags, every per-FX offline/online, the lane state —
rolls back in a single Ctrl-Z. (The `view_state` ext-state write is NOT among
them; it lands after the block, see Gotchas.) What was MEASURED (before any
fix): a `TrackFX_SetOffline` made outside any undo block mints ONE implicit
undo point per call, and a small project showed 10 points for one switch — 1
for the switch itself and 9 for its FX. That measurement is the whole reason
the FX writes must sit inside a block. Which block is the design decision, and
it is settled: the SWITCH'S OWN. The undo surface
(`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:7745-7822`) is
begin/end/state-change and nothing else — no append-to-point, no coalescing
flag, no undo-silent write — and a block cannot be held open across a return
to the message loop, so ANY deferral of the writes costs a second undo point.
Two points is a rejected design, not a lesser one: do not reintroduce a
deferred queue, an idle drain, or a second block. That an explicit block
suppresses the per-call implicit points is still the PREMISE the whole
arrangement rests on — INFERENCE from the measurement above, not observed;
`docs/VERIFICATION.md` §"Mode switching" is where it gets checked.
The hitch no longer sits on the switch's synchronous path: per-FX
offline/online runs on the background reclaimer (`fx_reclaim`), in ticks
bounded by `kOnlineBudgetMs`/`kOfflineBudgetMs`, off `applyMode`'s block
entirely (measured: `TrackFX_SetOffline` was 1897ms of a 1942ms switch).
- **ONE mode switch is ONE undo point.** The driven flags and the lane state
written inside `applyMode`'s block roll back in a single Ctrl-Z. (The
`view_state` ext-state write is NOT among them; it lands after the block, see
Gotchas.) Per-FX offline/online moved off this block entirely, onto the
reclaimer (`fx_reclaim`) — each write there runs inside its OWN discarded
block (`Undo_EndBlock2(proj, "", 0)`), so it mints nothing. DAW-VERIFIED: ten
unblocked `TrackFX_SetOffline` calls minted ten undo points; the same ten
calls wrapped in `Undo_BeginBlock2`/`Undo_EndBlock2(proj, "", 0)` minted zero
— confirming the discard-block premise the reclaimer's per-write block
(`fx_reclaim.cpp`'s `writeOffline`) rests on.
- **A reapply that wrote nothing leaves no undo point.** The load-tick reapply
re-plans a park for every inactive leaf, and over a project saved fully parked
it finds every flag and every FX already where the plan wants them. The block
@@ -97,20 +82,11 @@ decide membership or mode rules.
not cost the user a Ctrl-Z. A REAL switch always mints, written state or not:
it is an explicitly fired action and has to stay undoable even when the plan
found nothing to write.
- **Stated DEVIATION — the undo mask does not keep FX out of a real switch.** The
apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when
`I_FXEN` or a per-FX offline state actually moved — which every park does. So
any switch that parks at least one track still makes REAPER marshal the
project-wide FX chunk into its undo record. The saving is real only on a
reapply that wrote nothing and on a no-op switch. Narrowing it further would
mean not carrying the FX domain in the undo record at all, which would break
"one switch is one Ctrl-Z."
- **A rapid A→B→A flip pays two full park/restore cycles.** The retired intent
queue let an intent annihilate its own pending inverse, so a fast double-toggle
moved no plugins at all. Synchronous writes have no such window: the first
switch offlines the chain, the second brings it back, and both hitches are
paid. Accepted — the flip was never free (the flag writes always ran twice),
and the alternative costs the one-point invariant above.
- **A rapid A→B→A flip moves zero plugins.** The reclaimer only starts a park's
OFFLINE work once a track has read continuously parked for `kParkHoldMs`
(3000ms). A flip inside that window flips the flags twice but the debounce
never matures, so the reclaimer finds every chain already where the record
wants it and writes nothing.
- **Show-both semantics:** a per-track "pin visible across modes" flag re-enables
processing whenever shown. A show-both leaf appears in every mode's visible set
and is never parked — its driven flags stay at snapshot/restored values, FX
@@ -148,20 +124,19 @@ applies the resulting lane state to live tracks.
## Modules
- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and owns the ONE undo block all of that rides (`applyMintsUndoPoint` decides whether it leaves a point). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
- `view_fx_park` — the park surface: ONE track's whole move, both halves together. `parkTrack` is the trust decision (`decidePark` over "does the model hold a snapshot" × "does the chain already read parked", with `decideParkForTrack` owning the live reads BOTH halves of that second term needs), the pre-park snapshot when it is owed, then flags before FX; `restoreTrack` mirrors it, FX before flags, and consumes the snapshot. Each reports back what the caller's block needs (`TrackApplyResult`: did anything move, does the mask owe `UNDO_STATE_FX`, was the park refused, what could a restore not place). Also the `TrackFX_GetFXGUID` identity read all three of snapshot/park/restore address FX through, `trackFlagParm` — the ONE `Flag` → REAPER-parameter mapping they likewise share — the `writeIfChanged` read-before-write every driven parameter goes through, `trackStillLive` (a per-track `ValidatePtr2`, because a plugin unloading is assumed to pump the message loop), and the two console reports (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it; `reportFxRestoreDrops`). **The snapshot is consumed where the restore RUNS** — from that line the flags are back at their captured values, and a model still describing the track as parked would let a later persist or reapply replan a restore over whatever the user changed since.
- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and owns the ONE undo block all of that rides (`applyMintsUndoPoint` decides whether it leaves a point). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`.
- `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project).
- `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned.
- `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed).
- `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under).
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump); each `writeOffline` runs inside its own discarded undo block, DAW-verified to mint nothing. `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative.
## Gotchas
- The pure mode model (`ViewModeModel`, membership, `reconcile(liveGuids)`, the
snapshot-based park/restore planner) lives in `core/view` — reference it, do not
duplicate its spec here.
- **`TrackPlan::fxOffline` is read on the RESTORE side only.** `planToggle` builds
every park plan with `fxCount=0`, and this shell's park loop passes `tp.flags`
alone to `parkTrack`, which expands the real writes itself from
`TrackFX_GetCount` — so the park-side field is populated only by tests. A shell
change that starts reading it on the park path is reading an empty vector.
- The Two-canvas sub-phase (Phase D2/E)'s settled DAW-application rules
(fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are
reflected in Invariants above; the membership/lane-ownership model concepts
@@ -172,37 +147,23 @@ applies the resulting lane state to live tracks.
under whatever mode id is active at that point, not the one the user undid back
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
the solo cache inherits it rather than introducing it. Not fixed here.
- **Which routes reach the no-snapshot-plus-parked-chain pair, re-derived for the
one-point design.** Three of the routes that used to reach it were artifacts of
the retired deferral window, and are now NARROWED TO THE RE-ENTRANCY WINDOW
rather than gone: the flags-written-FX-not moment still exists inside
`parkTrack`, but nothing can observe it except code re-entered through the
pump premise below, and `applyMode` fails closed when re-entered
(`modeApplyInProgress`). The window that remains is what a call REACHABLE FROM
a pumped message loop but not routed through `applyMode` could see — no save,
no reload and no discard reaches it (`main.cpp`'s `OnTimer` defers
`session.poll()` itself, the one caller of `loadFromProject` a re-entered timer
could reach, under this same latch), and there is no queued intent for an undo
to drop. What survives:
- A `view_state` that PARSED but carries no snapshot for the track — a snapshot
`reconcile` pruned while its track was out of the live enumeration, then
undo/redo-restored with the track but not the snapshot. (Asserted by the
branch that introduced the refusal; not re-observed since.)
- A hand or script edit that leaves a chain park-shaped — including the single
likeliest real case, a track with untouched flags and just ONE hand-offlined
plugin (the FX half of the disjunction refuses alone; the flags never have to
read parked too).
- A track the USER keeps hidden from both panels, out of the mix and
FX-bypassed. It reads identically to both of the above and is refused too —
no lost state there at all, which is why the report asserts no cause.
- A REDO of a mode switch, hypothetically — the mechanism and its two
unverified links are in the `view_state` gotcha below, which owns that
question; do not re-derive it here.
An UNDO of a mode switch (Ctrl-Z) is NOT a route, and no longer needs an
ordering premise to say so: flags and FX moved inside ONE block, so they roll
back together and no intermediate state exists for a park to misread. The redo
direction is not symmetric with it — see below.
- **Which routes reach the no-record-plus-parked-chain pair EXPOSE handles.**
A `view_state` that PARSED but carries no snapshot for the track — a snapshot
`reconcile` pruned while its track was out of the live enumeration, then
undo/redo-restored with the track but not the snapshot; a hand or script edit
that leaves a chain park-shaped, including the likeliest real case, a track
with untouched flags and just ONE hand-offlined plugin (`chainReadsParked`
never reads the FX half at all, so this route is flags-only); or a track the
USER keeps hidden from both panels, out of the mix and FX-bypassed, which
reads identically and is EXPOSEd too — no lost state there at all, which is
why the report never asserts a cause. A REDO of a mode switch is a further,
hypothetical route — see the `view_state` gotcha below.
- **An UNDO of a mode switch no longer rolls back FX at all.** FX offline/online
moved off `applyMode`'s block entirely onto the reclaimer, so a Ctrl-Z restores
only the flags and lane state the block wrote — whatever the reclaimer had
converged (or had not yet converged) survives the undo untouched. Whether this
reads as correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md`
§"Mode switching".
- **The `view_state` write is NOT inside `applyMode`'s block.** `persistViewState`
(`design_view_actions.cpp`) runs after `applyMode` has returned, so
`SetProjExtState` lands past `Undo_EndBlock2`. The UNDO direction still comes
@@ -210,7 +171,7 @@ applies the resulting lane state to live tracks.
which is what a Ctrl-Z should restore — but by position, not by design. The
REDO direction is what this leaves open, and the cost is worse than the
model-vs-undo split the solo-cache gotcha describes: **a redo can plausibly
produce a NEW refusal route.** As a HYPOTHESIS — the redo restores the parked
produce a NEW EXPOSE route.** As a HYPOTHESIS — the redo restores the parked
flags AND the ext state as the point closed over it, i.e. the PRE-switch
`view_state`, carrying no snapshot for those tracks (`UNDO_STATE_MISCCFG`
covers extension state, `reaper_plugin.h:1544`). The `projectconfig` reload
@@ -218,7 +179,8 @@ applies the resulting lane state to live tracks.
plans NEITHER a park (the track is a member of the restored active mode) nor a
restore (no snapshot to restore from) — verified in `planToggle`, which pushes
nothing at all for an active leaf without a snapshot. The tracks stay parked
with no snapshot, so the next real switch parks them and `decidePark` refuses.
with no snapshot, so the next real switch parks them and `parkTrack` EXPOSEs
them instead.
TWO links are unverified and the route dies if either fails: (1) that the
point's stored state is captured at `Undo_EndBlock2` and therefore excludes the
`persistViewState` write that follows it — the SDK documents no capture-time
@@ -228,43 +190,39 @@ applies the resulting lane state to live tracks.
keeps its snapshots and the redo is consistent. `[verify — DAW]`. Do not "fix"
it by moving `persistViewState` inside the block — it can open a modal Save-As;
whether to split the persist is a separate decision.
- A DIFFERENT strand entirely, which the refusal report does NOT cover: an
- A DIFFERENT strand entirely, which the EXPOSE report does NOT cover: an
absent or malformed `view_state` (`loadViewModel`, `ext_state_io.cpp`) is not
a parked-chain reading at all — it falls back to a DEFAULT model (active mode
Arrange, empty membership), so nothing is planned to park the track,
`decidePark` never runs, and a track saved parked stays stuck with no console
`parkTrack` never runs, and a track saved parked stays stuck with no console
line whatsoever.
- **`applyMode`'s block can be nested, and now carries FX writes while it is.**
`render_in_place` calls `applyMode` inside its own `Undo_BeginBlock2` pair, so
that verb's single `UNDO_STATE_ALL` point now absorbs the reapply's FX moves
instead of a separate point landing after it — the better outcome, but it rests
on REAPER coalescing nested blocks, which the SDK header does not document.
The inverse is also open: a plugin unloading is ASSUMED to pump the message
loop, so an action fired mid-apply could open its own block inside this one.
`applyMode` itself cannot be that action — it fails closed when re-entered
(`modeApplyInProgress`, `view.cpp`'s `ApplyLatch`) — but nothing else is
latched, `mintManagedLanes` included, and it opens a block of its own. Both are
- **`applyMode` fails closed when re-entered — a model-consistency guard now,
not a pump-during-FX-writes one.** Nothing `applyMode` calls is known to pump
the message loop any more (the FX writes are gone from it), but a re-entrant
apply would still plan against the same model the outer call is iterating and
consume records out from under it — `modeApplyInProgress`
(`view.cpp`'s `ApplyLatch`) drops the re-entrant call rather than risk that. A
mode switch or membership reapply fired while another apply is on the stack is
DROPPED, not queued; the action callers just no-op, silently
(`reportModeSwitchRefused` speaks only for the transport gate) and the user
re-fires. The one caller with nothing to fall back on is `main.cpp`'s load
glue, which tests `modeApplyInProgress` AND `fxReclaimDrainInProgress` before
spending its one-shot signal and retries on the next tick — the reclaimer's
own drain (`fx_reclaim`'s `DrainLatch`) is latched for the same reason over
the same window: `TrackFX_SetOffline` is what pumps now, and it holds the view
model the drain reads, not `applyMode`. `render_in_place`'s own `applyMode`
call is the exception to the drop's recovery path, not to the drop itself: its
refusal leaves the membership tags it already wrote in place, so the next
reapply — not a user re-fire of render-in-place — is what brings the tracks
into sync.
- `render_in_place` still calls `applyMode` inside its own `Undo_BeginBlock2`
pair for the reapply — one point rather than two for the flags and lane state
— but with no FX writes left inside `applyMode`'s block there is nothing left
to coalesce beyond that; the SDK's nested-block coalescing itself is still
`[verify — DAW]`.
That same premise put a residual on track handles, now CLOSED by construction
rather than by a partial guard. The park and restore loops re-`ValidatePtr2`
per track (`trackStillLive`) because they are the ones interleaved with the FX
writes; the whole enumeration is then re-validated ONCE where those loops end,
so every pass after them — lane ops, parent visibility, the solo replay — sees
only handles REAPER still recognizes. A dead track's handle is nulled in place
rather than erased from the vector, which is what `view_solo`'s writers already
skip on.
The latch's own cost, stated: a mode switch or a membership reapply fired while
another apply is on the stack is DROPPED, not queued. The one caller with
nothing to fall back on is `main.cpp`'s load glue — it spends a one-shot signal
— so it tests `modeApplyInProgress` before consuming and retries next tick. The
action callers just no-op, silently (`reportModeSwitchRefused` speaks only for
the transport gate); the user re-fires. `render_in_place`'s own `applyMode` call
is the exception to that recovery path, not to the drop itself: its refusal
leaves the membership tags it already wrote in place, so the next reapply — not
a user re-fire of render-in-place — is what brings the tracks into sync.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a
`[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
the FXID lines behind on reorder rather than moving them with the plugin. If
confirmed, an SWS-driven reorder of a parked track's chain — not a native