Design View: no undo block may open over the reclaim span, and a reapply closes none

Every opener ends the span first, so a tag fired mid-drain no longer hijacks the
switch's point. One stated criterion governs both lane sites; no-op actions discard.
This commit is contained in:
2026-08-07 15:13:52 -04:00
parent 39529a5ac4
commit b2f6b3f1d0
9 changed files with 210 additions and 76 deletions
+74 -21
View File
@@ -74,11 +74,34 @@ decide membership or mode rules.
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. So: a path that writes only flags opens NO
block (a repair or a reapply must not cost the user a Ctrl-Z), and a path whose act
IS the user's opens one to make that act undoable at all — which is why the three
membership-edit actions own their own (`design_view_actions`' `ActionUndoBlock`)
rather than leaning on a reapply that now mints nothing.
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 that wrote nothing
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. 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. "Wrote" means a REAPER write throughout — model moves persist
through `persistViewState`, outside every block, so no point could carry them
(`ModeApplyResult`, `view.h`).
- **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
@@ -92,12 +115,37 @@ decide membership or mode rules.
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; ANY PROJECT-STATE
LOAD; a project save; extension unload. The span may stay open only across a wait it
project retargeting or closing; the next REAL SWITCH superseding it; ANY PROJECT-STATE
LOAD; 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`).
- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — every opener ends it
first, as its first act.** 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. Ending it first mints the switch's point whole, at
depth 0, and leaves the opener's block the only one in play. The
openers, all of them: `design_view_actions`' `ActionUndoBlock` (whose constructor
ends the span, so all four membership actions inherit it), `view_lanes`'
`mintManagedLanes`, and `shell/capture`'s `render_in_place`. The mirror half is
`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. RESIDUAL, out of this rule's reach: an extension undo block
ELSEWHERE (a capture, a bank verb, an ingest) that pumps the message loop can re-enter
the timer, and a drain tick that settles in there closes the span at depth 2->1 with
the same result. Closing that needs every extension-owned `Undo_BeginBlock2` to route
through one opener that ends the span — not attempted here.
- **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`
@@ -137,13 +185,13 @@ decide membership or mode rules.
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 at all, 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. Nothing a reapply writes mints unaided (see the
measurement above), so a block there could only manufacture the Ctrl-Z opening a
project must not cost. A caller whose own act needs to stay undoable spans it
itself. A REAL switch still 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 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 what it wrote instead, and the
driver holding the block decides — `ModeApplyResult::wroteProjectState`.
- **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
@@ -186,7 +234,7 @@ 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 opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none). 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` — 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 and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`.
- `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project).
- `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned.
- `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only.
@@ -288,12 +336,17 @@ applies the resulting lane state to live tracks.
refusal leaves the membership tags it already wrote in place, so the next
reapply — not a user re-fire of render-in-place — is what brings the tracks
into sync.
- `render_in_place` calls `applyMode` inside its own `Undo_BeginBlock2` pair, and
that block is now the only one in play on that path — the reapply opens none of
its own, so nothing nests and the SDK's undocumented nested-block coalescing
stopped being load-bearing here. The same holds for the membership actions'
`ActionUndoBlock`; `doMoveItems` is the one that still nests, over
`mintManagedLanes`.
- **"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 `Undo_BeginBlock2` pair; the reapply opens no block 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 (each caller ends the span first), 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`, both
properly paired inside a single outer block, 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