Collapse the FX-park drain's writes into one undo point instead of one per TrackFX_SetOffline

This commit is contained in:
2026-08-05 20:40:45 -04:00
parent bd86ede5b3
commit 13c824c63d
5 changed files with 164 additions and 41 deletions
+23 -13
View File
@@ -71,17 +71,24 @@ decide membership or mode rules.
so the new mode paints first. The deferral changes only WHEN the plugins move —
they still unload and re-instantiate, and un-persisted internal state is still
lost. What it does change is the undo record, and REAPER's behaviour there is
now OBSERVED, not assumed: the drain's `TrackFX_SetOffline` calls (the only
`TrackFX_SetOffline` in the tree) run outside any undo block, and REAPER mints
ONE implicit undo point per call a small project measured 10 points for one
switch, 1 for the switch itself and 9 for its FX. Ctrl-Z walks them one at a
time, each step re-onlining a single FX, so one Ctrl-Z after a switch does not
bring the plugins back. It also puts the switch point BELOW every FX point: an
undo deep enough to restore the driven flags and roll the `"reasampler"` ext
state back has already re-onlined every FX, so flags, FX and snapshot move
together. The per-FX fragmentation is itself a defect to be closed by collapsing
the drain's writes into one undo point; nothing here depends on them staying
separate. **The idle tick is not the only drain point.** Any path that
now OBSERVED, not assumed. What was measured, before the fix below: 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 — which Ctrl-Z then walked one FX at a time. **The drain
now opens its own undo block** (`fxParkUndoClose`, `UNDO_STATE_FX` only, no other
domain), so one drain is one point and one switch costs two: the switch's, then
the drain's. A drain that wrote no FX state closes with REAPER's discard form
and mints nothing at all, so a plain project open costs no undo point
(`fxParkUndoClose` owns why). The ordering property the observation established is DELIBERATELY PRESERVED: the
drain's block is its own and never the switch's, so the FX point still sits ABOVE
the switch point, and an undo deep enough to restore the driven flags and roll
the `"reasampler"` ext state back has already re-onlined every FX — flags, FX and
snapshot still move together. Folding the FX writes into the switch's own block,
or making them undo-silent, would break that and is not an option here. So ONE
Ctrl-Z after a switch now re-onlines every FX at once, and a second rolls the
flags and the ext state back — two steps rather than the measured ten, and never
one, which is the standing price of the drain being a separate act.
**The idle tick is not the only drain point.** Any path that
serializes the view model drains synchronously first (`persistViewState`,
`render_in_place`), because a save landing between a restore's synchronous flag
writes and its drain would record offline FX beside a model that no longer
@@ -134,7 +141,7 @@ applies the resulting lane state to live tracks.
## Modules
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN`, with per-FX offline deferred to `view_fx_park`), restores from snapshot. 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: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), the idle-tick drain `main.cpp`'s `OnTimer` calls, the per-track park 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, and `trackFlagParm` the ONE `Flag` → REAPER-parameter mapping snapshot's read, park's read and restore's write all address), and the refusal report (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it). **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them.
- `view_fx_park` — the park surface: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), the idle-tick drain `main.cpp`'s `OnTimer` calls and the ONE undo point it collapses its writes into (`fxParkUndoClose` — it opens that block itself, so no caller may drain while holding one), the per-track park 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, and `trackFlagParm` the ONE `Flag` → REAPER-parameter mapping snapshot's read, park's read and restore's write all address), and the refusal report (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it). **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them.
- `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
@@ -163,7 +170,10 @@ applies the resulting lane state to live tracks.
FX enqueue too. Full contract at `discardDeferredFxParks`.
- **Which routes actually reach that no-snapshot-plus-parked-chain pair.** An undo
of a mode switch (Ctrl-Z) is NOT one of them: per the observed undo semantics
above, the flags and the FX come back at the same step. Whether the ext-state
above, the drain's point sits above the switch's, so every FX is back online by
the step that rolls the flags and the ext state back — and the step in between
(FX online, flags still parked) still holds the model's snapshot, which decides
`ParkOnly`, not a refusal. Whether the ext-state
snapshot comes back with them is INFERENCE, not observed, and untested in the
REDO direction: `[verify — DAW]` does a redo that re-parks a track's flags/FX
also restore a `view_state` that is snapshot-free for it (matching a fresh