Merge Wave 3: the FX reclaimer (per-track atomic drain, generation-owned queue, transport-gated offline, save-time release flush)

This commit is contained in:
2026-08-06 23:35:27 -04:00
15 changed files with 1887 additions and 167 deletions
+4
View File
@@ -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
+31 -10
View File
@@ -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
@@ -188,7 +189,10 @@ static void OnTimer()
// this timer mid-apply), same retry-next-tick shape as the consume guard below.
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
// cheap to skip for one tick and resumes correctly, unlatched, on the next.
if (!reasampler::modeApplyInProgress()) g_session.poll();
// The reclaim drain is latched for the same reason and is the same window: it
// holds that view model for the length of a plugin unload, which pumps.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress())
g_session.poll();
// 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
@@ -197,11 +201,12 @@ static void OnTimer()
// signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
// dropped here is the saved mode never applied at all. Holding the whole block
// back one tick also keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
// Both latches are tested BEFORE the consume, not after the applyMode call: the load
// signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here
// is the saved mode never applied at all. Holding the whole block back one tick also
// keeps the panel re-arm ahead of the reapply.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() &&
g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never
@@ -215,6 +220,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: ONE bool read 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::fxReclaimPending())
reasampler::fxReclaimTick(g_session, nullptr);
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
}
@@ -236,18 +250,25 @@ static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*r
g_session.requestReload();
}
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines. The struct is registered ONLY for the
// BeginLoadProjectState undo/redo notification.
// Intentional no-op: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
return false; // we own no project lines — ext state carries our data
}
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
// We write no lines into `ctx` — this is the FX reclaimer's one durability point. Its
// spent park records ride the user's save rather than dirtying the project on the
// reclaimer's own initiative (fx_reclaim.h's ReleaseFlush).
//
// A save only: an isUndo pass is REAPER capturing an undo point, not writing the file,
// and clearing the pending flush on one would leave the record unspent on disk for good.
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool isUndo,
project_config_extension_t* /*reg*/)
{
if (!isUndo)
reasampler::fxReclaimFlushRelease(g_session);
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
+6 -35
View File
@@ -7,32 +7,9 @@ namespace reasampler::view {
namespace {
// Everything up to and including the resume point is already done. An empty
// `resumeAfterFxGuid` is ambiguous by itself — "nothing done yet" and "the last
// completed op had no identity" look identical — so an empty guid falls back to
// matching by slot instead; `resumeAfterSlot < 0` is what actually means
// "nothing done yet". Not found ⇒ nothing dropped: the resume point left the
// chain between ticks, and re-walking a compare-before-write list beats
// trusting a cursor that no longer points at anything.
std::vector<FxOfflineOp> remainingAfter(const std::vector<FxOfflineOp>& ops,
const std::string& resumeAfterFxGuid,
int resumeAfterSlot) {
if (resumeAfterFxGuid.empty() && resumeAfterSlot < 0) return ops;
for (std::size_t i = 0; i < ops.size(); ++i) {
const bool isResumePoint = resumeAfterFxGuid.empty()
? ops[i].slot == resumeAfterSlot
: ops[i].fxGuid == resumeAfterFxGuid;
if (isResumePoint)
return std::vector<FxOfflineOp>(ops.begin() + static_cast<std::ptrdiff_t>(i) + 1,
ops.end());
}
return ops;
}
// Slot keying, deliberately: the live chain IS the target set, so its positions
// are current by construction. Identity keying would drop an FX REAPER reports no
// GUID for, and reclaim has to reach those too — the fxGuid rides along only so
// the resume point has something to match.
// GUID for, and reclaim has to reach those too.
std::vector<FxOfflineOp> offlineTargets(const std::string& trackGuid,
const std::vector<std::string>& liveFxGuids) {
std::vector<FxOfflineOp> ops;
@@ -70,14 +47,13 @@ std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
if (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write
if (!model.leafBelongsToMode(guid, targetMode)) {
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}, {}});
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}});
continue;
}
// Active with the FX half already released: the chain is back online and
// the record is only waiting on its flags. Nothing owed either way.
if (!snap.fxHeld) continue;
online.push_back(
FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid), {}});
online.push_back(FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid)});
}
online.reserve(online.size() + offline.size());
@@ -87,14 +63,9 @@ std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids) {
if (work.kind == FxWorkKind::Offline) {
return resolveFxRestore(
remainingAfter(offlineTargets(work.trackGuid, liveFxGuids), work.resumeAfterFxGuid,
work.resumeAfterSlot),
liveFxGuids);
}
return resolveFxRestore(
remainingAfter(work.targets, work.resumeAfterFxGuid, work.resumeAfterSlot), liveFxGuids);
if (work.kind == FxWorkKind::Offline)
return resolveFxRestore(offlineTargets(work.trackGuid, liveFxGuids), liveFxGuids);
return resolveFxRestore(work.targets, liveFxGuids);
}
} // namespace reasampler::view
+10 -26
View File
@@ -19,9 +19,13 @@ enum class FxWorkKind {
Offline, // reclaim: every live FX offline
};
// One track's whole FX move. Track-granular so a drain may stop between tracks,
// and resumable WITHIN a track by FX identity: a slot cursor would silently skip
// or repeat a plugin if the chain were edited between two ticks.
// One track's whole FX move, and the ATOMIC unit of work: a drain may stop
// between items, never inside one, so an item is either untouched or complete
// and no partial state survives a tick boundary. Do not reintroduce a
// within-track resume cursor — carrying one across ticks meant reconciling it
// against a chain the user may have edited in between, and the case an FX with
// no reported identity forced (a cursor that reads as "nothing done yet" and
// re-walks the item forever) was a live non-termination hazard.
struct FxTrackWork {
std::string trackGuid;
FxWorkKind kind = FxWorkKind::Offline;
@@ -32,28 +36,8 @@ struct FxTrackWork {
// read it, so resolveFxWork synthesises the targets from what it is handed.
std::vector<FxOfflineOp> targets;
// Identity of the last FX this item's drain completed; empty means EITHER
// nothing has completed yet OR the completed FX itself carried no identity
// (REAPER reports no GUID for it, or it came from a Slot-keyed record) — see
// resumeAfterSlot below, which disambiguates the two. This is load-bearing
// for forward progress, not an optimisation: without the disambiguation, an
// unidentified FX would look like "none yet" and the drain would restart at
// the top of the list every tick, forever. Compare-before-write makes every
// write idempotent, so an identity that has since left the chain safely
// restarts the item rather than stranding it.
std::string resumeAfterFxGuid;
// Position of the last completed op, consulted only when resumeAfterFxGuid
// is empty, to tell "resumed past an unidentified FX at this slot" apart
// from "nothing done yet" (-1). Every FxOfflineOp carries its slot
// regardless of keying, so this is available for both Online and Offline
// work.
int resumeAfterSlot = -1;
bool operator==(const FxTrackWork& o) const {
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets &&
resumeAfterFxGuid == o.resumeAfterFxGuid &&
resumeAfterSlot == o.resumeAfterSlot;
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets;
}
};
@@ -82,8 +66,8 @@ std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
// Resolves one work item against the chain as it stands, where `liveFxGuids[i]`
// is the identity of the FX at slot `i` right now (empty when REAPER reported
// none). Honors the resume point, then matches by identity: an identity that is
// not live is DROPPED, never re-pointed at a slot.
// none) — the item's WHOLE move, matched by identity: an identity that is not
// live is DROPPED, never re-pointed at a slot.
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
const std::vector<std::string>& liveFxGuids);
+4 -1
View File
@@ -104,7 +104,10 @@ std::vector<std::string> selectedTrackGuids() {
}
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
// immediately (park/unpark/re-derive parents).
// immediately (park/unpark/re-derive parents). The return is deliberately discarded: a
// refused reapply leaves the membership edit standing and unapplied, and the user's next
// edit or mode switch reapplies it. What must NOT be dropped with it is the reclaimer's
// queue, which is why applyMode invalidates on its refusal path too (view.cpp).
void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}
+10
View File
@@ -211,6 +211,16 @@ bool ReaSamplerSession::saveViewStateOnly() {
return true;
}
// Its twin bar the dirty mark — the one line that differs is the whole point of the
// split, so the two stay spelled out rather than folded behind a flag. `project` is
// trusted as given rather than resolved via activeSavedProject() — see session.h.
bool ReaSamplerSession::flushViewState(ReaProject* project) {
if (!project) return false;
writeViewState(project, view_);
return true;
}
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
ReaProject* proj = activeSavedProject();
if (!proj) return false;
+13
View File
@@ -31,6 +31,9 @@
#include "core/version/app_version.h"
#include "core/view/view_mode_model.h"
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
class ReaProject;
namespace reasampler {
// Owns the session's BankBook (pool + named banks) and drives persistence
@@ -144,6 +147,16 @@ public:
// Returns on the SAME rule as saveToActiveProject above, prohibition included.
bool saveViewStateOnly();
// The same write with the dirty mark OMITTED, for a caller already inside REAPER's
// own project save: marking dirty there would leave the project modified the instant
// it was saved, which is the whole reason that caller exists (fx_reclaim's
// ReleaseFlush). `project` is the SAVING project the caller resolved (e.g. via
// GetCurrentProjectInLoadSave()) — this call trusts it rather than re-deriving the
// active tab, which is exactly the mismatch fx_reclaim's caller exists to avoid. Not
// a general-purpose sibling — a mutation site that skips the dirty mark silently
// loses its write when the user closes without saving.
bool flushViewState(ReaProject* project);
// Report-only prune dry-run: feeds the pure core with (present, referenced,
// owned) — `present` from the folder enumeration, the other two from the
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
+235
View File
@@ -0,0 +1,235 @@
// 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 <chrono>
#include <set>
#include <string>
#include <vector>
#include "shell/persist/session.h" // flushViewState — the release's durable half
#include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch
#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_GetCurrentProjectInLoadSave
#define REAPERAPI_WANT_ShowConsoleMsg
#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, the project it was derived against, and the timer's wakeup — see
// FxReclaimQueue, which owns all three.
FxReclaimQueue g_queue;
// A release applied to the in-memory model and not yet written — see ReleaseFlush.
ReleaseFlush g_releaseFlush;
// 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 derive a
// second queue for the same project and race the outer one's writes track for track.
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 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. The caller (ReclaimHost::resolveForStep)
// re-validates `proj` itself before calling this, on every arm, so this scan can
// assume a live project.
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<std::string> liveTrackGuids(ReaProject* proj) {
TrackHandles handles;
readFolderEntries(proj, handles);
std::set<std::string> 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;
ReaSamplerSession* session = nullptr;
// One item's liveChain call plus every writeOffline call it drives all name the
// SAME track, back to back, before drainTick can move to the next item — so the
// full O(T) resolveFresh scan is owed once per item, not once per call. Every
// call after the first re-validates the SAME pointer instead: cheap, and still
// catches a track (or its whole project) that a prior write's pump took down,
// which the scan-per-call version caught too, just at O(T) instead of O(1).
std::string cachedGuid_;
MediaTrack* cachedTrack_ = nullptr;
// Re-checks the project on EVERY call, both arms: the cached-track fast path used
// to hand `project` straight to ValidatePtr2 without re-validating it first, and a
// prior pumping write can have closed it since the tick's own entry check ran.
// (reaper_plugin_functions.h:7898 says `proj` is IGNORED when the pointer passed
// IS itself a project — not that passing one is only valid when it is; passing the
// project here is still right, just not for the reason once claimed.)
MediaTrack* resolveForStep(const std::string& guid) {
if (!ValidatePtr2(nullptr, project, "ReaProject*")) return nullptr;
if (guid == cachedGuid_ && cachedTrack_ &&
ValidatePtr2(project, cachedTrack_, "MediaTrack*"))
return cachedTrack_;
cachedTrack_ = resolveFresh(project, guid);
cachedGuid_ = guid;
return cachedTrack_;
}
// 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<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
// The SAME predicate applyMode refuses a real mode switch on, deliberately shared
// rather than re-read here: "the transport is rolling" is one fact with one home
// (view.h). drainTick narrows what it gates with this answer to offline work only.
bool transportRolling() const { return transportBlocksModeSwitch(project); }
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
MediaTrack* tr = resolveForStep(guid);
if (!tr) return false;
const int count = TrackFX_GetCount(tr);
out.clear();
out.reserve(static_cast<std::size_t>(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 = resolveForStep(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 even though a step is
// now one whole track: the write itself is what pumps, so the block cannot avoid
// spanning a pump — it can only span as little of one as possible. Widening it to
// the track would multiply that exposure by the chain length while suppressing no
// extra undo point (the discard form mints none either way), and a user action
// landing inside a discarded block can lose its own Ctrl-Z.
Undo_BeginBlock2(project);
TrackFX_SetOffline(tr, fxIndex, offline);
Undo_EndBlock2(project, "", 0);
return true;
}
// Reached only after the drain has unwound and the queue has proved its generation
// did not move, so the session still holds the model the queue was derived against.
// The spend lands in memory here and reaches disk on the project's next save.
void releaseFx(const std::string& guid) {
session->view().releaseSnapshotFx(guid);
g_releaseFlush.arm(project);
}
};
// The drop set the last console report named, and the project it named it for. Same
// one-per-changed-set memo as view_park's reportExposedTracks, which owns the reasoning;
// here the repeat it swallows is an aborted tick's re-derivation resolving identically.
// The pointer is COMPARE-ONLY and never dereferenced.
ReaProject* g_reportedDropOwner = nullptr;
std::vector<TrackFxDrops> g_reportedDrops;
void reportDrops(ReaProject* proj, const std::vector<TrackFxDrops>& drops) {
if (!shouldReportDrops(proj == g_reportedDropOwner, g_reportedDrops, drops)) return;
g_reportedDropOwner = proj;
g_reportedDrops = drops;
FxRestoreDrops total;
for (const TrackFxDrops& d : drops) total.add(d.drops);
const std::string msg = describeFxRestoreDrops(total, static_cast<int>(drops.size()));
// "!SHOW:" so a background tick cannot force the console open
// (reaper_plugin_functions.h:6536).
if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
}
} // namespace
bool fxReclaimPending() { return g_queue.pending(); }
void invalidateFxReclaim() {
// A generation bump and a flag, and deliberately nothing more. Discarding the queue
// here is what let a re-entered apply free the element the outer drain was writing
// through, and clear the very wakeup this call had just raised.
g_queue.invalidate();
}
bool fxReclaimDrainInProgress() { return g_draining; }
void fxReclaimTick(ReaSamplerSession& session, 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;
// beginTick owns what a pin/project mismatch means. What is local to here: the model
// the re-derivation below reads is the ACTIVE project's, because reaching this line
// means OnTimer ran session.poll() this tick — the two guards above are the same pair
// that gate that poll.
ReaProject* project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) project = nullptr;
if (!g_queue.beginTick(project)) return;
// DERIVED, never persisted: recomputed from view state every time the queue was
// invalidated, so a REAPER quit mid-drain simply re-derives and finishes on reopen.
if (g_queue.stale()) {
const ViewModeModel& model = session.view();
g_queue.adopt(makeQueue(
view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project))));
}
ReclaimHost host{project, &session};
const ReclaimRunResult run = g_queue.run(host);
reportDrops(project, run.drops);
if (!run.aborted && run.settled) g_queue.goIdle();
}
void fxReclaimFlushRelease(ReaSamplerSession& session) {
// GetCurrentProjectInLoadSave() names the project THIS hook is being asked to save
// (reaper_plugin_functions.h:1582-1587) -- not merely the active tab, which a
// Save-All or a background-project save can diverge from. EnumProjects(-1, ...) used
// to stand in for it here and was wrong on both of those: it could skip the flush for
// the project actually being written, or match the arm against the active tab while a
// different project saved and write into that wrong project's ext state.
ReaProject* saving = GetCurrentProjectInLoadSave();
if (!g_releaseFlush.pendingFor(saving)) return;
// Promptless, unlike design_view_actions' persistViewState, which can open a modal
// Save-As. [verify — DAW] whether this write reaches the <EXTSTATE> block of the
// file being saved right now, or lands one save late (see ReleaseFlush) — either
// way the arm is deliberately left standing rather than cleared on this one issue.
session.flushViewState(saving);
}
} // namespace reasampler
+469
View File
@@ -0,0 +1,469 @@
#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 <cstddef>
#include <map>
#include <string>
#include <vector>
#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 {
class ReaSamplerSession;
// --- 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 whole track's
// chain and cannot be split, so a budget decides only how many TRACKS a tick moves --
// and a single track with a long chain overruns it by as much as that chain costs.
//
// ONLINE 120ms: onlining restores state the user owns and is waiting on. The check
// runs only AFTER a step, so against the cited 105ms typical load it admits TWO
// typical tracks per tick (105 < 120, so a second runs; 210 >= 120 stops a third),
// not one -- worst case a tick runs over budget by up to one track's whole cost.
// OFFLINE 30ms: background work must not make the UI feel worse than the switch
// already did. At the cited 36-42ms costs one track alone already clears the
// budget, so opportunistic reclaim moves ONE track and yields -- but that count is a
// property of those costs, not the budget: a cheap chain (a stock JS at ~1ms) stays
// under 30ms for many tracks in a row and all of them move in one tick. Checked only
// AFTER a step either way, never before the first and never mid-step, so a track
// 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 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<ReclaimItem> makeQueue(std::vector<view::FxTrackWork> work) {
std::vector<ReclaimItem> 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<ReclaimItem>& 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<ReclaimItem>& items, double nowMs) {
std::map<std::string, double> 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(); }
// The owner swaps the clock out for the duration of a drain — see FxReclaimQueue.
void swap(ParkClock& other) { since_.swap(other.since_); }
private:
std::map<std::string, double> 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<ReclaimItem>& 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<int>(i);
}
return -1;
}
// --- the drain ----------------------------------------------------------------
// One ONLINE item's captured state the chain had no home for. An OFFLINE item can
// never produce one: its targets are the live chain's own slots, so every op resolves.
struct TrackFxDrops {
std::string trackGuid;
FxRestoreDrops drops;
bool operator==(const TrackFxDrops& o) const {
return trackGuid == o.trackGuid && drops == o.drops;
}
};
// True when a drop set is worth printing: any change of owner or of the set itself.
// The one-per-changed-set memo this gates is view_park's `shouldReport`/
// `reportExposedTracks`, which states the reason; the shell holds the memo.
//
// A tick that lost nothing is never worth printing AND must leave the memo alone — it
// is not evidence about the last set that DID lose something, and letting it overwrite
// the memo re-arms the identical report for the next re-derivation to print again.
// Answering false is what holds the memo back, so this is the one gate for both.
inline bool shouldReportDrops(bool sameOwnerAsLast, const std::vector<TrackFxDrops>& last,
const std::vector<TrackFxDrops>& now) {
if (now.empty()) return false;
return !(sameOwnerAsLast && last == now);
}
// 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;
// Tracks whose park record the tick drove fully online, so the record's FX half is
// spent. REPORTED, not applied: releasing is a model write and the model is exactly
// what a re-entered load can replace mid-drain, so only the owner may commit it —
// see FxReclaimQueue::run.
std::vector<std::string> released;
// Per track that lost any, what the tick could not place. Carried out for the shell
// to print through core/view/fx_offline's describeFxRestoreDrops — a plugin deleted
// while its track was parked is a degrade the user has to repair by hand, and
// swallowing it leaves no trace at all.
std::vector<TrackFxDrops> drops;
};
// One tick of the convergence loop. Templated on `Host` so the ordering, budget 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 transportRolling() playing or recording — gates the
// OFFLINE direction only; a tick
// may still online while it rolls
// bool liveChain(guid, std::vector<std::string>&) 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 and
// arm the save-time flush that
// makes the spend durable; called
// by FxReclaimQueue::run after the
// drain has unwound, never from
// inside the drain
//
// `items` and `clock` must be the caller's OWN storage, not shared state a re-entered
// call could reach: a write pumps the message loop. FxReclaimQueue::run is what makes
// that true for the live loop.
template <class Host>
ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, Host& host) {
ReclaimTickResult result;
// Run every tick, rolling or not: matured()'s own contract ("observe() runs first
// every tick") already assumed this, and a track can stop owing offline work on a
// rolling tick too now that this loop keeps writing online through one — a
// continuously-parked track's origin is untouched (observe carries the prior
// timestamp forward), so the only thing this changes is dropping a hold the moment
// its track genuinely stops owing offline work, matured or not.
clock.observe(items, host.nowMs());
// Mirrors applyMode's refusal (view.h, transportBlocksModeSwitch), narrowed to the
// OFFLINE direction only: onlining is the mode switch's own unfinished promise — the
// user is waiting on it — while offlining is opportunistic reclaim that can always
// wait for the transport to stop. Onlining while rolling still costs a plugin load
// mid-playback; that is an accepted trade for this gate's shape, not a hazard it
// introduces.
const bool offlineAllowed = !host.transportRolling();
auto nextAllowed = [&](double nowMs) {
const int i = nextEligible(items, clock, nowMs);
if (i >= 0 && !offlineAllowed &&
items[static_cast<std::size_t>(i)].work.kind == view::FxWorkKind::Offline)
return -1;
return i;
};
const int first = nextAllowed(host.nowMs());
if (first < 0) {
result.settled = queueSettled(items);
return result;
}
const view::FxWorkKind tickKind = items[static_cast<std::size_t>(first)].work.kind;
const double budget = tickBudgetMs(tickKind);
const double start = host.nowMs();
for (;;) {
const int i = nextAllowed(host.nowMs());
if (i < 0) break;
ReclaimItem& item = items[static_cast<std::size_t>(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;
// A track gone from the project writes nothing and releases nothing — the next
// apply's reconcile drops the whole record for a GUID that left it.
std::vector<std::string> liveFxGuids;
if (host.liveChain(item.work.trackGuid, liveFxGuids)) {
// 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);
for (const FxOfflineWrite& w : resolved.writes)
if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes;
if (resolved.drops.total() > 0)
result.drops.push_back(TrackFxDrops{item.work.trackGuid, resolved.drops});
// The record is spent once the chain has been driven to every captured state
// that is still PLACEABLE — which is why the release fires with drops
// outstanding: a dropped entry names an FX the chain no longer has, so
// holding the record for it would replan work that can never resolve.
// Releasing any EARLIER is what destroys the only account of which plugins
// the USER had offline before the park, and the next online blanket-enables
// them. Reported rather than applied; see ReclaimTickResult::released.
if (item.work.kind == view::FxWorkKind::Online)
result.released.push_back(item.work.trackGuid);
}
item.done = true; // both arms: a step is atomic, so no item is half-moved
if (host.nowMs() - start >= budget) break; // between tracks only
}
result.settled = queueSettled(items);
return result;
}
// --- the queue's owner ----------------------------------------------------------
struct ReclaimRunResult {
int writes = 0;
bool settled = false;
// Something invalidated the queue while the drain was on the stack. The work and
// the releases are DISCARDED, `settled` is never true, and the caller must not go
// idle — the next tick re-derives against whatever changed.
bool aborted = false;
// Carried out on the abort path TOO, unlike the releases: a drop is a fact about the
// chain the tick just wrote, not a claim about the model a re-entered load may have
// replaced. The re-derivation that follows an abort resolves the very same set,
// which is what shouldReportDrops is there to swallow.
std::vector<TrackFxDrops> drops;
};
// Owns the derived queue, the debounce clock, the project both were derived against, and
// the timer's wakeup — together, because it is their interaction that decides whether a
// tick may happen at all. A `ReaProject*` here is COMPARE-ONLY and never dereferenced, so
// the whole state machine is exercisable without REAPER (tests/test_fx_reclaim.cpp).
//
// The queue must also survive being re-entered: TrackFX_SetOffline is ASSUMED to pump the
// message loop, so an invalidation can arrive with a drain half-way through a track. The
// two halves of that safety are structural, not guarded. `invalidate()` bumps a counter
// and raises the wakeup and touches NOTHING else, so there is nothing for it to clear
// under a drain. `run()` moves the queue and the clock into locals for the drain's whole
// duration, so the object holds nothing the drain is reading — and re-checks the counter
// before it commits either the surviving queue or a single release.
class FxReclaimQueue {
public:
void invalidate() {
++generation_;
pending_ = true;
}
// The ONE test the timer makes per tick. False means settled: nothing to converge
// until the next invalidation.
bool pending() const { return pending_; }
// The project the current queue and holds were derived against, or nullptr when idle.
ReaProject* project() const { return project_; }
// The tick's entry transition, and the ONE place the pin moves. `project` is the
// validated active project, or nullptr when there is none; false means the caller
// must return without touching REAPER.
//
// A project that does not match the pin is a TAB SWITCH — the ordinary path, not a
// corner. Neither the derived work nor the holds are evidence about the project we
// arrived in, so both go, and the tick proceeds to re-derive against the new one. The
// WAKEUP STANDS: clearing it here (an earlier goIdle in this branch did) stranded the
// incoming project's whole reclaim, because the timer gates every tick on it and
// nothing re-raises it short of a mode switch or a membership edit there. Only the
// no-project arm may clear it — nothing to converge, and nothing to come back to.
bool beginTick(ReaProject* project) {
if (!project) {
goIdle();
clock_.forget();
return false;
}
if (project_ && project_ != project) {
discard();
clock_.forget();
}
project_ = project;
return true;
}
// Settled: drop the queue and the pin and stop the timer's ticks. The debounce clock
// deliberately survives — it is keyed on the track, not on the queue.
void goIdle() {
discard();
project_ = nullptr;
pending_ = false;
}
// No queue at all, or one derived before the last invalidation.
bool stale() const { return !derived_ || derivedAt_ != generation_; }
void adopt(std::vector<ReclaimItem> items) {
items_ = std::move(items);
derived_ = true;
derivedAt_ = generation_;
}
void discard() {
items_.clear();
derived_ = false;
}
const std::vector<ReclaimItem>& items() const { return items_; }
template <class Host>
ReclaimRunResult run(Host& host) {
const unsigned long long entered = generation_;
std::vector<ReclaimItem> work;
ParkClock clock;
work.swap(items_);
clock.swap(clock_);
const ReclaimTickResult tick = drainTick(work, clock, host);
// The hold is keyed on the track and outlives any re-derivation, so it comes
// back whether or not the drain's own work survives.
clock_.swap(clock);
if (generation_ != entered)
return ReclaimRunResult{tick.writes, false, true, tick.drops};
items_.swap(work);
// A release dropped by an abort is re-owed, not lost: the next derivation plans
// the same Online item, its writes all find the chain already there, and the
// release fires then. Applied in MEMORY only — the spend reaches disk on the
// project's next save (ReleaseFlush), never on the drain's own initiative.
for (const std::string& guid : tick.released) host.releaseFx(guid);
return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops};
}
private:
std::vector<ReclaimItem> items_;
ParkClock clock_;
ReaProject* project_ = nullptr;
unsigned long long generation_ = 0;
unsigned long long derivedAt_ = 0;
bool derived_ = false;
bool pending_ = false;
};
// --- the release's durable half --------------------------------------------------
// A park record spent in MEMORY only, waiting for the project's next save.
//
// The drain used to persist it itself, and that dirtied the project: open one saved
// mid-reclaim, let the drain finish, and the user is asked to save a project they only
// opened. The tool no longer marks a project dirty on its own initiative — it writes what
// it owes when the USER saves. The exposure that buys is the window between the two: die
// in there and the record survives on disk unspent, so the re-derived Online item drives
// the chain to it again, reverting any per-FX offline set since the reclaim finished.
//
// Keyed on the project so a release for one project's model can never land in a
// different project's ext state (checked against GetCurrentProjectInLoadSave() at the
// hook — see fxReclaimFlushRelease). A stale arm simply never fires — the same
// recovery leg.
//
// `clear()` is deliberately NOT called after a single issued write. [verify — DAW]
// whether a SetProjExtState made from inside SaveExtensionConfig reaches the
// <EXTSTATE> block of the very file being written now, or lands one save later —
// project_config_extension_t (reaper_plugin.h:1196-1208) documents no ordering, and
// SetProjExtState (reaper_plugin_functions.h:6288) promises only "restored the next
// time this project is loaded". If the unfavourable answer holds, clearing here would
// strand the spend in memory for the rest of the session with nothing to retry it —
// so the arm stays armed and a further save simply re-issues the same idempotent
// write.
class ReleaseFlush {
public:
void arm(ReaProject* project) { project_ = project; }
bool pendingFor(ReaProject* active) const { return project_ && project_ == active; }
void clear() { project_ = nullptr; }
private:
ReaProject* project_ = nullptr; // COMPARE-ONLY, never dereferenced
};
// --- 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.
bool fxReclaimPending();
// Raises the wakeup and marks the derived queue for re-derivation by the NEXT tick;
// discards nothing here, because this is reachable from inside a drain. Called from
// applyMode and nothing else
// — every membership, snapshot and active-mode mutation reapplies through it — on the
// refusal path as well as the success one (see view.cpp for why the refusal is the
// load-bearing half). The debounce clock deliberately survives: it is keyed on the
// track, not on the queue.
void invalidateFxReclaim();
// True while a drain is on the stack. Exposed so the timer's other work can stand off
// the model the drain is holding, the same way it stands off an applyMode.
bool fxReclaimDrainInProgress();
// Drains one tick against `proj` (nullptr = current project), re-deriving the queue from
// the session's view model first if it was invalidated or if the active project moved
// (FxReclaimQueue::beginTick). Returns without touching the project while an applyMode is
// on the stack, while already draining, or when there is no valid active project at all.
// Takes the SESSION rather than the model alone because completing an online spends the
// park record's FX half — see FxReclaimQueue::run.
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj);
// Writes an in-memory release into the SAVING project's view state (identified via
// GetCurrentProjectInLoadSave(), not the active tab). Called from the project-save
// hook and nowhere else: this is the ONE path that makes a spent record durable, and
// it deliberately does not mark the project dirty (see ReleaseFlush).
void fxReclaimFlushRelease(ReaSamplerSession& session);
} // namespace reasampler
+24 -1
View File
@@ -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"
@@ -61,7 +62,9 @@ bool transportBlocksModeSwitch(ReaProject* proj) {
bool modeApplyInProgress() { return g_applying; }
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
namespace {
bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below
ApplyLatch latch;
@@ -201,7 +204,27 @@ 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);
return true;
}
} // namespace
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
const bool applied = applyModeImpl(model, targetModeId, proj);
// Every membership, snapshot and active-mode mutation in the system reaches the
// project through this call, so this is the whole invalidation surface: the
// reclaimer's queue is a cache of what the FX chains SHOULD be, never state.
//
// Dropped on a REFUSAL too, and that is the load-bearing half. Callers mutate the
// model FIRST and reapply to make it visible (design_view_actions' tag/untag/
// show-both, render_in_place, the load tick), and every one of them discards this
// return — so a refusal is exactly the case where the mutation stands and nothing
// else would drop a queue that now converges toward the mode the user just left.
// The cost when the refusal followed no mutation is one re-derivation on the timer.
invalidateFxReclaim();
return applied;
}
} // namespace reasampler
+3
View File
@@ -59,6 +59,9 @@ bool modeApplyInProgress();
// (modeApplyInProgress), or if the project does not validate. A reapply (target ==
// active) is never gated and never touches solo. `proj` == nullptr means the current
// project, resolved ONCE and used for every call this apply makes.
//
// Either way — applied or refused — this drops the FX reclaimer's derived queue, which
// is why callers may safely discard the return after mutating the model.
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
} // namespace reasampler
+19 -22
View File
@@ -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<std::string> 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);
+6
View File
@@ -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`
File diff suppressed because it is too large Load Diff
-72
View File
@@ -240,74 +240,6 @@ static void testSlotKeyedRecordStillRestoresByPosition() {
CHECK(writeAt(res, 2, false));
}
// -- the resume point ---------------------------------------------------------
static void testResumePointSkipsWhatTheDrainAlreadyDid() {
ViewModeModel vm = parkedWithThreeFx();
CHECK(vm.setActiveMode(kDesignModeId));
std::vector<FxTrackWork> work = desiredFxState(vm, kDesignModeId, {"{T}"});
CHECK(work.size() == 1);
if (work.empty()) return;
work[0].resumeAfterFxGuid = "{FX-A}";
const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"});
CHECK(res.writes.size() == 2);
CHECK(!anyWriteAt(res, 0)); // A is done
CHECK(writeAt(res, 1, true));
CHECK(writeAt(res, 2, false));
// Resuming after the LAST identity leaves nothing owed — the item is finished.
work[0].resumeAfterFxGuid = "{FX-C}";
CHECK(resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.empty());
}
static void testResumePointThatLeftTheChainRestartsRatherThanStrands() {
ViewModeModel vm = parkedWithThreeFx();
CHECK(vm.setActiveMode(kDesignModeId));
std::vector<FxTrackWork> work = desiredFxState(vm, kDesignModeId, {"{T}"});
CHECK(work.size() == 1);
if (work.empty()) return;
// The drain's cursor named a plugin the user has since deleted. Every write is
// compare-before-write, so re-walking the item is cheap and correct; honoring a
// cursor that matches nothing would strand the rest of the chain.
work[0].resumeAfterFxGuid = "{FX-GONE}";
const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"});
CHECK(res.writes.size() == 3);
CHECK(writeAt(res, 1, true));
// Same rule on the offline side, where the cursor keys into the live chain.
ViewModeModel parked = parkedWithThreeFx(); // Arrange active ⇒ {T} parks
std::vector<FxTrackWork> offlineWork = desiredFxState(parked, kArrangeModeId, {"{T}"});
CHECK(offlineWork.size() == 1);
if (offlineWork.empty()) return;
offlineWork[0].resumeAfterFxGuid = "{FX-A}";
CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 2);
offlineWork[0].resumeAfterFxGuid = "{FX-GONE}";
CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 3);
}
static void testResumeAfterUnidentifiedFxMakesForwardProgress() {
// The drain completed slot 1, an FX REAPER reports no GUID for. Before the
// slot-cursor fix, an empty resumeAfterFxGuid was indistinguishable from
// "nothing done yet" and this item would restart at slot 0 every tick,
// forever — the non-terminating drain this test pins against.
ViewModeModel parked = parkedWithThreeFx(); // Arrange active ⇒ {T} parks (Offline, slot-keyed)
std::vector<FxTrackWork> work = desiredFxState(parked, kArrangeModeId, {"{T}"});
CHECK(work.size() == 1);
if (work.empty()) return;
work[0].resumeAfterFxGuid = "";
work[0].resumeAfterSlot = 1;
const FxRestoreResolution res =
resolveFxWork(work[0], {"{FX-A}", "", "{FX-B}", "{FX-C}"});
CHECK(res.writes.size() == 2); // slots 2 and 3 only — 0 and 1 already done
CHECK(!anyWriteAt(res, 0));
CHECK(!anyWriteAt(res, 1));
CHECK(anyWriteAt(res, 2));
CHECK(anyWriteAt(res, 3));
}
int main() {
testActiveTrackIsOwedOnlineInactiveIsOwedOffline();
testOnlineWorkSortsBeforeOfflineWork();
@@ -321,10 +253,6 @@ int main() {
testAddedFxIsLeftAlone();
testSlotKeyedRecordStillRestoresByPosition();
testResumePointSkipsWhatTheDrainAlreadyDid();
testResumePointThatLeftTheChainRestartsRatherThanStrands();
testResumeAfterUnidentifiedFxMakesForwardProgress();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}