Extends the OnTimer guard to skip session.poll() itself while modeApplyInProgress(), preventing an undo/redo or project-switch reload from replacing the view model under an in-flight applyMode.
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),I_FXEN=0(FX bypassed), andTrackFX_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'sdecidePark. - 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 sits on the switch's synchronous path, deliberately. Per-FX
offline/online runs inside
applyMode's own undo block, between the flag writes andUndo_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_stateext-state write is NOT among them; it lands after the block, see Gotchas.) What was MEASURED (before any fix): aTrackFX_SetOfflinemade 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'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. - Stated DEVIATION — the undo mask does not keep FX out of a real switch. The
apply mask (
kApplyUndoMask) dropsUNDO_STATE_FXand ORs it back in whenI_FXENor 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-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 owns the ONE undo block all of that rides (applyMintsUndoPointdecides 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 orB_MUTE.view_fx_park— the park surface: ONE track's whole move, both halves together.parkTrackis the trust decision (decideParkover "does the model hold a snapshot" × "does the chain already read parked", withdecideParkForTrackowning the live reads BOTH halves of that second term needs), the pre-park snapshot when it is owed, then flags before FX;restoreTrackmirrors it, FX before flags, and consumes the snapshot. Each reports back what the caller's block needs (TrackApplyResult: did anything move, does the mask oweUNDO_STATE_FX, was the park refused, what could a restore not place). Also theTrackFX_GetFXGUIDidentity read all three of snapshot/park/restore address FX through,trackFlagParm— the ONEFlag→ REAPER-parameter mapping they likewise share — thewriteIfChangedread-before-write every driven parameter goes through,trackStillLive(a per-trackValidatePtr2, 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— 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.
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. -
TrackPlan::fxOfflineis read on the RESTORE side only.planTogglebuilds every park plan withfxCount=0, and this shell's park loop passestp.flagsalone toparkTrack, which expands the real writes itself fromTrackFX_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_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-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, andapplyModefails closed when re-entered (modeApplyInProgress). The window that remains is what a call REACHABLE FROM a pumped message loop but not routed throughapplyModecould see — no save, no reload and no discard reaches it (main.cpp'sOnTimerdeferssession.poll()itself, the one caller ofloadFromProjecta re-entered timer could reach, under this same latch), and there is no queued intent for an undo to drop. What survives:- 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. (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_stategotcha 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.
- A
-
The
view_statewrite is NOT insideapplyMode's block.persistViewState(design_view_actions.cpp) runs afterapplyModehas returned, soSetProjExtStatelands pastUndo_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-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 anddecideParkrefuses. 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 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,decideParknever 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_placecallsapplyModeinside its ownUndo_BeginBlock2pair, so that verb's singleUNDO_STATE_ALLpoint 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.applyModeitself cannot be that action — it fails closed when re-entered (modeApplyInProgress,view.cpp'sApplyLatch) — but nothing else is latched,mintManagedLanesincluded, 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-ValidatePtr2per 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 whatview_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 ismain.cpp's load glue — it spends a one-shot signal — so it testsmodeApplyInProgressbefore consuming and retries next tick. The action callers just no-op, silently (reportModeSwitchRefusedspeaks only for the transport gate); the user re-fires.render_in_place's ownapplyModecall 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](seefxGuidStringinview_fx_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.