Defer Design View's per-FX park to an idle tick; honest undo mask, compare-before-write, PreventUIRefresh bracket, O(1) handle resolve

This commit is contained in:
2026-08-03 12:39:22 -04:00
parent 0eb2c67875
commit a4a1c3860f
8 changed files with 568 additions and 170 deletions
+8 -6
View File
@@ -868,9 +868,11 @@ the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order. together, which is the riskier order.
**Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures **Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures
**642 lines** (verified this pass). Its seam is blocked not by a private-state/friend **625 lines** (re-measured after the deferred FX-park split took `fxGuidString`,
question but by a build file another team owns: `src/shell/view/` has no `liveFxGuids` and the park/restore FX writes out into `view_fx_park`). Its remaining
`CMakeLists.txt` of its own today. seam is blocked not by a private-state/friend question but by a build file another
team owns: `src/shell/view/` has no `CMakeLists.txt` of its own today — a new TU
there costs one `target_sources` line in `src/app/CMakeLists.txt` instead.
**Priority / risk.** Not stated. **Priority / risk.** Not stated.
@@ -881,8 +883,8 @@ unblocked once the build-file ownership question is resolved.
## FX-GUID stability for `restoreFxOffline` is unverified in the DAW ## FX-GUID stability for `restoreFxOffline` is unverified in the DAW
**Context.** The Design View park/restore FX keying (`restoreFxOffline`, **Context.** The Design View park/restore FX keying (`applyRestore`,
`src/shell/view/view.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that `src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
survives a chain reorder while a track is parked. SWS issue #802 reports that after survives a chain reorder while a track is parked. SWS issue #802 reports that after
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin `SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
(`SNM_PreObjectState()``RemoveAllIds()`) — if that still holds, an SWS-driven (`SNM_PreObjectState()``RemoveAllIds()`) — if that still holds, an SWS-driven
@@ -893,7 +895,7 @@ operation this keying targets.
instances of the same plugin. instances of the same plugin.
**Already flagged in code — this entry is the tracked home, not a restatement.** **Already flagged in code — this entry is the tracked home, not a restatement.**
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view.cpp` and There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
them in full. them in full.
+7
View File
@@ -38,6 +38,7 @@ add_library(reaper_reasampler MODULE
${LICE_SRC} ${LICE_SRC}
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
${REASAMPLER_SRC_DIR}/shell/view/view.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp ${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
@@ -59,6 +60,12 @@ target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model
# link graph free of the voice engine a link edge to it here means the design drifted. # link graph free of the voice engine a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER
# types), so it is CTest-covered like a core/ module. Declared here rather than in a
# src/shell/view/CMakeLists.txt because that directory deliberately has none its
# TUs are compiled into this target directly.
reasampler_test(view_fx_park LINK fx_offline)
# Bank-package import: the promptless verb plus its action skin. Kept as its own # Bank-package import: the promptless verb plus its action skin. Kept as its own
# appended block rather than merged into the lists above, so the two package # appended block rather than merged into the lists above, so the two package
# directions stay textually independent. # directions stay textually independent.
+7
View File
@@ -38,6 +38,7 @@
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession #include "shell/persist/session.h" // ReaSamplerSession
#include "shell/view/view.h" // reconcileManagedLanes / applyMode #include "shell/view/view.h" // reconcileManagedLanes / applyMode
#include "shell/view/view_fx_park.h" // the mode switch's deferred FX park
namespace capture = reasampler::capture; namespace capture = reasampler::capture;
@@ -172,6 +173,12 @@ static void OnTimer()
g_session.poll(); g_session.poll();
// A mode switch applies its visibility/routing writes synchronously and leaves
// the per-FX offline work here, so the new mode paints before the plugins
// unload. Drained AFTER poll() so a project switch discards the queue instead
// of applying it to the project that replaced it; idle cost is one empty test.
reasampler::drainDeferredFxParks(g_session.view());
// persist stays MODEL-ONLY (loads the saved view model but does not apply // persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a // visibility, to avoid coupling persist to the view shell); poll() raises a
// one-shot load signal that we drain here to reapply the SAVED active mode so a // one-shot load signal that we drain here to reapply the SAVED active mode so a
+13 -5
View File
@@ -45,9 +45,16 @@ decide membership or mode rules.
mode ids — no absolute paths, no index positions). mode ids — no absolute paths, no index positions).
- **Documented caveat:** offlined FX re-instantiate when a track returns to the - **Documented caveat:** offlined FX re-instantiate when a track returns to the
active mode — stateful plugins (convolution, loaded samplers, tail-holding active mode — stateful plugins (convolution, loaded samplers, tail-holding
effects) re-initialize on return (possible load hitch, un-persisted internal effects) re-initialize on return (load hitch, un-persisted internal state lost).
state lost). Accepted cost of the CPU reclaim; surfaced at the toggle affordance Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip).
(tooltip). **The hitch no longer sits on the switch's synchronous path:** per-FX
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]`.
- **Show-both semantics:** a per-track "pin visible across modes" flag re-enables - **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 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 and is never parked — its driven flags stay at snapshot/restored values, FX
@@ -85,7 +92,8 @@ applies the resulting lane state to live tracks.
## Modules ## 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` + per-FX offline), 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` — 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 is also where a consumed snapshot is cleared — not where the restore was planned — so a second switch arriving before the first drained re-parks against the still-true captured state instead of re-capturing parked values.
- `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`. - `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 ## Gotchas
@@ -105,7 +113,7 @@ applies the resulting lane state to live tracks.
the solo cache inherits it rather than introducing it. Not fixed here. the solo cache inherits it rather than introducing it. Not fixed here.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view.cpp`) and SWS issue #802 is a `[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
the FXID lines behind on reorder rather than moving them with the plugin. If 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 confirmed, an SWS-driven reorder of a parked track's chain — not a native
+142 -159
View File
@@ -10,6 +10,7 @@
#include <optional> #include <optional>
#include <set> #include <set>
#include <string> #include <string>
#include <unordered_map>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -18,6 +19,7 @@
#include "core/view/lane_keys.h" #include "core/view/lane_keys.h"
#include "core/view/solo_cache.h" #include "core/view/solo_cache.h"
#include "shell/capture/track_guid.h" #include "shell/capture/track_guid.h"
#include "shell/view/view_fx_park.h"
#include "shell/view/view_solo.h" #include "shell/view/view_solo.h"
#include "core/view/view_tree.h" #include "core/view/view_tree.h"
@@ -28,12 +30,8 @@
#define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String #define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_PreventUIRefresh
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline #define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_TrackList_AdjustWindows
@@ -64,6 +62,26 @@ constexpr int kFreeModeFixedLanes = 2;
// deliberately excluded, nothing is moving there. // deliberately excluded, nothing is moving there.
constexpr int kTransportMoving = 1 | 4; constexpr int kTransportMoving = 1 | 4;
// The domains one apply actually writes: track config/routing (visibility,
// B_MAINSEND, I_SOLO), item lane assignment, and the ext-state block
// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which
// includes UNDO_STATE_FX and so makes every undo record carry every track's FX
// state — a cost that scales with the project's plugin count rather than with
// what the switch changed. FX is OR'd back in per apply, only when an FX-domain
// flag really moved.
constexpr int kApplyUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG;
// Holds REAPER's UI refresh off for the write phase; the deliberate rebuild
// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because
// an unbalanced pair leaves the user's UI frozen with no way back — the SDK's
// own warning at reaper_plugin_functions.h:5581.
struct UiRefreshHold {
UiRefreshHold() { PreventUIRefresh(1); }
~UiRefreshHold() { PreventUIRefresh(-1); }
UiRefreshHold(const UiRefreshHold&) = delete;
UiRefreshHold& operator=(const UiRefreshHold&) = delete;
};
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane // C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist // track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
// but displays as non-fixed-lane). // but displays as non-fixed-lane).
@@ -102,7 +120,7 @@ const char* flagParm(Flag f) {
// construction. Also caches each MediaTrack* by GUID for later resolve(). // construction. Also caches each MediaTrack* by GUID for later resolve().
std::vector<TrackFolderEntry> readFolderEntries( std::vector<TrackFolderEntry> readFolderEntries(
ReaProject* proj, ReaProject* proj,
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) { TrackHandles& handleByGuid) {
std::vector<TrackFolderEntry> entries; std::vector<TrackFolderEntry> entries;
int count = CountTracks(proj); int count = CountTracks(proj);
entries.reserve(static_cast<std::size_t>(count)); entries.reserve(static_cast<std::size_t>(count));
@@ -119,46 +137,21 @@ std::vector<TrackFolderEntry> readFolderEntries(
return entries; return entries;
} }
MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid, // Every park, restore, parent and lane op resolves its GUID; a linear scan made
const std::string& guid) { // that O(T²) on the one path whose cost the user waits on. The vector form
for (const auto& kv : handleByGuid) { // survives beside it because view_solo's writers take one pass over it.
if (kv.first == guid) return kv.second; using TrackByGuid = std::unordered_map<std::string, MediaTrack*>;
}
return nullptr; // stale/deleted GUID — pruned by being skipped TrackByGuid indexHandles(const TrackHandles& handleByGuid) {
TrackByGuid byGuid;
byGuid.reserve(handleByGuid.size());
for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second);
return byGuid;
} }
// The FX's own durable identity, braced exactly like the track GUID keys. Empty MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
// when REAPER reports none — an FX we cannot name is one we cannot restore, and auto it = byGuid.find(guid);
// fx_offline treats it that way rather than guessing at its slot. Lifetime is return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped
// settled: the string copy is taken immediately and the GUID* is never held
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
// through fx_offline on that path specifically), a save/reload round trip, and
// two live instances of one plugin type staying distinguishable. See
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
GUID* g = TrackFX_GetFXGUID(tr, fx);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
// The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
// whatever it covers, all three cover identically.
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
return guids;
} }
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO; // Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
@@ -179,30 +172,25 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) {
return snap; // fxKeying stays Identity — a live capture always knows the chain return snap; // fxKeying stays Identity — a live capture always knows the chain
} }
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) { // A reapply (tag/untag, project load) re-plans every flag it already applied, so
// most of what it writes is a value the track already holds; the read is the
// cheap half of the pair, and it also keeps those no-op writes out of the undo
// record's mask.
bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
SetMediaTrackInfo_Value(tr, parm, value);
return true;
}
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags, int& undoMask) {
for (const TrackFlagOp& op : flags) { for (const TrackFlagOp& op : flags) {
SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value)); if (!writeIfChanged(tr, flagParm(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;
} }
} }
// The pure park plan leaves fxOffline empty by design; expand it here from the
// live FX count.
void parkFxOffline(MediaTrack* tr) {
int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx) {
TrackFX_SetOffline(tr, fx, true);
}
}
// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
return res.drops;
}
// Managed-lane application: the pure planner keys LanePlayOps by the lane's // Managed-lane application: the pure planner keys LanePlayOps by the lane's
// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which // DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which
// renumbers on reorder. So every write here re-resolves durable key -> current // renumbers on reorder. So every write here re-resolves durable key -> current
@@ -238,7 +226,7 @@ std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
char parm[32]; char parm[32];
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays)); writeIfChanged(tr, parm, static_cast<double>(lanePlays));
} }
// Groups ops by track, reconciles each op's durable laneKey to the track's // Groups ops by track, reconciles each op's durable laneKey to the track's
@@ -246,7 +234,7 @@ void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
// enables fixed-lane mode on any track carrying a managed lane, and drives // enables fixed-lane mode on any track carrying a managed lane, and drives
// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true // C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true
// (SDK: required after an I_FREEMODE change). // (SDK: required after an I_FREEMODE change).
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid, bool applyLaneOps(const TrackByGuid& handleByGuid,
const std::vector<LanePlayOp>& lanes) { const std::vector<LanePlayOp>& lanes) {
if (lanes.empty()) return false; if (lanes.empty()) return false;
@@ -312,7 +300,7 @@ std::string itemModeFromMembership(const ViewModeModel& model, const std::string
// there regardless of name). // there regardless of name).
std::vector<LaneTrack> readLaneTracks( std::vector<LaneTrack> readLaneTracks(
const ViewModeModel& model, const ViewModeModel& model,
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) { const TrackHandles& handleByGuid) {
std::vector<LaneTrack> tracks; std::vector<LaneTrack> tracks;
tracks.reserve(handleByGuid.size()); tracks.reserve(handleByGuid.size());
for (const auto& [guid, tr] : handleByGuid) { for (const auto& [guid, tr] : handleByGuid) {
@@ -358,7 +346,7 @@ bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, // managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk,
// so a user's existing manual lanes are never renamed or reassigned. // so a user's existing manual lanes are never renamed or reassigned.
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) { const TrackByGuid& handleByGuid) {
bool changed = false; bool changed = false;
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack; std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
@@ -436,8 +424,9 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
return false; // same fail-closed shape as the mode-exists guard above return false; // same fail-closed shape as the mode-exists guard above
} }
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid; TrackHandles handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid); std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
FolderTree tree = buildFolderTree(entries); FolderTree tree = buildFolderTree(entries);
// Prune snapshots for tracks no longer in the live enumeration before // Prune snapshots for tracks no longer in the live enumeration before
@@ -455,81 +444,6 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
TogglePlan plan = model.planToggle(tree, targetModeId); TogglePlan plan = model.planToggle(tree, targetModeId);
Undo_BeginBlock2(proj);
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
// ride the existing undo block — one mode toggle stays one Ctrl-Z.
if (realSwitch) {
model.soloCache().store(outgoingModeId, outgoingSolo);
clearTrackSolos(handleByGuid, outgoingSolo);
}
// PARK: snapshot before mutating, store into the model, then apply.
for (const TrackPlan& tp : plan.park) {
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune
// Snapshot ONCE, at first park: a snapshot already present means the
// track is still parked from a prior apply, so its live flags are the
// parked values — recapturing would overwrite the true pre-park state
// with zeros and a later restore would hide it for good. Restore
// clears the snapshot, so the next genuine park recaptures fresh state.
if (model.snapshot(guid) == nullptr)
model.storeSnapshot(guid, snapshotTrack(tr));
applyFlags(tr, tp.flags);
parkFxOffline(tr);
}
// RESTORE: apply verbatim, then drop the consumed snapshot.
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune
applyFlags(tr, tp.flags);
const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
if (drops.total() > 0) {
fxDrops.add(drops);
++fxDropTracks;
}
model.clearSnapshot(guid);
}
// Captured FX state that could not be applied is REPORTED. Silence here would
// read to the user as "restore worked" while an FX sat at whatever state the
// park left it in. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
// so it never force-opens the console window: applyMode's reapply path also
// runs unattended on project load (see the reconcile comment above), and this
// one call site can't tell that case apart from an interactive toggle/tag-edit
// reapply — both call in with target == active — so splitting loud-on-toggle
// from quiet-on-load would need a flag threaded from every caller, several of
// which are outside this change. Quiet-always is the safe default: the message
// still lands in the console for whoever opens it, on every path.
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
// and every other managed lane is silenced+hidden. Empty for a D1-only
// project, leaving that behavior byte-identical.
const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);
// PARENT VISIBILITY (never parked): recomputed every toggle, never
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
for (const FolderNode& node : tree.nodes) {
if (!node.isParent) continue;
MediaTrack* tr = resolve(handleByGuid, node.guid);
if (!tr) continue; // stale GUID — prune
double show = visible.count(node.guid) ? 1.0 : 0.0;
SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show);
SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show);
}
// Target is guaranteed registered (checked at entry); fall back to the id // Target is guaranteed registered (checked at entry); fall back to the id
// defensively if that ever changes. // defensively if that ever changes.
const Mode* targetMode = model.modes().query(targetModeId); const Mode* targetMode = model.modes().query(targetModeId);
@@ -537,16 +451,81 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
"ReaSampler: activate " + "ReaSampler: activate " +
(targetMode ? targetMode->displayName : targetModeId) + " view"; (targetMode ? targetMode->displayName : targetModeId) + " view";
model.setActiveMode(targetModeId); int undoMask = kApplyUndoMask;
bool laneModeChanged = false;
// Replay + consume, before the single relayout below picks the change up. Dropped Undo_BeginBlock2(proj);
// against `visible` (computed above for parent visibility), not the park plan: a {
// folder parent can be hidden without being parked, and a hidden track must not UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen
// receive a replayed solo it carries no visible control to undo.
if (realSwitch) { // DISJOIN THE SOLO SURFACES. Both this clear and the replay after
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) { // setActiveMode ride the existing undo block — one mode toggle stays one Ctrl-Z.
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); if (realSwitch) {
model.soloCache().clear(targetModeId); model.soloCache().store(outgoingModeId, outgoingSolo);
clearTrackSolos(handleByGuid, outgoingSolo);
}
// PARK: snapshot before mutating, store into the model, then apply. The
// per-FX offline half is deferred (view_fx_park) — it is the expensive
// half and nothing about the new mode's appearance waits on it.
for (const TrackPlan& tp : plan.park) {
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune
// Snapshot ONCE, at first park: a snapshot already present means the
// track is still parked from a prior apply (or its deferred restore
// has not landed yet), so its live flags are the parked values —
// recapturing would overwrite the true pre-park state with zeros and
// a later restore would hide it for good.
if (model.snapshot(guid) == nullptr)
model.storeSnapshot(guid, snapshotTrack(tr));
applyFlags(tr, tp.flags, undoMask);
deferFxPark(proj, guid);
}
// RESTORE: flags verbatim now, per-FX offline on the drain, which is also
// where the consumed snapshot is dropped.
for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune
applyFlags(tr, tp.flags, undoMask);
deferFxRestore(proj, guid, tp.fxOffline);
}
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
// and every other managed lane is silenced+hidden. Empty for a D1-only
// project, leaving that behavior byte-identical.
laneModeChanged = applyLaneOps(trackByGuid, plan.lanes);
// PARENT VISIBILITY (never parked): recomputed every toggle, never
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
for (const FolderNode& node : tree.nodes) {
if (!node.isParent) continue;
MediaTrack* tr = resolve(trackByGuid, node.guid);
if (!tr) continue; // stale GUID — prune
const double show = visible.count(node.guid) ? 1.0 : 0.0;
writeIfChanged(tr, "B_SHOWINTCP", show);
writeIfChanged(tr, "B_SHOWINMIXER", show);
}
model.setActiveMode(targetModeId);
// Replay + consume, before the single relayout below picks the change up.
// Dropped against `visible` (computed above for parent visibility), not the
// park plan: a folder parent can be hidden without being parked, and a
// hidden track must not receive a replayed solo it carries no visible
// control to undo.
if (realSwitch) {
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
model.soloCache().clear(targetModeId);
}
} }
} }
@@ -560,12 +539,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// fixed lanes this apply (SDK requirement for I_FREEMODE changes). // fixed lanes this apply (SDK requirement for I_FREEMODE changes).
if (laneModeChanged) UpdateTimeline(); if (laneModeChanged) UpdateTimeline();
Undo_EndBlock2(proj, undoLabel.c_str(), -1); Undo_EndBlock2(proj, undoLabel.c_str(), undoMask);
return true; return true;
} }
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid; TrackHandles handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid); std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
// The tree is needed to detect a content-bearing folder derived-visible in // The tree is needed to detect a content-bearing folder derived-visible in
// >1 mode, exactly as applyMode builds it. // >1 mode, exactly as applyMode builds it.
@@ -575,8 +554,10 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
const LaneMintPlan plan = planLaneMinting(model, tree, tracks); const LaneMintPlan plan = planLaneMinting(model, tree, tracks);
if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
Undo_BeginBlock2(proj); Undo_BeginBlock2(proj);
const bool changed = applyMintPlan(model, plan, handleByGuid); const bool changed = applyMintPlan(model, plan, trackByGuid);
if (!changed) { if (!changed) {
// Plan was non-empty but every write was already satisfied — discard // Plan was non-empty but every write was already satisfied — discard
@@ -599,17 +580,19 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
// here — it would re-park/restore whole tracks and recompute parent // here — it would re-park/restore whole tracks and recompute parent
// visibility, which a lane-only mint must not touch. // visibility, which a lane-only mint must not touch.
const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId()); const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
applyLaneOps(handleByGuid, togglePlan.lanes); applyLaneOps(trackByGuid, togglePlan.lanes);
UpdateTimeline(); // a split happened this call — refresh is owed UpdateTimeline(); // a split happened this call — refresh is owed
UpdateArrange(); UpdateArrange();
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1); // Lane minting writes track lane config and item lane assignment and nothing
// else — never an FX state — so it takes the same honest mask applyMode does.
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kApplyUndoMask);
return true; return true;
} }
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid; TrackHandles handleByGuid;
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
// Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/
+157
View File
@@ -0,0 +1,157 @@
// See view_fx_park.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
// The queue's rule is pure (header, test_view_fx_park.cpp); this file owns the
// REAPER reads/writes and the ordering against the live enumeration.
#include "shell/view/view_fx_park.h"
#include <string>
#include <unordered_map>
#include <vector>
#include "core/view/view_mode_model.h"
#include "shell/capture/track_guid.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); }
void adoptOwner(ReaProject* proj) {
ReaProject* p = proj ? proj : currentProject();
if (p == g_owner) return;
g_queue.clear(); // intents planned against another project are never replayed here
g_owner = p;
}
// TrackFX_SetOffline unloads and re-instantiates the plugin, so writing a state
// that already holds is not a no-op — it is the expensive half of a mode switch
// spent on nothing.
void setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) {
if (TrackFX_GetOffline(tr, fx) == offline) return;
TrackFX_SetOffline(tr, fx, offline);
}
void applyPark(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx) setOfflineIfChanged(tr, fx, true);
}
// Restores per-FX offline from the plan verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops applyRestore(MediaTrack* tr, const std::vector<FxOfflineOp>& ops) {
const FxRestoreResolution res = resolveFxRestore(ops, liveFxGuids(tr));
for (const FxOfflineWrite& w : res.writes) setOfflineIfChanged(tr, w.fxIndex, w.offline);
return res.drops;
}
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
// settled: the string copy is taken immediately and the GUID* is never held
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
// through fx_offline on that path specifically), a save/reload round trip, and
// two live instances of one plugin type staying distinguishable. See
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
GUID* g = TrackFX_GetFXGUID(tr, fx);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
} // namespace
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
return guids;
}
void deferFxPark(ReaProject* proj, const std::string& guid) {
adoptOwner(proj);
g_queue.park(guid);
}
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops) {
adoptOwner(proj);
g_queue.restore(guid, std::move(ops));
}
void drainDeferredFxParks(ViewModeModel& model) {
if (g_queue.empty()) return;
if (g_owner != currentProject()) {
// The project the intents were planned against was closed or switched
// away from: its tracks are not ours to write and its handles may be
// gone. Discard rather than apply.
g_queue.clear();
g_owner = nullptr;
return;
}
std::unordered_map<std::string, MediaTrack*> byGuid;
const int count = CountTracks(g_owner);
byGuid.reserve(static_cast<std::size_t>(count));
for (int i = 0; i < count; ++i) {
MediaTrack* tr = GetTrack(g_owner, i);
if (!tr) continue;
std::string guid = guidString(tr);
if (!guid.empty()) byGuid.emplace(std::move(guid), tr);
}
FxRestoreDrops drops;
int dropTracks = 0;
for (const FxParkIntent& intent : g_queue.pending()) {
auto it = byGuid.find(intent.guid);
if (it == byGuid.end()) continue; // track deleted since the switch — prune
if (intent.park) {
applyPark(it->second);
continue;
}
const FxRestoreDrops d = applyRestore(it->second, intent.restoreOps);
if (d.total() > 0) {
drops.add(d);
++dropTracks;
}
model.clearSnapshot(intent.guid);
}
g_queue.clear();
// Captured FX state that could not be applied is REPORTED — silence would read
// to the user as "restore worked" while an FX sat at whatever state the park
// left it in. The "!SHOW:" prefix (reaper_plugin_functions.h:6536) keeps it from
// force-opening the console: this drain also runs behind an unattended
// project-load reapply, and cannot tell that case from an interactive toggle.
const std::string fxDropMsg = describeFxRestoreDrops(drops, dropTracks);
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
}
} // namespace reasampler
+95
View File
@@ -0,0 +1,95 @@
#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.
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
class MediaTrack;
class ReaProject;
namespace reasampler {
class ViewModeModel;
// The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
// whatever it covers, all three cover identically.
std::vector<std::string> liveFxGuids(MediaTrack* tr);
// One deferred per-FX intent for one track. A park carries no ops (every live
// slot goes offline); a restore carries the planned ops verbatim.
struct FxParkIntent {
std::string guid;
bool park = false;
std::vector<FxOfflineOp> restoreOps;
};
// The queue's re-entrancy rule, pure so it can be asserted without a DAW: at
// most ONE intent per track GUID, and the latest one wins. Park and restore are
// inverses, so an intent landing on its own pending inverse CANCELS it rather
// than stacking — the queued work never ran, so the track already holds the
// state the newcomer asks for, and replaying both would be both slower and
// observably wrong.
class FxParkQueue {
public:
void park(const std::string& guid) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, true, {}});
} else if (!held->park) {
erase(held);
}
}
void restore(const std::string& guid, std::vector<FxOfflineOp> ops) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, false, std::move(ops)});
} else if (held->park) {
erase(held);
} else {
held->restoreOps = std::move(ops);
}
}
// Enqueue order, which is apply order: park before restore within one
// switch, as the synchronous body already orders them.
const std::vector<FxParkIntent>& pending() const { return pending_; }
bool empty() const { return pending_.empty(); }
void clear() { pending_.clear(); }
private:
FxParkIntent* find(const std::string& guid) {
for (FxParkIntent& i : pending_)
if (i.guid == guid) return &i;
return nullptr;
}
void erase(FxParkIntent* held) {
pending_.erase(pending_.begin() + (held - pending_.data()));
}
std::vector<FxParkIntent> pending_;
};
// Enqueue against `proj` (nullptr = current project). An enqueue naming a
// different project than the pending intents discards those unapplied.
void deferFxPark(ReaProject* proj, const std::string& guid);
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops);
// Applies every pending intent, then clears each drained restore's snapshot
// from `model` — the snapshot is consumed when the restore actually lands, not
// when it was planned, so a second switch arriving first re-parks against the
// still-true captured state instead of re-capturing parked values. Discards the
// queue unapplied if the project it was enqueued against is no longer current
// (close / switch); an intent whose track is gone is pruned. Idle cost is one
// empty-queue test.
void drainDeferredFxParks(ViewModeModel& model);
} // namespace reasampler
+139
View File
@@ -0,0 +1,139 @@
// Standalone tests for the deferred FX-park queue's re-entrancy rule — no
// REAPER, no test framework.
//
// The property under test: a mode switch leaves its per-FX offline work here, so
// a second switch arriving before the first drained must leave every track in the
// state the SECOND switch specifies — never the first's, never both replayed.
#include "../src/shell/view/view_fx_park.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- helpers -----------------------------------------------------------------
// The ops applyMode hands a restore: one per FX captured in the track's snapshot.
static std::vector<FxOfflineOp> ops(const std::string& fxGuid, bool offline) {
return {FxOfflineOp{"{TRACK}", FxKeying::Identity, fxGuid, 0, offline}};
}
static const FxParkIntent* intentFor(const FxParkQueue& q, const std::string& guid) {
for (const FxParkIntent& i : q.pending())
if (i.guid == guid) return &i;
return nullptr;
}
// -- tests -------------------------------------------------------------------
static void testParkEnqueuesOneIntentCarryingNoOps() {
FxParkQueue q;
q.park("{A}");
CHECK(q.pending().size() == 1);
const FxParkIntent* held = intentFor(q, "{A}");
CHECK(held != nullptr);
CHECK(held && held->park);
CHECK(held && held->restoreOps.empty());
}
static void testRestoreOnAnUndrainedParkCancelsRatherThanStacks() {
// The park never ran, so the track's FX still hold their captured state —
// exactly what the restore would write. Replaying both would unload every
// plugin only to reload it.
FxParkQueue q;
q.park("{A}");
q.restore("{A}", ops("{FX}", false));
CHECK(q.empty());
}
static void testParkOnAnUndrainedRestoreCancelsRatherThanStacks() {
// The mirror case: the restore never ran, so the FX are still parked offline,
// which is where the new park wants them.
FxParkQueue q;
q.restore("{A}", ops("{FX}", false));
q.park("{A}");
CHECK(q.empty());
}
static void testRepeatedParkStaysOneIntent() {
FxParkQueue q;
q.park("{A}");
q.park("{A}");
q.park("{A}");
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
}
static void testLaterRestoreReplacesTheEarlierOnesOps() {
FxParkQueue q;
q.restore("{A}", ops("{OLD}", false));
q.restore("{A}", ops("{NEW}", true));
CHECK(q.pending().size() == 1);
const FxParkIntent* held = intentFor(q, "{A}");
CHECK(held && !held->park);
CHECK(held && held->restoreOps.size() == 1);
CHECK(held && held->restoreOps.front().fxGuid == "{NEW}");
CHECK(held && held->restoreOps.front().offline);
}
static void testOneTracksCancelLeavesEveryOtherTrackAlone() {
FxParkQueue q;
q.park("{A}");
q.park("{B}");
q.park("{C}");
q.restore("{B}", ops("{FX}", false)); // cancels B only
CHECK(q.pending().size() == 2);
CHECK(intentFor(q, "{A}") != nullptr);
CHECK(intentFor(q, "{B}") == nullptr);
CHECK(intentFor(q, "{C}") != nullptr);
// Order survives the middle erase: the drain applies in enqueue order.
CHECK(q.pending()[0].guid == "{A}");
CHECK(q.pending()[1].guid == "{C}");
}
static void testCancelledTrackCanBeQueuedAgain() {
// Two rapid switches then a third: the third is the one that must land.
FxParkQueue q;
q.park("{A}");
q.restore("{A}", ops("{FX}", false));
q.park("{A}");
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
}
static void testClearDropsEverythingPending() {
FxParkQueue q;
q.park("{A}");
q.restore("{B}", ops("{FX}", true));
q.clear();
CHECK(q.empty());
CHECK(q.pending().empty());
}
int main() {
testParkEnqueuesOneIntentCarryingNoOps();
testRestoreOnAnUndrainedParkCancelsRatherThanStacks();
testParkOnAnUndrainedRestoreCancelsRatherThanStacks();
testRepeatedParkStaysOneIntent();
testLaterRestoreReplacesTheEarlierOnesOps();
testOneTracksCancelLeavesEveryOtherTrackAlone();
testCancelledTrackCanBeQueuedAgain();
testClearDropsEverythingPending();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}