applyMode now returns with its undo block open; the reclaimer closes it when the drain settles. A transport deferral or a tab switch ends it early -- two points, never a leak.
21 KiB
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_MUTEis untouched absolutely. The master track is untouched absolutely: it is outsideGetTrack's index space, so it never enters the enumeration any of these writes iterate, and its visibility flags are never driven (the SDK forbidsB_SHOWINTCP/B_SHOWINMIXERon 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), andI_FXEN=0(FX bypassed) — flags only. Per-FXTrackFX_SetOfflineis 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 atview_park'sparkTrack. - 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 throughcore/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 no longer sits on the switch's synchronous path: per-FX
offline/online runs on the background reclaimer (
fx_reclaim), in ticks bounded bykOnlineBudgetMs/kOfflineBudgetMs, offapplyMode's block entirely (measured:TrackFX_SetOfflinewas 1897ms of a 1942ms switch). - ONE mode switch is ONE undo point — the background FX parking included. The
driven flags, the lane state, AND the per-FX offline/online the reclaimer writes
minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share
ONE undo block:
applyModeopens it (fx_reclaim'sUndoSpan) and a REAL switch RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. A reapply closes its own before returning. This does not move an FX write back onto the switch — the writes still run on the timer, off the 1897ms path. DAW-MEASURED, and the reason the design is shaped this way: block WIDTH is the entire effect. For tenTrackFX_SetOfflinecalls from a TIMER — unblocked, or under a block per write, by every dispatch route — REAPER minted TEN points; under ONE block spanning all ten it minted ONE. The("", 0)discard form does NOT discard outside an action, it coalesces; it discards only from a hand-fired action. A block opened on one timer callback and closed on a LATER one holds. Whether one held from an ACTION into a later timer callback holds is the same property but is[verify — DAW]; if it does not, the switch and its parking land as two points, which is the intended degradation. - No path may return past a terminating condition with the span open. A leaked
block swallows every edit the user makes afterwards, indefinitely, so every
terminating path routes through ONE idempotent close (
fxReclaimEndUndoSpan): the drain settling, aborting on a generation bump, or deferring on the transport; the project retargeting or closing; the next apply superseding it; a project save; extension unload. The span may stay open only across a wait it can BOUND — the 3s debounce, which is the dead time between the switch and its first FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as long as the playback pass), so it ends the span and the parking that follows mints its own point (spanEndsAfter). - A reapply that wrote nothing leaves no undo point — and a reapply never holds
the span open, so a project open, a membership edit and
render_in_place's inner reapply all keep the block shape they had. 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'sapplyMintsUndoPoint;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. - 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 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-sideI_FREEMODE = 2,I_NUMFIXEDLANES,C_LANEPLAYS:N; item-sideI_FIXEDLANE,C_LANEPLAYS,B_FIXEDLANE_HIDDEN.I_FREEMODEchanges requireUpdateTimeline()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 OPENS the ONE undo span all of that rides (applyMintsUndoPointdecides whether it leaves a point; on a real switchfx_reclaimis what closes it). 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 orB_MUTE. FX are NOT written here — seefx_reclaim.view_park— the park surface, FLAG DOMAIN ONLY.parkTrackcaptures 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;restoreTrackwrites the flags back from the snapshot and spends its flag half (releaseSnapshotFlags). AlsotrackFlagParm(the oneFlag→ 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, andreportExposedTracks/describeExposedTracks(named tracks, once per changed set per project).view_lanes— managed fixed lanes:mintManagedLanessplits multi-mode content onto per-mode lanes,applyLaneOpsdrivesC_LANEPLAYSfor the active mode,reconcileManagedLanesrecovers ownership from durableP_LANENAMEon project open. Manual lanes are never minted, driven, renamed or reassigned.view_reconcile— the repair pass for a project state REAPER reloaded under us:noteAppliedStaterecords what an apply left driven;reconcileAppliedFlagsdiffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix forUNDO_STATE_TRACKCFGnot carryingB_SHOWINTCP/B_SHOWINMIXER(DAW-confirmed).view_tracks— the scaffolding every write pass sets up:TrackHandles/TrackByGuid,readFolderEntries/indexHandles, andUiRefreshHold(thePreventUIRefreshRAII pair every write phase runs under).view_solo— theI_SOLOread/write pair behind the per-mode solo surface, plusclearTrackSolos/restoreTrackSolos, the outgoing-clear and incoming-replay entry pointsviewdrives them through. Holds no policy: what to cache, clear, or replay iscore/view/solo_cache.fx_reclaim— the FX convergence loop, off the switch's synchronous path (TrackFX_SetOfflinewas 1897ms of a measured 1942ms switch).drainTickmoves one track's FX per step againstcore/view/fx_state's derived work list (Online before Offline; an Offline item waits outkParkHoldMsso a fast A→B→A flip moves nothing), budgeted per tick (kOnlineBudgetMs/kOfflineBudgetMs) and gated off the transport for the Offline direction only.FxReclaimQueueowns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump).UndoSpanowns the ONE undo block the whole drain writes under — opened byapplyModeon a real switch, else lazily by the firstwriteOffline, and closed through the single idempotentfxReclaimEndUndoSpanthat every terminating path routes through (spanEndsAfterdecides which ticks terminate).ReleaseFlush/fxReclaimFlushReleasemake 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 incore/view— reference it, do not duplicate its spec here. - 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_SOLObut not the model's solo cache oractiveModeId— 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-record-plus-parked-chain pair EXPOSE handles.
A
view_statethat PARSED but carries no snapshot for the track — a snapshotreconcilepruned 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 (chainReadsParkednever 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 theview_stategotcha below. - An UNDO of a mode switch rolls back the FX the span covered, and only those.
The span carries
UNDO_STATE_FXonly once the drain has actually written FX under it (UndoSpan::widen), so a switch whose parking never matured — a fast A→B→A flip — still closes onkViewUndoMaskalone and costs no FX serialization. What a Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a transport deferral, and a drain no switch opened a span for, each mint their own point. Whether that reads as correct in the DAW is[verify — DAW],docs/VERIFICATION.md§"Mode switching". - The
view_statewrite is NOT insideapplyMode's block — but on a REAL switch it is now inside the SPAN.persistViewState(design_view_actions.cpp) runs afterapplyModehas returned, and a real switch returns with the span open, so theSetProjExtState— and the modal Save-AspersistViewStatecan pop on an unsaved project — land inside the block after all. That is a consequence of the span, not a decision made here; it does not resolve the redo hypothesis below, which turns on capture-time semantics the SDK does not document.[verify — DAW]that a Save-As dialog opening under an open undo block is harmless. The rest of this gotcha describes the reapply path, where nothing changed. 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 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-switchview_state, carrying no snapshot for those tracks (UNDO_STATE_MISCCFGcovers extension state,reaper_plugin.h:1544). Theprojectconfigreload 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 inplanToggle, 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 andparkTrackEXPOSEs them instead. TWO links are unverified and the route dies if either fails: (1) that the point's stored state is captured atUndo_EndBlock2and therefore excludes thepersistViewStatewrite that follows it — the SDK documents no capture-time semantics for the state mask; (2) that a redo firesBeginLoadProjectStatewithisUndo == trueso the session really does reload the stale ext state (asserted inmain.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 movingpersistViewStateinside the block — it can open a modal Save-As; whether to split the persist is a separate decision. - 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,parkTracknever runs, and a track saved parked stays stuck with no console line whatsoever. applyModefails closed when re-entered — a model-consistency guard now, not a pump-during-FX-writes one. NothingapplyModecalls 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'sApplyLatch) 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 (reportModeSwitchRefusedspeaks only for the transport gate) and the user re-fires. The one caller with nothing to fall back on ismain.cpp's load glue, which testsmodeApplyInProgressANDfxReclaimDrainInProgressbefore spending its one-shot signal and retries on the next tick — the reclaimer's own drain (fx_reclaim'sDrainLatch) is latched for the same reason over the same window:TrackFX_SetOfflineis what pumps now, and it holds the view model the drain reads, notapplyMode.render_in_place's ownapplyModecall 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_placestill callsapplyModeinside its ownUndo_BeginBlock2pair for the reapply — one point rather than two for the flags and lane state — but with no FX writes left insideapplyMode's block there is nothing left to coalesce beyond that; the SDK's nested-block coalescing itself is still[verify — DAW].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](seefxGuidStringinview_park.cpp) and SWS issue #802 is a known reason it might not hold:SNM_MoveOrRemoveTrackFXreportedly 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 throughresolveFxRestore. Do not design around this pre-emptively; if native reorder is clean (the likely case), only the SWS path degrades.