Merge FX-park snapshot guard: refuse to park when the pre-park truth is gone

A snapshot is never taken from a chain a park has touched, on either
half -- flags or per-FX offline. Stranded tracks are refused, named
once in the console, and left recoverable by hand.
This commit is contained in:
2026-08-05 20:17:00 -04:00
8 changed files with 480 additions and 35 deletions
+59 -6
View File
@@ -30,6 +30,25 @@ decide membership or mode rules.
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
`TrackFX_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 —
`discardDeferredFxParks` drops intents whose flag writes already landed, so a
reloaded model and an already-parked project routinely coexist. A park meeting
that pair REFUSES: no flag write, no FX-offline enqueue, 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 enqueue 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`'s `decidePark`.
- **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
@@ -51,10 +70,18 @@ decide membership or mode rules.
offline/online is enqueued and applied on a later idle tick (`view_fx_park`),
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: the offline writes land outside
the switch's undo block, so the tool no longer re-drives them on an undo or a
redo — what a Ctrl-Z then leaves the chain at is REAPER's own FX-state record,
`[verify — DAW]`. **The idle tick is not the only drain point.** Any path that
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
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
@@ -107,7 +134,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 per-FX offline 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), and the idle-tick drain `main.cpp`'s `OnTimer` calls. **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, 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
@@ -130,7 +157,33 @@ applies the resulting lane state to live tracks.
over the reloaded model, re-planning a park for every inactive leaf, so parked
FX converge on the following drain. The one case that does not self-heal is a
track whose reloaded model carries no snapshot — nothing plans its restore, so
FX left offline stay offline. Full contract at `discardDeferredFxParks`.
FX left offline stay offline, and its next park REFUSES rather than
re-snapshotting the parked chain (snapshot-source invariant above). Stuck, but
never falsely committed — and never finished either, since the refusal skips the
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
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
park), or can it re-park the live track while the model still holds a stale
snapshot? What is confirmed to reach the pair: 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), and 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 reads
identically to all of them and is refused too — no lost state there at all,
which is why the report asserts no cause.
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,
`decidePark` never runs, and a track saved parked stays stuck with no console
line whatsoever.
- `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_fx_park.cpp`) and SWS issue #802 is a
+20 -19
View File
@@ -107,16 +107,6 @@ void applyTransparentLaneDisplay(MediaTrack* tr) {
static_cast<double>(settings | kLaneSettingsHideButtons));
}
const char* flagParm(Flag f) {
switch (f) {
case Flag::ShowInTcp: return "B_SHOWINTCP";
case Flag::ShowInMixer: return "B_SHOWINMIXER";
case Flag::MainSend: return "B_MAINSEND";
case Flag::FxEnable: return "I_FXEN";
}
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
}
// The master track is not enumerated by GetTrack (index space excludes it),
// so it can never enter the tree — the master-untouched invariant holds by
// construction.
@@ -161,11 +151,14 @@ MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
// The FX half is snapshotFxOffline's — only it knows when the live chain has
// stopped being a trustworthy source.
TrackSnapshot snapshotTrack(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelledRestore) {
auto read = [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
};
TrackSnapshot snap;
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
snap.showInTcp = read(Flag::ShowInTcp);
snap.showInMixer = read(Flag::ShowInMixer);
snap.mainSend = read(Flag::MainSend);
snap.fxEnable = read(Flag::FxEnable);
PreParkFx fx = snapshotFxOffline(tr, cancelledRestore);
snap.fxOffline = std::move(fx.states);
@@ -185,7 +178,7 @@ bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags, int& undoMask) {
for (const TrackFlagOp& op : flags) {
if (!writeIfChanged(tr, flagParm(op.flag), static_cast<double>(op.value))) continue;
if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(op.value))) continue;
// I_FXEN is the only FX-domain flag driven here, so it is the only one
// whose undo record has to carry UNDO_STATE_FX.
if (op.flag == Flag::FxEnable) undoMask |= UNDO_STATE_FX;
@@ -449,6 +442,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
int undoMask = kApplyUndoMask;
bool laneModeChanged = false;
std::vector<std::string> refusedParkNames;
Undo_BeginBlock2(proj);
{
@@ -470,13 +464,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune
// decidePark's contract; view_fx_park owns the reads. A refusal is named HERE, not held past Undo_EndBlock2.
const ParkAction action =
decideParkForTrack(tr, model.snapshot(guid) != nullptr, tp.flags);
if (action == ParkAction::Refuse) { refusedParkNames.push_back(trackDisplayName(tr)); continue; }
// Enqueued FIRST: a park landing on this track's own pending restore
// cancels it, and those ops are then the only surviving record of the
// pre-park FX state. Snapshot ONCE — a snapshot already present means
// the track is still parked, so recapturing would overwrite the true
// pre-park state with zeros and a later restore would hide it for good.
// pre-park FX state.
const std::vector<FxOfflineOp> cancelled = deferFxPark(proj, guid);
if (model.snapshot(guid) == nullptr)
if (action == ParkAction::SnapshotThenPark)
model.storeSnapshot(guid, snapshotTrack(tr, cancelled));
applyFlags(tr, tp.flags, undoMask);
}
@@ -539,6 +536,10 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (laneModeChanged) UpdateTimeline();
Undo_EndBlock2(proj, undoLabel.c_str(), undoMask);
// After the undo block and the UI hold: a refusal is a report, not a project
// write, and it must not join what a Ctrl-Z rolls back.
reportRefusedParks(proj, refusedParkNames);
return true;
}
+49
View File
@@ -5,6 +5,7 @@
#include "shell/view/view_fx_park.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <string>
#include <unordered_map>
#include <vector>
@@ -14,7 +15,9 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackName
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
@@ -32,6 +35,10 @@ FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
bool g_draining = false;
// The last refusal report, so an unchanged one is not reprinted on every reapply.
ReaProject* g_reportedOwner = nullptr;
std::vector<std::string> g_reportedRefusals;
// Makes "one drain at a time" explicit rather than implied by the call sites.
// RAII because an apply can throw and a stuck flag would silence the queue for
// the rest of the session.
@@ -97,8 +104,50 @@ std::string fxGuidString(MediaTrack* tr, int fx) {
return std::string(buf);
}
// ANY offline FX, not every — see the snapshot-source invariant (view_fx_park.h,
// src/shell/view/CLAUDE.md) for why.
bool anyFxOffline(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx)
if (TrackFX_GetOffline(tr, fx)) return true;
return false;
}
} // namespace
// GetTrackName, not P_NAME, matching capture::trackName's reason for the same
// choice. The buffer is read bounded — the SDK documents no NUL within
// bufOut_sz. A track the report cannot name still has to appear in it, so the
// read failing yields a placeholder rather than dropping the entry.
std::string trackDisplayName(MediaTrack* tr) {
std::vector<char> buf(1024, '\0');
if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast<int>(buf.size())))
return "(name unavailable)";
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
}
ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot,
const std::vector<TrackFlagOp>& parkFlags) {
if (haveSnapshot) return decidePark(true, false); // second term unread — skip the reads
const bool flagsRead = parkFlagsAlreadyApplied(parkFlags, [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
});
return decidePark(false, chainReadsParked(flagsRead, anyFxOffline(tr)));
}
void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& refusedNames) {
ReaProject* const owner = proj ? proj : currentProject();
if (!shouldReport(owner == g_reportedOwner, g_reportedRefusals, refusedNames)) return;
g_reportedOwner = owner;
g_reportedRefusals = refusedNames;
const std::string msg = describeRefusedParks(refusedNames);
// "!SHOW:" so an unattended project-load reapply cannot force the console open
// (reaper_plugin_functions.h:6536), same as the drain's drop report.
if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
}
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
+120 -4
View File
@@ -1,13 +1,16 @@
#pragma once
// Design View's per-FX offline surface: the FX-identity read that snapshot,
// park and restore all address FX through, the deferred intent queue that keeps
// TrackFX_SetOffline off the mode switch's synchronous path, and the idle-tick
// drain that applies it. See src/shell/view/CLAUDE.md's FX-parking caveat.
// Design View's park surface: the FX-identity read that snapshot, park and
// restore all address FX through, the deferred intent queue that keeps
// TrackFX_SetOffline off the mode switch's synchronous path, the idle-tick drain
// that applies it, and the trust test deciding whether a chain may be
// snapshotted at all. See src/shell/view/CLAUDE.md's FX-parking caveat.
#include <cstddef>
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/view_mode_model.h" // Flag / TrackFlagOp — what a park writes
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
class MediaTrack;
@@ -103,6 +106,119 @@ private:
std::vector<FxParkIntent> pending_;
};
// -- may this chain be snapshotted? -------------------------------------------
//
// A pre-park snapshot may only ever be taken from a chain NO park has touched.
// Its values are restore's sole source of truth, so a snapshot lifted from an
// already-parked chain records park state as the user's state, and every later
// restore then faithfully writes hidden/out-of-mix/FX-disabled back — permanent
// and silent. A model carrying no snapshot is NOT evidence the chain is clean:
// discardDeferredFxParks drops intents whose flag writes already landed, so a
// reloaded model and an already-parked project routinely coexist.
//
// A snapshot has TWO halves and the test covers both, disjunctively: the flags
// can read clean while the FX are still offline (a hand recovery that stopped at
// the flags, a reload) and vice versa. Either half reading parked poisons the
// whole snapshot, so either half alone refuses.
// The REAPER track parameter each driven flag addresses. One home for the
// mapping — park's live read, restore's write and the trust test below must
// never disagree about which parameter a Flag names.
inline const char* trackFlagParm(Flag f) {
switch (f) {
case Flag::ShowInTcp: return "B_SHOWINTCP";
case Flag::ShowInMixer: return "B_SHOWINMIXER";
case Flag::MainSend: return "B_MAINSEND";
case Flag::FxEnable: return "I_FXEN";
}
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
}
// Does the track already sit at every value this park would write? Then a park
// reached it. `readFlag` answers one Flag's live value; templating on it lets the
// live path compare as it reads (no per-track vector) while the tests drive the
// same fold from a table, keyed by Flag rather than by plan order. An empty plan
// is evidence of nothing and answers false.
template <class ReadFlag>
bool parkFlagsAlreadyApplied(const std::vector<TrackFlagOp>& parkFlags, ReadFlag readFlag) {
if (parkFlags.empty()) return false;
for (const TrackFlagOp& op : parkFlags)
if (readFlag(op.flag) != op.value) return false;
return true;
}
// DISJUNCTIVE fold — rationale is the snapshot-source invariant above (also
// src/shell/view/CLAUDE.md).
inline bool chainReadsParked(bool parkFlagsRead, bool anyFxOffline) {
return parkFlagsRead || anyFxOffline;
}
enum class ParkAction {
SnapshotThenPark, // clean chain — capture the pre-park truth, then park
ParkOnly, // still parked; the held snapshot IS the pre-park truth
Refuse, // no snapshot AND a parked chain — the truth is gone
};
// Refusing leaves the track exactly as found: no flag write, no FX-offline
// enqueue, no snapshot. Nothing false is committed, so the moment the user's own
// edit puts the chain back somewhere trustworthy the next park captures it
// correctly — where a snapshot of the parked state would have closed that door
// for good.
inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) {
if (haveSnapshot) return ParkAction::ParkOnly;
return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark;
}
// `decidePark` over the live track: the four driven flags AND the FX chain, which
// a snapshot taken here would record both halves of. Skips the reads on the
// ParkOnly path, where decidePark ignores the second term anyway.
ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot,
const std::vector<TrackFlagOp>& parkFlags);
// "Track N" for an unnamed track (GetTrackName, matching capture::trackName's
// choice of API for the same reason). Exported so a caller holding the handle
// only briefly (view.cpp's park loop, before Undo_EndBlock2/TrackList_AdjustWindows
// invalidate it) can capture the name at resolve time rather than the handle.
std::string trackDisplayName(MediaTrack* tr);
// The console report for the tracks one apply refused, empty when none. NAMES
// them: a refused track is hidden from both the TCP and the mixer, so a count
// alone points at nothing the user can go and find. It asserts no cause — the
// pair it detects has several routes (src/shell/view/CLAUDE.md), and only some of
// them are anyone's mistake.
inline std::string describeRefusedParks(const std::vector<std::string>& trackNames) {
if (trackNames.empty()) return {};
std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" +
(trackNames.size() == 1 ? "" : "s") +
" left unparked -- no stored pre-park state, and a live state that "
"already reads as parked; parking now would record that as your own "
"state.\n";
for (const std::string& name : trackNames) msg += " " + name + "\n";
msg += "To bring a track back under mode control: restore its TCP and mixer visibility, "
"its main send and its FX enable, AND set every FX in its chain online -- FX enable "
"is the chain bypass and does not bring an individually offlined FX back. The next "
"mode switch will then park it correctly.\n";
return msg;
}
// Pure gate behind reportRefusedParks' one-per-changed-set memo: prints (i.e.
// updates the memo) on any change of owner or of the named set, stays silent on
// an exact repeat. An empty `now` still counts as a change from a non-empty
// `last` — that update itself prints nothing (describeRefusedParks({}) is
// empty) but resets the memo so a later refusal is never mistaken for a repeat
// of one that already healed.
inline bool shouldReport(bool sameOwnerAsLast, const std::vector<std::string>& last,
const std::vector<std::string>& now) {
return !(sameOwnerAsLast && last == now);
}
// Prints describeRefusedParks for `refusedNames` (captured by the caller while its
// track handles are still fresh — see view.cpp's park loop), ONCE per changed set
// per project: the same refusal recurs on every reapply — every project open,
// every tag/untag — and repeating it verbatim is noise the user cannot act on any
// better the second time. Gated by shouldReport.
void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& refusedNames);
// The FX half of a snapshot, with the keying it must be read back under.
struct PreParkFx {
std::vector<FxOfflineState> states;