Files
reasampler/src/shell/view/fx_reclaim.cpp
T
daniel b7b93a7ac3 One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act
The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
2026-08-07 16:38:11 -04:00

321 lines
16 KiB
C++

// 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/undo/undo_block.h" // undoBlockOpen — the reverse of the nesting rule
#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;
// The one open undo block a mode switch and its parking share — see UndoSpan.
UndoSpan g_span;
// The ONE open and the ONE close. Both idempotent, so every caller may say either
// unconditionally; the close reads the span's values before clearing it, because the
// clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may
// find a span it could close a second time.
void openSpan(ReaProject* proj, const char* label, int mask) {
if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj);
}
void closeSpan() {
if (!g_span.open()) return;
ReaProject* const proj = g_span.project();
const std::string label = g_span.label();
const int mask = g_span.mask();
g_span.end();
// The span outlives its opener, so by here the project may be the one that just
// closed — its undo history went with it and there is no block left to end.
if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask);
}
// Monotonic and sub-millisecond: the drain's budget 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 monotonicMs() {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
// 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_;
}
double nowMs() const { return monotonicMs(); }
// 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); }
// Refusing here ends the drain's REMAINING tracks after an abandon: drainTick treats a
// false as "this track is gone", so the rest of the queue costs one bool apiece and
// reaches no REAPER call at all.
bool liveChain(const std::string& guid, std::vector<std::string>& out) {
if (g_queue.abandoned()) return false;
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;
}
// ...and here for the track being written RIGHT NOW, whose own pump is how the state
// load arrived. FIRST, before the openSpan below: a write past the terminator would
// both land in the project the load is replacing and reopen the span it just closed.
bool writeOffline(const std::string& guid, int fxIndex, bool offline) {
if (g_queue.abandoned()) return false;
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
// Every write in the drain rides ONE span. Do not reintroduce a block per write:
// it minted one undo point per FX toggle, because the ("", 0) discard form
// coalesces rather than discards outside an action (see UndoSpan). The open is a
// no-op under a switch's span, which is why the FX bit is added separately —
// here, at the first real write, so a switch whose parking never matured closes
// on the flags-and-items mask it opened with and serializes no FX state.
openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX);
g_span.widen(UNDO_STATE_FX);
TrackFX_SetOffline(tr, fxIndex, offline);
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(); }
FxReclaimActivity fxReclaimActivity() {
// The QUEUE's own pin, never the active tab: the direction is a fact about the project
// the drain is converging, and reading another tab's transport into it would gate the
// answer on a playhead that has nothing to do with this work. No pin means idle — and
// costs the caller no REAPER call at all, which is what makes this safe to poll per
// frame.
ReaProject* const proj = g_queue.project();
if (!proj) return FxReclaimActivity::Idle;
// The pin is only re-tested at beginTick; a poll landing between two ticks can find the
// project already gone, and GetPlayStateEx is about to be handed this pointer.
if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return FxReclaimActivity::Idle;
return g_queue.activity(monotonicMs(), transportBlocksModeSwitch(proj));
}
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) {
// NO guard here may close the span: each names a caller that is still on the stack and
// still owns it — the outer drain, or the applyMode that opened it. All re-tick.
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;
// The MIRROR of the rule that no block may open over the span: an extension undo
// block is open — a capture, a bank verb, an ingest — and its own message pump is how
// this tick arrived. openSpan from in there would begin at depth 2, and that block's
// close would then mint under the SPAN's label and destroy the user's own point.
// Refused whole rather than written unspanned, which would instead absorb the FX
// toggles into the user's act. Starves nothing: the wakeup, the derived queue and the
// project pin are all untouched (this returns before beginTick), so the first tick
// after the block closes drains exactly as if this one had never been due.
if (undoBlockOpen()) 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;
// A span belongs to the project it was opened on. Keyed on the SPAN's own project
// rather than the queue's pin, which moves independently: a tab switch, a closed
// project, or no active project at all ends it where it stands rather than carrying
// it into another project's history — and the no-project arm below returns.
if (g_span.open() && g_span.project() != project) closeSpan();
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 (spanEndsAfter(run)) closeSpan();
if (!run.aborted && run.settled) g_queue.goIdle();
}
void fxReclaimBeginUndoSpan(ReaProject* proj) {
openSpan(proj, "", 0);
}
void fxReclaimLabelUndoSpan(const char* label, int mask) {
g_span.relabel(label ? label : "", mask);
}
void fxReclaimEndUndoSpan() { closeSpan(); }
void fxReclaimAbandonForStateLoad() {
// Close FIRST and unconditionally. The abandon below stops the drain and puts the loop
// to sleep; the block is what a Ctrl-Z would otherwise close over the wrong state, and
// it is open whether or not a drain is on the stack at all.
closeSpan();
g_queue.abandon();
}
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();
// BEFORE the arm test, and unconditionally: a save is a user act the span must not
// absorb, and the arm is not what decides whether one is open.
closeSpan();
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