Files
reasampler/src/shell/view/CLAUDE.md
T
daniel 258b1b6b85 Remove the temporary undo-span tracing now that the mid-drain Ctrl-Z fix has landed
Deletes the ring-buffer tracer and its diagnostic action; reverts the span
entry points to their pre-tracing no-parameter signatures.
2026-08-09 13:53:56 -04:00

39 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_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), 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 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 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).
  • TrackFX_SetOffline is the ONLY write in this feature that mints an undo point, and a block around anything else can only manufacture one. DAW-MEASURED, and the premise every decision below rests on: ten writes of the four driven flags (B_SHOWINTCP, B_SHOWINMIXER, B_MAINSEND, I_FXEN) from a timer callback, with no block and no undo call, via SetMediaTrackInfo_Value and again via GetSetMediaTrackInfo, added ZERO entries to the Undo History. Ten TrackFX_SetOffline calls from a timer minted TEN points unblocked, TEN under a block per write, and ONE under a single block spanning all ten — block WIDTH is the entire effect there. 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. The four flags above are the whole of what was measured. The lane parameters this directory also writes with no block behind them — C_LANEPLAYS, I_FREEMODE, C_LANESCOLLAPSED, C_LANESETTINGS (view_lanes' applyLaneOps) — are assumed silent BY INFERENCE from the four, never observed: [verify — DAW]. If they turn out to mint unaided, a reapply that repairs a lane leaves a stray point the criterion below did not intend; that is the failure mode to look for.
  • ONE CRITERION decides who opens an undo block: the path whose own act has to survive as a point, and no other. Two things make an act the path's own — it was explicitly fired by the user (a real mode switch, the membership actions, render_in_place), or it makes a STRUCTURAL project change no re-derivation reproduces (view_lanes' mintManagedLanes, which creates lanes and re-homes items; the panel's detect tick fires it, so it is nobody's action, and it opens a block anyway because nothing else would ever put those lanes back). Everything else RE-ASSERTS driven state the view model recomputes from membership on every apply — the park/restore flags, parent visibility, and applyLaneOps' C_LANEPLAYS plus its I_FREEMODE re-assert on a track that already owns managed lanes — and opens none, because a block there could only manufacture the point a project open or a membership edit must not cost. This is why the same I_FREEMODE write is blocked at one site and not the other: minting SPLITS a track, applyLaneOps only re-states which lane plays. Second clause, binding every site that does open one: a block with nothing REAPER can hold closes on the discard form (Undo_EndBlock2(proj, "", 0) — real from a hand-fired action, per the measurement above), so re-tagging tracks already in that mode leaves no empty point and no relabelled Undo button. The one exception is a REAL SWITCH, which mints written state or not: it is an explicitly fired action and has to stay undoable even where the plan found nothing to write. "Nothing REAPER can hold" is NOT "no REAPER write", and reading it that way was a shipped defect: the four driven flags ARE a REAPER write and mint nothing, so a site predicated on writes names points REAPER then declines to append — history unchanged, Undo button relabelled. What a membership edit's point holds is its view_state write, which is why that write is INSIDE the block and the predicate is the model diff (shell/actions/design_view_actions' runMembershipEdit).
  • 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: applyMode opens it on a REAL SWITCH ONLY (fx_reclaim's UndoSpan) and RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. This does not move an FX write back onto the switch — the writes still run on the timer, off the 1897ms path. DAW-CONFIRMED, no longer inferred: a block opened in the applyMode ACTION and closed several timer callbacks later by the drain holds — switch, then an undisturbed parking drained to completion, then one Ctrl-Z gave exactly ONE undo entry and returned to the previous mode. (The two-point degradation this once hedged against never materialised; the degradations that DO occur are the transport deferral and mintManagedLanes' early close, both named below.) Opening the span any LATER than the switch is rejected, and the ~3s of open block before the drain's first FX write is not exposure buying nothing. The four driven flags mint nothing unaided (measured above), so the block's CLOSE is the only thing that ever makes the switch undoable; open the span at the drain's first write instead and the whole park hold carries no point for a Ctrl-Z to reach — which is precisely the bug the dispatch interception below fixes. The dead time is where the switch's point sits waiting to be minted, which is exactly what the state-load terminator and the dispatch interception reach into that window to do.
  • 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 REAL SWITCH superseding it; ANY PROJECT-STATE LOAD; REAPER's OWN Undo/Redo caught at action dispatch; a project save; extension unload; and any path about to open a block of its own (next invariant). 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). Stated as ONE property over the pure state, which is how it is tested (tests/test_fx_reclaim.cpp, at every terminator rather than incidentally in one scenario): !pending() implies !span.open(). The timer gates every tick on pending(), so a span left open with the wakeup down has nothing that could ever close it. That is why applyMode raises the wakeup BEFORE it opens the span rather than on its way out — a throw in between would otherwise leak the block with the reclaimer asleep — and why the state-load terminator closes BEFORE it abandons.
  • NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — and no site decides that for itself any more. REAPER's blocks are read as a DEPTH COUNTER, not a stack of identities ([verify — DAW], and the same undocumented nested-block behaviour the Gotchas below name): Undo_EndBlock2 at depth 2->1 mints nothing and drops the label and mask it was handed, and the close that does reach depth 0 mints under whatever label IT carries. So a block opened over the span leaves each close paired with the other's open — the switch's point gone, its parking relabelled as the intruder's act, and the UNDO_STATE_FX the drain widened the span to (UndoSpan::widen) dropped, which is the exact loss the widen exists to prevent. The RULE does not rest on that reading: ending the span first yields the switch's point and the opener's as two correctly-labelled points under the counter reading AND under an identity-pairing one, which is why it is stated as an unconditional discipline rather than a workaround for a behaviour nobody has observed. The mechanism is shell/undo's UndoBlock, which every extension-owned block in the tree now opens through — its constructor ends the span when, and only when, it is the OUTERMOST block, so a nested opener cannot close at depth 2->1 and hijack its enclosing block. That directory's CLAUDE.md owns the mechanism and the compile-time enforcement behind it; here it closes three things at once. First, the openers no longer have to be enumerated by hand — the former list (ActionUndoBlock, mintManagedLanes, render_in_place) was never the whole set and omitted ten user-fired blocks in shell/capture, shell/bank_ops and shell/actions, each of which absorbed the user's own act into the mode switch. Second, mintManagedLanes gets the same nesting criterion as everything else: it is called from inside doMoveItems' and render_in_place' blocks, where it used to end the span unconditionally. Third, the RESIDUAL in the other direction is closed too — a foreign block's message pump can re-enter the timer, and a drain tick in there would open its span at depth 2; fxReclaimTick now refuses outright while undoBlockOpen() and consumes nothing, so it simply drains on the first tick after the block closes. The mirror half is still applyMode's: a REAPPLY never closes a span, precisely because it is reachable from inside one of those blocks — only a real switch supersedes, and a real switch is always fired at depth 0. Consequently, a span is never open at depth > 0. The only two openers are a real switch (always fired straight from an action handler, never from inside another block) and the reclaimer's own lazy writeOffline, which the residual above makes unreachable at depth > 0. This is what lets fxReclaimAbandonForStateLoad and fxReclaimFlushRelease close the span unconditionally with no depth check of their own, even when the hook that calls them fires from inside an extension UndoBlock — a capture's own save can (capture_orchestrator's RunCaptureItemAssign wraps RunCapture, which reaches Main_SaveProject). closeSpan() there just finds nothing open. ACCEPTED, and not fixed: mintManagedLanes opens its block once its plan is non-empty, but a non-empty plan whose writes are all already satisfied still mints nothing — so a background detect tick can end a switch's span having competed with it for nothing. Telling the two apart means running the plan, which is the write. The cost is bounded at ONE extra undo point (the switch mints early; its remaining parking mints again under kReclaimUndoLabel), the same degradation a transport deferral already carries, where the alternative destroys the switch's point outright.
  • EVERY project-state load terminates the span itself, at the hook, before REAPER restores anything — isUndo does not narrow it. fxReclaimAbandonForStateLoad runs UNBRANCHED from BeginLoadProjectState (main.cpp) — the only point at which the block still closes over the state it was OPENED on, and the only entry REAPER gives for a state load at all, so undo, redo, a project open, a File > Revert and a new project (reaper_plugin.h:1203-1205) all reach the one terminator. The span is open for at least kParkHoldMs after every real switch and a switch is exactly what a user undoes, so letting the close happen where it used to — as a side effect of the reapply the reloaded model triggers, a full timer tick later — closed the pre-undo block over POST-undo state, minting a spurious point and destroying the redo branch. A NON-undo load left to the next tick's project-retarget close is the identical defect one branch away: the same block closes over the same post-load state, a tick late. isUndo decides one thing only, in main.cpp: whether the SESSION owes a deferred ext-state reload (a normal open's belongs to the identity poll's Load branch, whose forked-sibling re-GUID a reload request here would bypass). The same call stops any drain on the stack where it stands (FxReclaimQueue::abandon, refused at both host entry points), which is stronger than an invalidation: an invalidation still finishes the track it is on, because a step is atomic. A load that keeps the SAME ReaProject — a revert — is what makes that stop load-bearing rather than tidy, for the reason stated at abandon. Daniel's ruling, and the accepted cost: undoing a switch mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one point the terminator closed.
  • REAPER's own Undo/Redo is intercepted one step EARLIER than that, at action dispatch — the load hook is too late for an undo. DAW-OBSERVED, and the whole reason this exists: switch to Design view, press Ctrl-Z inside the park hold, and the user gets one undo point per FX. While the span is open the switch HAS NO POINT YET, so Ctrl-Z does not undo the switch — it undoes whatever preceded it, with REAPER's undo machinery running nested inside our still-open block, and the drain then parks against a model that was undone underneath it. So main.cpp's two command hooks end the span BEFORE REAPER runs the action (shell/undo's endReclaimSpanForUndoCommand): the switch's point mints at that instant and the undo then executes at depth 0 against a properly formed point. The interception CONSUMES NOTHING — the hook returns false and REAPER proceeds normally. The ids are 40029/40030, taken from Cockos' own reference plugins in the vendored SDK (vendor/reaper-sdk/reaper-plugins/reaper_csurf/csurf.h:312-313, sent as WM_COMMAND by four of them) rather than read off the action list by eye; the pair and its section scoping are pinned by tests/test_undo_intercept.cpp. BOTH hooks call it, because hookcommand2 is not the non-main sections' hook: it runs prior to every action triggered by a KEY or MIDI event in ANY section, main included (reaper_plugin.h:214), and a Ctrl-Z is a key event. Which of the two fires first is undocumented and immaterial — the close is idempotent. What this does NOT cover — every one of these still lands inside an open span, exactly as before: an undo fired in a NON-MAIN section (the MIDI editor's own Ctrl-Z is a project undo; its command id lives in that section's own id space and is not verifiable from the SDK, so nothing is guessed here) — plausible in real use; the Undo History window's list, which drives the undo directly rather than dispatching 40029 — plausible, and the likeliest remaining route; and a ReaScript or another extension calling Undo_DoUndo2/Undo_DoRedo2 (reaper_plugin_functions.h:7777-7790) — plausible wherever the user runs scripts. What IS covered beyond the keystroke: the Edit menu, the toolbar button, a control surface (the csurf plugins above), an OSC/MIDI binding, and — by inference from reaper_plugin.h:206's recursion warning rather than by measurement — a script's Main_OnCommand(40029).
  • A state load puts the reclaimer to SLEEP; only a re-read of the model wakes it. abandon clears the wakeup where invalidate raises it, because the two say different things: an invalidation says the MODEL moved and owes a re-derivation, a load says the PROJECT moved under a model nobody has re-read yet. applyMode is invalidateFxReclaim's one caller and fxReclaimPending gates the tick that is the one path to any FX write or park-record spend, so every load that re-reads the model wakes the loop on the same tick, through the reapply the reload drives — and a load that re-reads nothing wakes nothing. File > Revert is exactly that load: same ReaProject, same GUID, same path, so the identity poll answers NoOp and the model is never re-read. Waking there was the only thing that ran, and it converged the FX chains toward the pre-revert model and spent the park records the reverted project still needed. The accepted cost of the sleep: an outstanding reclaim in a project whose interrupting load was some OTHER project's waits for that project's next apply rather than resuming on the next tick. (That a revert leaves the view model stale at all is the identity poll's defect, not the reclaimer's — this invariant only keeps the reclaimer from acting on it.)
  • A reapply opens no undo block and CLOSES NO SPAN, so it can leave no point — whether it wrote or not, and whoever drove it: a project open, a membership edit, render_in_place's inner reapply. The open half is the criterion above (a reapply re-asserts derived state and is nobody's act); the close half is the nesting rule above (a reapply runs inside its driver's block, so a supersede-close from there would land at depth 2->1 and hijack it). It reports only whether it applied; a driver holding a block decides its point from its OWN model diff, never from what the reapply wrote, and ModeApplyResult no longer offers that answer to be misread.
  • 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. Undoing a switch mid-park is that same flip, so a state load DROPS the holds where an invalidation keeps them (FxReclaimQueue::abandon, and again at the end of run for an abandon that arrived through a write's own pump, which found the clock moved into the drain's local). Keeping them fired the post-load re-park on the FIRST tick after the load, with zero debounce, in the wake of REAPER's own restore; re-earning them is what makes the undo cost nothing and keeps the writes clear of the load.
  • NOTHING THE EXTENSION DOES PER TICK RUNS WHILE REAPER IS IN A PROJECT LOAD OR SAVE. BeginLoadProjectState fires before REAPER restores anything, and the restore itself brings plugins online — a plugin load pumps the message loop, which is how a timer tick lands INSIDE the load. main.cpp's OnTimer therefore returns immediately while GetCurrentProjectInLoadSave() names a project (reaper_plugin_functions.h:1584-1587), self-lowering, with no latch to leak. Without it, that tick's poll() reads the half-restored <EXTSTATE> — the very pre-load value the hook defers past because it is wrong — the reapply plans against it, and the reclaim tick writes FX into a project REAPER is still rewriting; each such round is its own settle-and-close, so a re-convergence that owes ONE point costs one per round. That last step is reachable by inspection, not measured: the DAW report it answers is one undo entry per FX after a Ctrl-Z mid-drain, and whether this is the whole of that cause is [verify — DAW]. The extension's other re-entrancy guards (modeApplyInProgress, fxReclaimDrainInProgress) cover OUR pumps only; this is the one that covers REAPER's. It consumes nothing — reload request, load signal, wakeup, derived queue and pin all stand. The realtime-capture drive deliberately sits AHEAD of it, keeping its single-pointer-test idle path and its behaviour across a load unchanged.
  • 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 on a REAL SWITCH ends any prior span, raises the reclaimer's wakeup, and opens the ONE undo span all of that rides (fx_reclaim is what closes it; a reapply opens none and closes none). Reports whether it applied and nothing else (ModeApplyResult) — what it wrote is deliberately not an answer it gives, because none of it mints. 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). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only.
  • 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). UndoSpan owns the ONE undo block the whole drain writes under — opened by applyMode on a real switch, else lazily by the first writeOffline, never while an extension undo block is open (fxReclaimTick refuses the tick outright, consuming nothing), and closed through the single idempotent fxReclaimEndUndoSpan that every terminating path routes through (spanEndsAfter decides which ticks terminate; fxReclaimAbandonForStateLoad is the state-load terminator, which also stops the drain). ReleaseFlush/fxReclaimFlushRelease make a completed record's spend durable on the project's next save, never on the drain's own initiative. fxReclaimActivity reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected fxReclaimPending stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible).

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.
  • 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-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 rolls back the FX the span covered, and only those. The span carries UNDO_STATE_FX only 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 on kViewUndoMask alone 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 — and, since the terminator above, whatever the drain had NOT yet written when the undo arrived, because the span closes at that instant. Whether that reads as correct in the DAW is [verify — DAW], docs/VERIFICATION.md §"Mode switching".
  • A NATIVE REAPER action fired during the span is absorbed into the switch's point. The nesting rule and UndoBlock cover every EXTENSION-owned block; REAPER's own actions cannot be routed through it, so under the depth-counter reading their block closes at depth 2->1, minting nothing and folding their state into ours. Not fixed here and not measured — it rests on the same [verify — DAW] counter reading as the nesting rule, and the window is the park hold plus the drain.
  • The view_state write is NOT inside applyMode's block — but on a REAL switch it is now inside the SPAN. persistViewState (design_view_actions.cpp) runs after applyMode has returned, and a real switch returns with the span open, so the SetProjExtState — and the modal Save-As persistViewState can 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. On the MEMBERSHIP path the write is now deliberately inside the action's own UndoBlock — it is the only thing there a point can hold — and the persist is split so its modal Save-As half runs BEFORE that block opens (shell/actions/design_view_actions, which owns the reasoning). 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-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 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 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]. Link (1) is the same capture-time reading the membership path's fix now rests on, and the nearest thing to evidence for it is reaper_plugin.h:1198-1201: REAPER serializes an extension's state for a point through SaveExtensionConfig with isUndo set — i.e. at point creation, not later. Settling it by moving the SWITCH path's persistViewState is still the wrong move: that one runs with the span open, so its modal would stand inside a block nothing closes until the drain converges.
  • 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, parkTrack never runs, and a track saved parked stays stuck with no console line whatsoever.
  • 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.
  • "Nothing nests on the reapply paths" was FALSE, and cost the switch its undo point. render_in_place and the membership actions each call applyMode inside their own block; the reapply opens none of its own, but a prior REAL SWITCH's span can still be open — for kParkHoldMs plus the whole drain — and it is a block like any other. Two things nest there, and both are now handled by the "nothing may open a block while the span is open" invariant: the caller's block over the span (UndoBlock ends it at the outermost open), and the reapply's former unconditional supersede-close inside the caller's block (only a real switch closes now). What still nests legitimately: doMoveItems and render_in_place over mintManagedLanes, all three UndoBlocks, so the SDK's undocumented nested-block coalescing is load-bearing only for that shape.
  • 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_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.