diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 4a8cb89..217f589 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -39,6 +39,7 @@ add_library(reaper_reasampler MODULE ${LICE_SRC} ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp + ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_reconcile.cpp @@ -72,6 +73,9 @@ target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # has none — its TUs are compiled into this target directly. reasampler_test(view_park LINK view_mode_model) reasampler_test(view_reconcile LINK applied_state view_mode_model) +# The reclaimer's drain loop is templated on its host for exactly this reason: the +# ordering, budget, debounce and resume bookkeeping run against a fake chain. +reasampler_test(fx_reclaim LINK fx_state) # 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 diff --git a/src/app/main.cpp b/src/app/main.cpp index a2755f9..e70606f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -38,6 +38,7 @@ #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession +#include "shell/view/fx_reclaim.h" // the FX reclaimer's tick #include "shell/view/view.h" // applyMode #include "shell/view/view_lanes.h" // reconcileManagedLanes #include "shell/view/view_reconcile.h" // reconcileAppliedFlags @@ -215,6 +216,15 @@ static void OnTimer() reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); } + // Converge the FX chains toward what the view state says they should be — the + // 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same + // idle discipline as the realtime fast path above: a SINGLE BOOL TEST when there is + // nothing to converge. Ordered AFTER the load block deliberately — a reload replaces + // the view model wholesale, and the applyMode above is what re-derives the queue + // from it, so draining first would spend a tick against the outgoing model. + if (reasampler::g_fxReclaimPending) + reasampler::fxReclaimTick(g_session.view(), nullptr); + reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed } diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp new file mode 100644 index 0000000..1563118 --- /dev/null +++ b/src/shell/view/fx_reclaim.cpp @@ -0,0 +1,163 @@ +// See fx_reclaim.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The drain's decisions are pure (header, test_fx_reclaim.cpp); this file owns the +// queue's lifetime, the per-step track resolve, and the writes. + +#include "shell/view/fx_reclaim.h" + +#include +#include +#include +#include + +#include "shell/view/view.h" // modeApplyInProgress +#include "shell/view/view_park.h" // fxGuidString — the one FX-identity read +#include "shell/view/view_tracks.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_TrackFX_GetCount +#define REAPERAPI_WANT_TrackFX_GetOffline +#define REAPERAPI_WANT_TrackFX_SetOffline +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_ValidatePtr2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// The derived queue and the project it was derived against. The pointer is +// COMPARE-ONLY and never dereferenced beyond the ValidatePtr2 the tick already makes — +// a tab switch has to be detectable without following a pointer that may name a closed +// project. +std::vector g_items; +ReaProject* g_project = nullptr; +ParkClock g_clock; + +// TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter +// the timer and land back here mid-drain. Fail closed: a re-entered drain would walk the +// same items the outer one is mutating and double-write from a stale resume cursor. +bool g_draining = false; +struct DrainLatch { + DrainLatch() { g_draining = true; } + ~DrainLatch() { g_draining = false; } + DrainLatch(const DrainLatch&) = delete; + DrainLatch& operator=(const DrainLatch&) = delete; +}; + +// Resolved fresh from the GUID on every step rather than held across a write, for the +// same pump premise: a plugin load can run arbitrary REAPER code, and a handle taken +// before it is not one this code may trust after it. +MediaTrack* resolveFresh(ReaProject* proj, const std::string& guid) { + TrackHandles handles; + readFolderEntries(proj, handles); + for (const auto& kv : handles) + if (kv.first == guid) return kv.second; + return nullptr; // deleted, or moved to another project +} + +std::set liveTrackGuids(ReaProject* proj) { + TrackHandles handles; + readFolderEntries(proj, handles); + std::set guids; + for (const auto& kv : handles) guids.insert(kv.first); + return guids; +} + +// The live half of drainTick's Host contract (see fx_reclaim.h). +struct ReclaimHost { + ReaProject* project = nullptr; + ViewModeModel* model = nullptr; + + // Monotonic and sub-millisecond: the budget below discriminates at 30ms, which + // GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so + // util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply. + double nowMs() const { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + } + + bool liveChain(const std::string& guid, std::vector& out) { + MediaTrack* tr = resolveFresh(project, guid); + if (!tr) return false; + const int count = TrackFX_GetCount(tr); + out.clear(); + out.reserve(static_cast(count < 0 ? 0 : count)); + // The plain 0..count-1 enumeration, matching the park snapshot's — never the + // 0x1000000 input-FX or 0x2000000 container forms. + for (int fx = 0; fx < count; ++fx) out.push_back(fxGuidString(tr, fx)); + return true; + } + + bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + MediaTrack* tr = resolveFresh(project, guid); + if (!tr) return false; + if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank + if (TrackFX_GetOffline(tr, fxIndex) == offline) return false; // already there + + // DAW-VERIFIED: the same ten TrackFX_SetOffline calls mint ten undo points + // unblocked and ZERO inside this pair. One block per write, not per tick: the + // write itself is what pumps, so the block cannot avoid spanning a pump — it can + // only span as little of one as possible. + Undo_BeginBlock2(project); + TrackFX_SetOffline(tr, fxIndex, offline); + Undo_EndBlock2(project, "", 0); + return true; + } + + void releaseFx(const std::string& guid) { model->releaseSnapshotFx(guid); } +}; + +// Every path that stops scheduling ticks leaves the same state behind: no queue, no pin, +// nothing pending. The debounce clock is NOT part of it — see fx_reclaim.h. +void goIdle() { + g_items.clear(); + g_project = nullptr; + g_fxReclaimPending = false; +} + +} // namespace + +bool g_fxReclaimPending = false; + +void invalidateFxReclaim() { + goIdle(); + g_fxReclaimPending = true; +} + +void fxReclaimTick(ViewModeModel& model, ReaProject* proj) { + if (g_draining) return; + // The model is mid-rewrite and its records are being consumed out from under any + // plan derived from it. The apply invalidates on its way out, so nothing is lost. + if (modeApplyInProgress()) return; + DrainLatch latch; + + // Both arms below are "the project this queue belongs to is no longer the one in + // front of us": no active project at all, or a tab switch / close. Drop the queue + // rather than write another project's chains; returning to it re-derives, because + // poll() reads that as a project load and the load glue reapplies, which invalidates. + // The clock goes too — a hold measured in a project we stopped watching is not + // evidence about the one we come back to. + ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); + if (!ValidatePtr2(nullptr, project, "ReaProject*") || (g_project && g_project != project)) { + goIdle(); + g_clock.forget(); + return; + } + + // DERIVED, never persisted: recomputed from view state every time the queue was + // discarded, so a REAPER quit mid-drain simply re-derives and finishes on reopen. + if (!g_project) { + g_project = project; + g_items = makeQueue( + view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))); + } + + ReclaimHost host{project, &model}; + if (drainTick(g_items, g_clock, host).settled) goIdle(); +} + +} // namespace reasampler diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h new file mode 100644 index 0000000..92dc8e2 --- /dev/null +++ b/src/shell/view/fx_reclaim.h @@ -0,0 +1,233 @@ +#pragma once +// The FX reclaimer: the convergence loop that drives every parked chain offline and +// every returning chain back to what its park record captured, off the mode switch's +// synchronous path (TrackFX_SetOffline was 1897ms of a measured 1942ms switch). The +// drain's decisions are pure and live here; the REAPER writes live in the .cpp. + +#include +#include +#include +#include + +#include "core/view/fx_state.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +// --- tuning ------------------------------------------------------------------ + +// A track's OFFLINE work fires only after it has been continuously parked this long. +// The consequence is wanted, not tolerated: a fast A->B->A flip moves zero plugins, +// because the offline never fires and the online that follows finds every chain +// already where the record wants it. +inline constexpr double kParkHoldMs = 3000.0; + +// Per-tick budgets, set against the measured per-plugin costs (Phase Plant 36ms +// offline / 105ms online; MTurboReverb 42ms / 240ms). A step is one plugin and cannot +// be split, so a budget decides only how many plugins a tick moves. +// +// ONLINE 120ms: onlining restores state the user owns and is waiting on, so a tick +// buys at least one typical load and two cheap ones. OFFLINE 30ms: under one typical +// unload, so opportunistic reclaim moves exactly ONE plugin per tick and yields -- +// background work must not make the UI feel worse than the switch already did. The +// budget is checked BEFORE each step and never mid-step, so a plugin dearer than the +// whole budget still runs; that is what makes forward progress unconditional. +inline constexpr double kOnlineBudgetMs = 120.0; +inline constexpr double kOfflineBudgetMs = 30.0; + +inline double tickBudgetMs(view::FxWorkKind kind) { + return kind == view::FxWorkKind::Online ? kOnlineBudgetMs : kOfflineBudgetMs; +} + +// --- the queue: a cache of intent, never state -------------------------------- + +// One derived work item plus this loop's own completion mark. The work — including its +// resume cursor — is core/view/fx_state's; nothing here is persisted, and the whole +// queue is discarded and re-derived on any model change. +struct ReclaimItem { + view::FxTrackWork work; + bool done = false; +}; + +inline std::vector makeQueue(std::vector work) { + std::vector items; + items.reserve(work.size()); + for (view::FxTrackWork& w : work) items.push_back(ReclaimItem{std::move(w), false}); + return items; +} + +inline bool queueSettled(const std::vector& items) { + for (const ReclaimItem& it : items) + if (!it.done) return false; + return true; +} + +// When each track owed OFFLINE work was first observed parked. Keyed on the track, not +// on the queue, so it OUTLIVES a re-derivation: a membership edit somewhere else must +// not restart an unrelated track's hold. An entry is dropped the moment its track stops +// owing offline work, which is what makes a flip start a fresh hold rather than resume +// the one it interrupted. +class ParkClock { +public: + void observe(const std::vector& items, double nowMs) { + std::map next; + for (const ReclaimItem& it : items) { + if (it.work.kind != view::FxWorkKind::Offline) continue; + auto prior = since_.find(it.work.trackGuid); + next[it.work.trackGuid] = prior != since_.end() ? prior->second : nowMs; + } + since_.swap(next); + } + + // Absent answers false: observe() runs first every tick, so an unknown GUID is one + // that owes no offline work at all. + bool matured(const std::string& guid, double nowMs) const { + auto it = since_.find(guid); + return it != since_.end() && nowMs - it->second >= kParkHoldMs; + } + + void forget() { since_.clear(); } + +private: + std::map since_; +}; + +// Online before offline, and an offline item only once its hold has matured. +// desiredFxState already sorts Online first, so a plain scan carries the direction +// priority; this adds only the debounce. +inline int nextEligible(const std::vector& items, const ParkClock& clock, + double nowMs) { + for (std::size_t i = 0; i < items.size(); ++i) { + if (items[i].done) continue; + if (items[i].work.kind == view::FxWorkKind::Offline && + !clock.matured(items[i].work.trackGuid, nowMs)) + continue; + return static_cast(i); + } + return -1; +} + +// --- the resume cursor -------------------------------------------------------- + +// A Slot-keyed ONLINE record (lifted from a project saved before FX identity existed) +// carries no identity in its targets, so an identity cursor would match nothing there +// and restart the item on every yield. Every other shape matches on the live identity, +// which is what survives a chain reorder between two ticks. +inline bool resumeBySlotOnly(const view::FxTrackWork& work) { + return work.kind == view::FxWorkKind::Online && !work.targets.empty() && + work.targets.front().keying == FxKeying::Slot; +} + +inline std::string slotIdentity(const std::vector& liveFxGuids, int slot) { + const auto i = static_cast(slot); + return slot >= 0 && i < liveFxGuids.size() ? liveFxGuids[i] : std::string{}; +} + +// Records the step just completed. The SLOT is written unconditionally because an FX +// REAPER reports no GUID for has nothing else to carry the cursor — see FxTrackWork's +// resumeAfterSlot for what the resolve does with the pair. +inline void markResumePoint(view::FxTrackWork& work, const std::vector& liveFxGuids, + int slot) { + work.resumeAfterFxGuid = resumeBySlotOnly(work) ? std::string{} : slotIdentity(liveFxGuids, slot); + work.resumeAfterSlot = slot; +} + +// --- the drain ---------------------------------------------------------------- + +// What one tick did. `settled` means every item is complete and the caller may go idle +// until the next invalidation. An item merely waiting out its debounce is NOT settled — +// going idle there would strand its reclaim for the rest of the session. +struct ReclaimTickResult { + int writes = 0; + bool settled = false; +}; + +// One tick of the convergence loop. Templated on `Host` so the ordering, budget, resume +// and release bookkeeping are all exercised without REAPER (tests/test_fx_reclaim.cpp); +// fx_reclaim.cpp holds the one live implementation. Host must provide: +// +// double nowMs() monotonic milliseconds +// bool liveChain(guid, std::vector&) FX identity per live slot; +// false when the track is gone +// bool writeOffline(guid, fxIndex, offline) compare-before-write inside its +// own discarded undo block; +// true iff it actually wrote +// void releaseFx(guid) release the record's FX half +template +ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, Host& host) { + ReclaimTickResult result; + clock.observe(items, host.nowMs()); + + const int first = nextEligible(items, clock, host.nowMs()); + if (first < 0) { + result.settled = queueSettled(items); + return result; + } + + const view::FxWorkKind tickKind = items[static_cast(first)].work.kind; + const double budget = tickBudgetMs(tickKind); + const double start = host.nowMs(); + + for (;;) { + const int i = nextEligible(items, clock, host.nowMs()); + if (i < 0) break; + ReclaimItem& item = items[static_cast(i)]; + // Offline work never rides the online budget: the two directions are priced + // apart deliberately, and a tick that started on one must not spend the other's. + if (item.work.kind != tickKind) break; + + std::vector liveFxGuids; + if (!host.liveChain(item.work.trackGuid, liveFxGuids)) { + // Track gone. Nothing to write, and no record to release either — the next + // apply's reconcile drops the whole record for a GUID that left the project. + item.done = true; + continue; + } + + // Held in a named local: through C++20 a range-for does not extend the lifetime + // of a temporary reached through a member access, so iterating the resolution's + // `.writes` directly off the call would dangle. + const FxRestoreResolution resolved = view::resolveFxWork(item.work, liveFxGuids); + + bool yielded = false; + for (const FxOfflineWrite& w : resolved.writes) { + if (host.nowMs() - start >= budget) { yielded = true; break; } + if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes; + markResumePoint(item.work, liveFxGuids, w.fxIndex); + } + if (yielded) return result; // the resume cursor picks this item up next tick + + item.done = true; + // The record's FX half is released HERE and only here: the chain has now been + // driven to every state the record captured, so the record is spent. Releasing + // it any earlier destroys the only account of which plugins the USER had + // offline before the park, and the next online blanket-enables them. + if (item.work.kind == view::FxWorkKind::Online) host.releaseFx(item.work.trackGuid); + } + + result.settled = queueSettled(items); + return result; +} + +// --- shell entry points -------------------------------------------------------- + +// The ONE test the timer makes per tick, mirroring the realtime capture's single +// pointer test. False means the queue is settled and there is nothing to converge. +extern bool g_fxReclaimPending; + +// Discards the derived queue. Called from every path that can change what the FX chains +// SHOULD be — which is applyMode's success path and nothing else, because every +// membership, snapshot and active-mode mutation reapplies through it. The debounce clock +// deliberately survives: it is keyed on the track, not on the queue. +void invalidateFxReclaim(); + +// Drains one tick against `proj` (nullptr = current project), re-deriving the queue from +// `model` first if it was invalidated. Returns without touching the project while an +// applyMode is on the stack, while already draining (TrackFX_SetOffline is assumed to +// pump the message loop), or when the active project is not the one the queue was +// derived against. +void fxReclaimTick(ViewModeModel& model, ReaProject* proj); + +} // namespace reasampler diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 132bc3e..42c9b66 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -13,6 +13,7 @@ #include "core/view/solo_cache.h" #include "core/view/view_tree.h" +#include "shell/view/fx_reclaim.h" #include "shell/view/view_lanes.h" #include "shell/view/view_park.h" #include "shell/view/view_reconcile.h" @@ -201,6 +202,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject // The tool's own account of what it just left driven — the only one that survives // an undo rolling the model back underneath it. noteAppliedState(project, model); + + // Every membership, snapshot and active-mode mutation in the system reaches the + // project through this call, so this is the whole invalidation surface: what the FX + // chains SHOULD be has just changed, and the reclaimer's queue is a cache of that + // intent, never state. + invalidateFxReclaim(); return true; } diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp index ef74936..58f4785 100644 --- a/src/shell/view/view_park.cpp +++ b/src/shell/view/view_park.cpp @@ -22,28 +22,6 @@ namespace reasampler { namespace { -// 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); -} - // Captures prior driven-flag state, and the chain's per-FX offline state, before // parking. Never reads B_MUTE/I_SOLO; ints preserve whatever REAPER reported // (TrackSnapshot's defensive contract). The plain 0..TrackFX_GetCount-1 enumeration @@ -96,6 +74,25 @@ std::vector g_reportedExposed; const int kViewUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; +// 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); +} + bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { if (GetMediaTrackInfo_Value(tr, parm) == value) return false; SetMediaTrackInfo_Value(tr, parm, value); diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h index 4b99706..5150db2 100644 --- a/src/shell/view/view_park.h +++ b/src/shell/view/view_park.h @@ -29,6 +29,12 @@ inline const char* trackFlagParm(Flag f) { return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet } +// The FX's own durable identity, braced exactly like the track GUID keys, empty when +// REAPER reports none. The ONE such read: the park's snapshot and the reclaimer's +// resolve must address the same chain by the same names, or a restore lands one +// plugin's state on another. See its definition for the stability caveat. +std::string fxGuidString(MediaTrack* tr, int fx); + // A re-park's FX half, unioned rather than overwritten: keeps every FX identity // `prior` already names (the user's real pre-park state, still offline while the // reclaimer hasn't run) and appends whatever identity `live` sees that `prior` diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp new file mode 100644 index 0000000..bb91e62 --- /dev/null +++ b/tests/test_fx_reclaim.cpp @@ -0,0 +1,398 @@ +// Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The +// loop is templated on its host precisely so the four properties that make it safe to +// run in the background are assertable here: the debounce, the per-tick budget's +// yield-and-resume, the queue being a cache of intent that a model change discards, and +// the user's own offline plugins surviving an online. + +#include "../src/shell/view/fx_reclaim.h" + +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +// -- a fake project ------------------------------------------------------------ + +// One live FX: its identity (empty when REAPER reports none) and whether it is offline. +struct FakeFx { + std::string guid; + bool offline = false; +}; + +// Stands in for the REAPER half of drainTick's Host contract. The clock advances only +// on a REAL write, mirroring the live host: a compare-before-write that finds the FX +// already where it wants it costs a read, not a plugin load. +struct FakeHost { + std::map> chains; + std::set released; + std::vector writeLog; + double clock = 0.0; + double stepCostMs = 50.0; + + double nowMs() const { return clock; } + + bool liveChain(const std::string& guid, std::vector& out) { + auto it = chains.find(guid); + if (it == chains.end()) return false; + out.clear(); + for (const FakeFx& fx : it->second) out.push_back(fx.guid); + return true; + } + + bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + auto it = chains.find(guid); + if (it == chains.end()) return false; + if (fxIndex < 0 || fxIndex >= static_cast(it->second.size())) return false; + if (it->second[static_cast(fxIndex)].offline == offline) return false; + + it->second[static_cast(fxIndex)].offline = offline; + clock += stepCostMs; + writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on")); + return true; + } + + void releaseFx(const std::string& guid) { released.insert(guid); } + + bool offlineAt(const std::string& guid, std::size_t slot) const { + auto it = chains.find(guid); + return it != chains.end() && slot < it->second.size() && it->second[slot].offline; + } +}; + +// -- model helpers ------------------------------------------------------------- + +TrackSnapshot recordWithFx(std::vector fx) { + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = std::move(fx); + return snap; +} + +// What the shell does on every invalidation: throw the queue away and recompute it. +std::vector derive(const ViewModeModel& vm, const std::set& live) { + return makeQueue(desiredFxState(vm, vm.activeModeId(), live)); +} + +std::vector onlineChain(const std::vector& guids) { + std::vector chain; + for (const std::string& g : guids) chain.push_back(FakeFx{g, false}); + return chain; +} + +std::vector offlineChain(const std::vector& guids) { + std::vector chain; + for (const std::string& g : guids) chain.push_back(FakeFx{g, true}); + return chain; +} + +// A Design leaf the tool has parked: record held, both halves, nothing user-offlined. +ViewModeModel parkedDesignLeaf(const std::vector& fxGuids) { + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); // active mode is Arrange ⇒ {T} is parked + std::vector fx; + for (const std::string& g : fxGuids) fx.push_back(FxOfflineState{g, 0}); + vm.storeSnapshot("{T}", recordWithFx(fx)); + return vm; +} + +// Puts the queue's offline items past their debounce the way the live loop does: the +// hold starts at the FIRST observation, so advancing the clock before that one just +// moves the origin and matures nothing. +void matureHold(std::vector& items, ParkClock& clock, FakeHost& host) { + clock.observe(items, host.nowMs()); + host.clock += kParkHoldMs; +} + +// Runs ticks until the queue settles, bounded so a non-terminating drain fails the test +// rather than hanging CTest. +int drainToSettled(std::vector& items, ParkClock& clock, FakeHost& host, + int maxTicks = 64) { + for (int t = 1; t <= maxTicks; ++t) + if (drainTick(items, clock, host).settled) return t; + CHECK(false); // did not settle — the drain is not making forward progress + return -1; +} + +} // namespace + +// -- 1. debounce --------------------------------------------------------------- + +static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() { + // A→B: {T} parks. The reclaimer must not touch it yet. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Offline); + CHECK(drainTick(items, clock, host).writes == 0); + CHECK(!drainTick(items, clock, host).settled); // waiting out its hold, not finished + + // B→A half a second later: the flags come back and the FX half is still held, so the + // reclaimer is now owed an ONLINE — over a chain it never got round to offlining. + host.clock = 500.0; + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + items = derive(vm, {"{T}"}); + CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online); + drainToSettled(items, clock, host); + + // The whole point of the 3s hold: a fast A→B→A flip moved NO plugins. + CHECK(host.writeLog.empty()); + CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1) && !host.offlineAt("{T}", 2)); + CHECK(host.released.count("{T}") == 1); // the chain is verifiably online; record spent + + // The contrast, so the assertion above is about the hold and not about the drain + // being inert: the same park held past 3s DOES reclaim. + ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + FakeHost host2; + host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + ParkClock clock2; + std::vector items2 = derive(held, {"{T}"}); + CHECK(drainTick(items2, clock2, host2).writes == 0); // observed parked at t=0 + host2.clock = kParkHoldMs; + drainToSettled(items2, clock2, host2); + CHECK(host2.writeLog.size() == 3); + CHECK(host2.offlineAt("{T}", 0) && host2.offlineAt("{T}", 1) && host2.offlineAt("{T}", 2)); + CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed +} + +// -- 2. budget: yield and resume ------------------------------------------------ + +static void testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}); + host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ exactly one plugin per tick + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + matureHold(items, clock, host); + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(items[0].work.resumeAfterSlot == 0); + CHECK(items[0].work.resumeAfterFxGuid == "{FX-A}"); + CHECK(!items[0].done); + + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(items[0].work.resumeAfterSlot == 1); + + // Slots 2 and 3, one per tick; the tick that writes the last one also exhausts the + // item's write list, so it settles in the same pass rather than costing a spare tick. + CHECK(drainToSettled(items, clock, host) == 2); + + // Each slot moved exactly once and in order — no slot re-walked across a yield. + const std::vector expected{"{T}:0=off", "{T}:1=off", "{T}:2=off", "{T}:3=off"}; + CHECK(host.writeLog == expected); +} + +static void testOnlineDrainGetsTheLargerBudget() { + // Same per-plugin cost, same chain length: the only difference is the direction, and + // onlining is the one the user is waiting on. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + FakeHost host; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}); + host.stepCostMs = 50.0; + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online); + CHECK(drainTick(items, clock, host).writes == 3); // 120ms budget vs 30ms +} + +static void testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork() { + ViewModeModel vm; + vm.membership().tag("{Z-back}", kArrangeModeId); // active; owed an online + vm.membership().tag("{A-park}", kDesignModeId); // parked; owed an offline + vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}})); + vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}})); + + FakeHost host; + host.chains["{Z-back}"] = offlineChain({"{FX-1}"}); + host.chains["{A-park}"] = onlineChain({"{FX-2}"}); + host.stepCostMs = 1.0; // cheap enough that only the kind break can stop the tick + ParkClock clock; + + std::vector items = derive(vm, {"{Z-back}", "{A-park}"}); + CHECK(items.size() == 2); + CHECK(items[0].work.kind == FxWorkKind::Online); // fx_state's ordering + matureHold(items, clock, host); // the parked track's hold is no longer the blocker + + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(host.writeLog == std::vector{"{Z-back}:0=on"}); + CHECK(!host.offlineAt("{A-park}", 0)); // opportunistic reclaim waits for its own tick + + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(host.offlineAt("{A-park}", 0)); +} + +// -- 3. invalidation: the queue is a cache of intent ---------------------------- + +static void testModelChangeDiscardsDrainProgressButNotTheParkHold() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + vm.membership().tag("{U}", kDesignModeId); // a second parked leaf, left alone below + vm.storeSnapshot("{U}", recordWithFx({{"{FX-U}", 0}})); + + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + host.chains["{U}"] = onlineChain({"{FX-U}"}); + host.stepCostMs = 50.0; + ParkClock clock; + + std::vector items = derive(vm, {"{T}", "{U}"}); + drainTick(items, clock, host); // observes both parked at t=0 + host.clock = kParkHoldMs + 100.0; + drainTick(items, clock, host); // one plugin moves; {T} now carries a cursor + CHECK(items[0].work.resumeAfterSlot >= 0); + + // The model changes: {T} is retagged into the active mode and restored, while {U} + // stays exactly as parked as it was. The queue is discarded whole — direction, + // cursors, completion marks and all — and re-derived. + vm.membership().tag("{T}", kArrangeModeId); + vm.releaseSnapshotFlags("{T}"); + items = derive(vm, {"{T}", "{U}"}); + CHECK(items.size() == 2); + CHECK(items[0].work.kind == FxWorkKind::Online); + CHECK(items[0].work.trackGuid == "{T}"); + CHECK(items[0].work.resumeAfterSlot == -1); // no drain progress survives a re-derive + CHECK(items[0].work.resumeAfterFxGuid.empty()); + CHECK(!items[0].done); + + // ...but {U}'s hold does survive it. {U} never stopped being parked, and restarting + // its 3s on every unrelated model change would defer its reclaim indefinitely. + clock.observe(items, host.clock); + CHECK(clock.matured("{U}", host.clock)); + CHECK(nextEligible(items, clock, host.clock) == 0); // online first, then {U} available + + // The mirror: a track that LEAVES the offline set and comes back starts a fresh hold. + // {T} was dropped from the clock by the observation above, while it was online. + vm.membership().tag("{T}", kDesignModeId); + items = derive(vm, {"{T}", "{U}"}); + clock.observe(items, host.clock); + CHECK(!clock.matured("{T}", host.clock)); + CHECK(!clock.matured("{T}", host.clock + kParkHoldMs - 1.0)); + CHECK(clock.matured("{T}", host.clock + kParkHoldMs)); + CHECK(clock.matured("{U}", host.clock)); // and {U}'s own hold still is not restarted +} + +// -- 4. the user's own offline plugins ------------------------------------------ + +static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() { + // {FX-B} was offline before the park because the USER put it there; the record is + // the only account of that, and an online must honor it. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.setActiveMode(kDesignModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}})); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + drainToSettled(items, clock, host); + + CHECK(!host.offlineAt("{T}", 0)); + CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined + CHECK(!host.offlineAt("{T}", 2)); + for (const std::string& entry : host.writeLog) CHECK(entry != "{T}:1=on"); + CHECK(host.writeLog.size() == 2); + CHECK(host.released.count("{T}") == 1); +} + +// -- forward progress: the cursor cases that decide termination ----------------- + +static void testResumeCursorAdvancesPastAnFxWithNoIdentity() { + // The case Wave 1's slot cursor exists for. Slot 1 is an FX REAPER reports no GUID + // for; with the identity alone the cursor would read as "nothing done yet" and the + // item would re-walk from slot 0 on every tick. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "", "{FX-C}"}); + host.stepCostMs = 50.0; // one plugin per tick on the offline budget + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + matureHold(items, clock, host); + drainTick(items, clock, host); + drainTick(items, clock, host); + CHECK(items[0].work.resumeAfterFxGuid.empty()); // slot 1 has no identity to record + CHECK(items[0].work.resumeAfterSlot == 1); // ...so the slot is what carries it + + CHECK(drainToSettled(items, clock, host) == 1); // slot 2, and the item is exhausted + const std::vector expected{"{T}:0=off", "{T}:1=off", "{T}:2=off"}; + CHECK(host.writeLog == expected); +} + +static void testSlotKeyedOnlineRecordCarriesItsCursorAsASlot() { + // A record lifted from a project saved before FX identity existed: its targets hold + // no identity, so a cursor taken from the LIVE identity would match none of them and + // the item would re-walk its whole chain after every yield. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.setActiveMode(kDesignModeId); + TrackSnapshot snap = recordWithFx({{"", 0}, {"", 1}, {"", 0}}); + snap.fxKeying = FxKeying::Slot; + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.chains["{T}"] = offlineChain({"{FX-X}", "{FX-Y}", "{FX-Z}"}); + host.stepCostMs = kOnlineBudgetMs + 10.0; // one plugin per tick, then yield + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + CHECK(items.size() == 1 && resumeBySlotOnly(items[0].work)); + drainTick(items, clock, host); + CHECK(items[0].work.resumeAfterFxGuid.empty()); // NOT "{FX-X}", which matches no target + CHECK(items[0].work.resumeAfterSlot == 0); + + drainToSettled(items, clock, host); + const std::vector expected{"{T}:0=on", "{T}:2=on"}; + CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured + CHECK(host.offlineAt("{T}", 1)); +} + +static void testATrackThatLeftTheProjectCompletesRatherThanStalls() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; // no chain registered ⇒ liveChain reports the track gone + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + matureHold(items, clock, host); + CHECK(drainTick(items, clock, host).settled); + CHECK(host.writeLog.empty()); + CHECK(host.released.empty()); // the next apply's reconcile drops the whole record +} + +int main() { + testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); + + testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped(); + testOnlineDrainGetsTheLargerBudget(); + testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork(); + + testModelChangeDiscardsDrainProgressButNotTheParkHold(); + + testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain(); + + testResumeCursorAdvancesPastAnFxWithNoIdentity(); + testSlotKeyedOnlineRecordCarriesItsCursorAsASlot(); + testATrackThatLeftTheProjectCompletesRatherThanStalls(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}