Make an FX reclaim step one whole track's chain: drop the resume cursor, yield only between tracks

This commit is contained in:
2026-08-06 20:48:17 -04:00
parent 5a35b71dc0
commit f04c704353
6 changed files with 134 additions and 274 deletions
+8 -4
View File
@@ -38,7 +38,8 @@ 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.
// same queue the outer one holds a reference into, re-running the very item that outer
// step is still writing.
bool g_draining = false;
struct DrainLatch {
DrainLatch() { g_draining = true; }
@@ -99,9 +100,12 @@ struct ReclaimHost {
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.
// 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);
+29 -59
View File
@@ -25,15 +25,17 @@ namespace reasampler {
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.
// 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, 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 --
// unload, so opportunistic reclaim moves exactly ONE track 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.
// budget is checked only AFTER a step, 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;
@@ -43,9 +45,9 @@ inline double tickBudgetMs(view::FxWorkKind kind) {
// --- 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.
// 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;
@@ -109,31 +111,6 @@ inline int nextEligible(const std::vector<ReclaimItem>& items, const ParkClock&
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<std::string>& liveFxGuids, int slot) {
const auto i = static_cast<std::size_t>(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<std::string>& 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
@@ -144,8 +121,8 @@ struct ReclaimTickResult {
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);
// 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
@@ -178,33 +155,26 @@ ReclaimTickResult drainTick(std::vector<ReclaimItem>& items, ParkClock& clock, H
// 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)) {
// 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;
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;
// 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);
}
item.done = true; // both arms: a step is atomic, so no item is half-moved
// 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);
if (host.nowMs() - start >= budget) break; // between tracks only
}
result.settled = queueSettled(items);