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:
@@ -39,6 +39,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
${LICE_SRC}
|
${LICE_SRC}
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp
|
${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp
|
${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/view/view_reconcile.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.
|
# has none — its TUs are compiled into this target directly.
|
||||||
reasampler_test(view_park LINK view_mode_model)
|
reasampler_test(view_park LINK view_mode_model)
|
||||||
reasampler_test(view_reconcile LINK applied_state 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
|
# Bank-package import: the promptless verb plus its action skin. Kept as its own
|
||||||
# appended block rather than merged into the lists above, so the two package
|
# appended block rather than merged into the lists above, so the two package
|
||||||
|
|||||||
+31
-10
@@ -38,6 +38,7 @@
|
|||||||
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
|
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
|
||||||
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
||||||
#include "shell/persist/session.h" // ReaSamplerSession
|
#include "shell/persist/session.h" // ReaSamplerSession
|
||||||
|
#include "shell/view/fx_reclaim.h" // the FX reclaimer's tick
|
||||||
#include "shell/view/view.h" // applyMode
|
#include "shell/view/view.h" // applyMode
|
||||||
#include "shell/view/view_lanes.h" // reconcileManagedLanes
|
#include "shell/view/view_lanes.h" // reconcileManagedLanes
|
||||||
#include "shell/view/view_reconcile.h" // reconcileAppliedFlags
|
#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.
|
// 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
|
// 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.
|
// 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
|
// persist stays MODEL-ONLY (loads the saved view model but does not apply
|
||||||
// visibility, to avoid coupling persist to the view shell); poll() raises a
|
// visibility, to avoid coupling persist to the view shell); poll() raises a
|
||||||
@@ -197,11 +201,12 @@ static void OnTimer()
|
|||||||
// signal re-arms the bank panel's new-content detector — notified BEFORE the
|
// 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
|
// 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).
|
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
|
||||||
// modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
|
// Both latches are tested BEFORE the consume, not after the applyMode call: the load
|
||||||
// the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
|
// signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here
|
||||||
// dropped here is the saved mode never applied at all. Holding the whole block
|
// is the saved mode never applied at all. Holding the whole block back one tick also
|
||||||
// back one tick also keeps the panel re-arm ahead of the reapply.
|
// keeps the panel re-arm ahead of the reapply.
|
||||||
if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
|
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() &&
|
||||||
|
g_session.consumeLoadSignal()) {
|
||||||
reasampler::bankPanelNotifyProjectLoaded();
|
reasampler::bankPanelNotifyProjectLoaded();
|
||||||
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
||||||
// the cross-session source of truth) BEFORE reapplying visibility. Never
|
// 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);
|
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
|
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();
|
g_session.requestReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
|
// Intentional no-op: ReaSampler stores state via project EXT STATE, not this
|
||||||
// extension's own project lines. The struct is registered ONLY for the
|
// extension's own project lines.
|
||||||
// BeginLoadProjectState undo/redo notification.
|
|
||||||
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
||||||
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
||||||
{
|
{
|
||||||
return false; // we own no project lines — ext state carries our data
|
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*/)
|
project_config_extension_t* /*reg*/)
|
||||||
{
|
{
|
||||||
|
if (!isUndo)
|
||||||
|
reasampler::fxReclaimFlushRelease(g_session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
||||||
|
|||||||
@@ -7,32 +7,9 @@ namespace reasampler::view {
|
|||||||
|
|
||||||
namespace {
|
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
|
// 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
|
// 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
|
// GUID for, and reclaim has to reach those too.
|
||||||
// the resume point has something to match.
|
|
||||||
std::vector<FxOfflineOp> offlineTargets(const std::string& trackGuid,
|
std::vector<FxOfflineOp> offlineTargets(const std::string& trackGuid,
|
||||||
const std::vector<std::string>& liveFxGuids) {
|
const std::vector<std::string>& liveFxGuids) {
|
||||||
std::vector<FxOfflineOp> ops;
|
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 (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write
|
||||||
|
|
||||||
if (!model.leafBelongsToMode(guid, targetMode)) {
|
if (!model.leafBelongsToMode(guid, targetMode)) {
|
||||||
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}, {}});
|
offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Active with the FX half already released: the chain is back online and
|
// 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.
|
// the record is only waiting on its flags. Nothing owed either way.
|
||||||
if (!snap.fxHeld) continue;
|
if (!snap.fxHeld) continue;
|
||||||
online.push_back(
|
online.push_back(FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid)});
|
||||||
FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid), {}});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
online.reserve(online.size() + offline.size());
|
online.reserve(online.size() + offline.size());
|
||||||
@@ -87,14 +63,9 @@ std::vector<FxTrackWork> desiredFxState(const ViewModeModel& model,
|
|||||||
|
|
||||||
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
|
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
|
||||||
const std::vector<std::string>& liveFxGuids) {
|
const std::vector<std::string>& liveFxGuids) {
|
||||||
if (work.kind == FxWorkKind::Offline) {
|
if (work.kind == FxWorkKind::Offline)
|
||||||
return resolveFxRestore(
|
return resolveFxRestore(offlineTargets(work.trackGuid, liveFxGuids), liveFxGuids);
|
||||||
remainingAfter(offlineTargets(work.trackGuid, liveFxGuids), work.resumeAfterFxGuid,
|
return resolveFxRestore(work.targets, liveFxGuids);
|
||||||
work.resumeAfterSlot),
|
|
||||||
liveFxGuids);
|
|
||||||
}
|
|
||||||
return resolveFxRestore(
|
|
||||||
remainingAfter(work.targets, work.resumeAfterFxGuid, work.resumeAfterSlot), liveFxGuids);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace reasampler::view
|
} // namespace reasampler::view
|
||||||
|
|||||||
+10
-26
@@ -19,9 +19,13 @@ enum class FxWorkKind {
|
|||||||
Offline, // reclaim: every live FX offline
|
Offline, // reclaim: every live FX offline
|
||||||
};
|
};
|
||||||
|
|
||||||
// One track's whole FX move. Track-granular so a drain may stop between tracks,
|
// One track's whole FX move, and the ATOMIC unit of work: a drain may stop
|
||||||
// and resumable WITHIN a track by FX identity: a slot cursor would silently skip
|
// between items, never inside one, so an item is either untouched or complete
|
||||||
// or repeat a plugin if the chain were edited between two ticks.
|
// 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 {
|
struct FxTrackWork {
|
||||||
std::string trackGuid;
|
std::string trackGuid;
|
||||||
FxWorkKind kind = FxWorkKind::Offline;
|
FxWorkKind kind = FxWorkKind::Offline;
|
||||||
@@ -32,28 +36,8 @@ struct FxTrackWork {
|
|||||||
// read it, so resolveFxWork synthesises the targets from what it is handed.
|
// read it, so resolveFxWork synthesises the targets from what it is handed.
|
||||||
std::vector<FxOfflineOp> targets;
|
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 {
|
bool operator==(const FxTrackWork& o) const {
|
||||||
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets &&
|
return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets;
|
||||||
resumeAfterFxGuid == o.resumeAfterFxGuid &&
|
|
||||||
resumeAfterSlot == o.resumeAfterSlot;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,8 +66,8 @@ std::vector<FxOfflineOp> fxRestoreTargets(const ViewModeModel& model,
|
|||||||
|
|
||||||
// Resolves one work item against the chain as it stands, where `liveFxGuids[i]`
|
// 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
|
// 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
|
// none) — the item's WHOLE move, matched by identity: an identity that is not
|
||||||
// not live is DROPPED, never re-pointed at a slot.
|
// live is DROPPED, never re-pointed at a slot.
|
||||||
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
|
FxRestoreResolution resolveFxWork(const FxTrackWork& work,
|
||||||
const std::vector<std::string>& liveFxGuids);
|
const std::vector<std::string>& liveFxGuids);
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ std::vector<std::string> selectedTrackGuids() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
|
// 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() {
|
void reapplyActiveMode() {
|
||||||
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,16 @@ bool ReaSamplerSession::saveViewStateOnly() {
|
|||||||
return true;
|
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) {
|
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||||
ReaProject* proj = activeSavedProject();
|
ReaProject* proj = activeSavedProject();
|
||||||
if (!proj) return false;
|
if (!proj) return false;
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
#include "core/version/app_version.h"
|
#include "core/version/app_version.h"
|
||||||
#include "core/view/view_mode_model.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 {
|
namespace reasampler {
|
||||||
|
|
||||||
// Owns the session's BankBook (pool + named banks) and drives persistence
|
// 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.
|
// Returns on the SAME rule as saveToActiveProject above, prohibition included.
|
||||||
bool saveViewStateOnly();
|
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,
|
// Report-only prune dry-run: feeds the pure core with (present, referenced,
|
||||||
// owned) — `present` from the folder enumeration, the other two from the
|
// owned) — `present` from the folder enumeration, the other two from the
|
||||||
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
|
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
#include "core/view/solo_cache.h"
|
#include "core/view/solo_cache.h"
|
||||||
#include "core/view/view_tree.h"
|
#include "core/view/view_tree.h"
|
||||||
|
#include "shell/view/fx_reclaim.h"
|
||||||
#include "shell/view/view_lanes.h"
|
#include "shell/view/view_lanes.h"
|
||||||
#include "shell/view/view_park.h"
|
#include "shell/view/view_park.h"
|
||||||
#include "shell/view/view_reconcile.h"
|
#include "shell/view/view_reconcile.h"
|
||||||
@@ -61,7 +62,9 @@ bool transportBlocksModeSwitch(ReaProject* proj) {
|
|||||||
|
|
||||||
bool modeApplyInProgress() { return g_applying; }
|
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
|
if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below
|
||||||
ApplyLatch latch;
|
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
|
// The tool's own account of what it just left driven — the only one that survives
|
||||||
// an undo rolling the model back underneath it.
|
// an undo rolling the model back underneath it.
|
||||||
noteAppliedState(project, model);
|
noteAppliedState(project, model);
|
||||||
|
|
||||||
return true;
|
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
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ bool modeApplyInProgress();
|
|||||||
// (modeApplyInProgress), or if the project does not validate. A reapply (target ==
|
// (modeApplyInProgress), or if the project does not validate. A reapply (target ==
|
||||||
// active) is never gated and never touches solo. `proj` == nullptr means the current
|
// active) is never gated and never touches solo. `proj` == nullptr means the current
|
||||||
// project, resolved ONCE and used for every call this apply makes.
|
// 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);
|
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -22,28 +22,6 @@ namespace reasampler {
|
|||||||
|
|
||||||
namespace {
|
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
|
// 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
|
// parking. Never reads B_MUTE/I_SOLO; ints preserve whatever REAPER reported
|
||||||
// (TrackSnapshot's defensive contract). The plain 0..TrackFX_GetCount-1 enumeration
|
// (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;
|
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) {
|
bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
|
||||||
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
|
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
|
||||||
SetMediaTrackInfo_Value(tr, parm, value);
|
SetMediaTrackInfo_Value(tr, parm, value);
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ inline const char* trackFlagParm(Flag f) {
|
|||||||
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
|
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
|
// 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
|
// `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`
|
// reclaimer hasn't run) and appends whatever identity `live` sees that `prior`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -240,74 +240,6 @@ static void testSlotKeyedRecordStillRestoresByPosition() {
|
|||||||
CHECK(writeAt(res, 2, false));
|
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() {
|
int main() {
|
||||||
testActiveTrackIsOwedOnlineInactiveIsOwedOffline();
|
testActiveTrackIsOwedOnlineInactiveIsOwedOffline();
|
||||||
testOnlineWorkSortsBeforeOfflineWork();
|
testOnlineWorkSortsBeforeOfflineWork();
|
||||||
@@ -321,10 +253,6 @@ int main() {
|
|||||||
testAddedFxIsLeftAlone();
|
testAddedFxIsLeftAlone();
|
||||||
testSlotKeyedRecordStillRestoresByPosition();
|
testSlotKeyedRecordStillRestoresByPosition();
|
||||||
|
|
||||||
testResumePointSkipsWhatTheDrainAlreadyDid();
|
|
||||||
testResumePointThatLeftTheChainRestartsRatherThanStrands();
|
|
||||||
testResumeAfterUnidentifiedFxMakesForwardProgress();
|
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user