# src/shell/view — Design View mode application shell ## Scope The REAPER-facing half of Design View: applying a mode's visibility/processing state to live tracks (park/restore), snapshotting flag values before parking, and restoring from snapshot on toggle-back. The mode registry, membership derivation, and the pure park/restore planner are owned by `core/view` (`view_mode_model`) — this directory is the shell that reads/writes REAPER track flags, it does not decide membership or mode rules. ## Invariants - **Never touches master or `B_MUTE`; never LOSES solo.** The tool owns visibility, `B_MAINSEND`, `I_FXEN`, per-FX offline, and — on a real mode switch only — `I_SOLO`, on every managed leaf, tagged or untagged. `B_MUTE` is untouched absolutely. The master track is untouched absolutely: it is outside `GetTrack`'s index space, so it never enters the enumeration any of these writes iterate, and its visibility flags are never driven (the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER` on master). - **Solo surfaces are disjoint per mode, cached not destroyed.** A real switch (target != active) reads every live track's raw `I_SOLO`, banks the non-zero values against the OUTGOING mode, clears them, and replays the incoming mode's banked values verbatim — solo-in-place and safe-solo variants included, never collapsed to a boolean. This is the same snapshot sense of non-destructive that park/restore already gives visibility and FX state: the user's solo is never 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`. - **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 to its captured value — the phase's trust anchor, the analog of the capture null test. Per-FX offline is snapshotted WITH each FX's identity (`TrackFX_GetFXGUID`) and restored through `core/view/fx_offline`, so a chain reordered while the track was parked cannot land one plugin's state on another; an FX gone at restore time is dropped and reported to the console, never restored onto its old slot. - **GUID-keyed, reorder-safe.** Membership/snapshot keys on track GUID (`GetTrackGUID`), never track index; tolerates unknown/stale GUIDs (pruned on reconcile). - **Relative/portable state only** in the persisted view section (GUID strings, mode ids — no absolute paths, no index positions). - **Documented caveat:** offlined FX re-instantiate when a track returns to the 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. - **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 then closes on the `("", 0)` discard form (`view.h`'s `applyMintsUndoPoint`; `mintManagedLanes`' no-op path uses the same idiom) — opening a project must 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. - **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 online, in the mix. ("Show but keep parked" is not offered.) Stored on the membership record; persists; togglable per selection. - **No literal second canvas.** A literal second arrange surface, a second window, or a duplicated project stays rejected — reject any such path in review. **Two-canvas sub-phase (Phase D2/E) — settled and landed parts, DAW-application half:** - **Fixed-lane item-level separation mechanics.** Map mode → lane; toggle drives per-lane play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. SDK surface (verified present in `vendor/reaper-sdk`): track-side `I_FREEMODE = 2`, `I_NUMFIXEDLANES`, `C_LANEPLAYS:N`; item-side `I_FIXEDLANE`, `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN`. `I_FREEMODE` changes require `UpdateTimeline()` to take visible effect. - **Inactive-mode content is hidden AND silenced.** The off-mode lane is set `C_LANEPLAYS = 0` — neither shown nor played — consistent with exclusive membership and with D1's "flipping modes is a real change, not cosmetic." Show-both is the deliberate opt-out for a lane that must stay audible across modes. - **Capture placement is mode-aware.** An explicit placement while in Design mode — including capture-and-place — lands the item in the Design lane; the same rule governs manual insertion. The capture load-bearing principle is untouched: capture still writes a file + index entry and never auto-inserts; this governs only *where* an explicit placement lands. - **REAPER floor: v7** for this sub-phase (fixed lanes shipped in v7); no version-gate branch — below v7 the sub-phase is simply unavailable. Item→lane membership rules (the adoption rule, exclusive-per-item membership, the managed/manual lane distinction, and the lane-ownership index) are model concepts owned by `core/view` — see that directory's Invariants; this directory only 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_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`. ## 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 it also covers live in `core/view`'s Invariants. - REAPER's own undo restores live `I_SOLO` but not the model's solo cache or `activeModeId` — neither rolls back with a Ctrl-Z. An undo after a mode switch leaves the two out of step, and the next switch banks the undo-restored solos 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. - **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 out right — the point closed over the ext state as it stood BEFORE the switch, 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 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 then rebuilds the model from that stale value, and the reapply it triggers 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. 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 semantics for the state mask; (2) that a redo fires `BeginLoadProjectState` with `isUndo == true` so the session really does reload the stale ext state (asserted in `main.cpp`, never observed). If (2) is false the in-memory model 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 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 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 `[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 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 drag-reorder — can produce wrong-plugin restores or mass drops through `resolveFxRestore`. Do not design around this pre-emptively; if native reorder is clean (the likely case), only the SWS path degrades.