Add the FX reclaimer: a debounced, time-boxed convergence loop that offlines parked chains and restores returning ones off the switch path
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <chrono>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<ReclaimItem> 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<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;
|
||||
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<double, std::milli>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count();
|
||||
}
|
||||
|
||||
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
|
||||
MediaTrack* tr = resolveFresh(project, 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 = 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
|
||||
@@ -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 <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 {
|
||||
|
||||
// --- 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<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(); }
|
||||
|
||||
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 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
|
||||
// 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<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
|
||||
template <class Host>
|
||||
ReclaimTickResult drainTick(std::vector<ReclaimItem>& 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<std::size_t>(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<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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string, std::vector<FakeFx>> chains;
|
||||
std::set<std::string> released;
|
||||
std::vector<std::string> writeLog;
|
||||
double clock = 0.0;
|
||||
double stepCostMs = 50.0;
|
||||
|
||||
double nowMs() const { return clock; }
|
||||
|
||||
bool liveChain(const std::string& guid, std::vector<std::string>& 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<int>(it->second.size())) return false;
|
||||
if (it->second[static_cast<std::size_t>(fxIndex)].offline == offline) return false;
|
||||
|
||||
it->second[static_cast<std::size_t>(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<FxOfflineState> 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<ReclaimItem> derive(const ViewModeModel& vm, const std::set<std::string>& live) {
|
||||
return makeQueue(desiredFxState(vm, vm.activeModeId(), live));
|
||||
}
|
||||
|
||||
std::vector<FakeFx> onlineChain(const std::vector<std::string>& guids) {
|
||||
std::vector<FakeFx> chain;
|
||||
for (const std::string& g : guids) chain.push_back(FakeFx{g, false});
|
||||
return chain;
|
||||
}
|
||||
|
||||
std::vector<FakeFx> offlineChain(const std::vector<std::string>& guids) {
|
||||
std::vector<FakeFx> 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<std::string>& fxGuids) {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId); // active mode is Arrange ⇒ {T} is parked
|
||||
std::vector<FxOfflineState> 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<ReclaimItem>& 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<ReclaimItem>& 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<ReclaimItem> 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<ReclaimItem> 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<ReclaimItem> 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<std::string> 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<ReclaimItem> 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<ReclaimItem> 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<std::string>{"{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<ReclaimItem> 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<ReclaimItem> 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<ReclaimItem> 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<std::string> 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<ReclaimItem> 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<std::string> 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<ReclaimItem> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user