#pragma once // Design View's per-FX offline surface: the FX-identity read that snapshot, // park and restore all address FX through, the deferred intent queue that keeps // TrackFX_SetOffline off the mode switch's synchronous path, and the two drains // that apply it — restores whole, parks one FX per idle tick. See // src/shell/view/CLAUDE.md's FX-parking caveat. #include #include #include "core/view/fx_offline.h" // Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. class MediaTrack; class ReaProject; namespace reasampler { // The chain as it stands now: identity by current slot. Snapshot, park and // restore all address FX through this one plain 0..TrackFX_GetCount-1 // enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so // whatever it covers, all three cover identically. std::vector liveFxGuids(MediaTrack* tr); // One deferred per-FX intent for one track. A park carries no ops (every live // slot goes offline); a restore carries the planned ops verbatim. struct FxParkIntent { std::string guid; bool park = false; // A park write is landing or has landed on this track, so the live chain // matches NEITHER endpoint. Parks apply one FX per idle tick, so this is a // state a park genuinely sits in — and while it holds, the cancel rule // below is unsafe in both directions: only the restore's own ops can put // back what a half-finished park offlined, and a park arriving on that // restore must RESUME rather than annihilate or it strands every FX the // first pass had not reached. Restores carry no such state: they are // detached whole and applied in one drain. bool partial = false; std::vector restoreOps; }; // The queue's re-entrancy rule, pure so it can be asserted without a DAW: at // most ONE intent per track GUID, and the latest one wins. Park and restore are // inverses, so an UNSTARTED intent landing on its own pending inverse CANCELS it // rather than stacking — the queued work never ran, so the track already holds // the state the newcomer asks for, and replaying both would be both slower and // observably wrong. That cancel is what makes a rapid A→B→A mode flip cost zero // plugin work: B→A's restore annihilates A's park before a single FX moved. class FxParkQueue { public: // Returns the ops of a pending restore this park CANCELLED, empty otherwise. // The caller needs them: that restore never ran, so the live chain still // reads the PARKED offline states and is no longer a source for a fresh // pre-park snapshot (see preParkFxFromCancelledRestore). // // The empty return is a LOAD-BEARING sentinel that deliberately conflates // "nothing was cancelled" with "cancelled a restore carrying no ops": a // zero-op restore held no FX state to hand back, so falling through to a // live chain read is the same answer, not a worse one. std::vector park(const std::string& guid) { FxParkIntent* held = find(guid); if (!held) { pending_.push_back(FxParkIntent{guid, true, false, {}}); return {}; } if (held->park) return {}; std::vector cancelled = std::move(held->restoreOps); if (held->partial) *held = FxParkIntent{guid, true, true, {}}; else erase(held); return cancelled; } // A restore cancelling an UNSTARTED park is COMPLETE at that point — the // park never ran, so no drain will ever come for this GUID. Against a park // that has already written, the restore supersedes instead: its ops are the // only thing that can put the offlined FX back. void restore(const std::string& guid, std::vector ops) { FxParkIntent* held = find(guid); if (!held) { pending_.push_back(FxParkIntent{guid, false, false, std::move(ops)}); } else if (held->park && !held->partial) { erase(held); } else if (held->park) { *held = FxParkIntent{guid, false, true, std::move(ops)}; } else { held->restoreOps = std::move(ops); } } // Enqueue order, which is apply order WITHIN a kind — the two kinds drain // separately and on different schedules (see the two drains below). const std::vector& pending() const { return pending_; } bool empty() const { return pending_.empty(); } void clear() { pending_.clear(); } // Detaches every pending RESTORE and leaves the parks behind, in order. The // detach — not a live iteration, not a clear afterwards — is what lets the // queue accept intents enqueued WHILE the caller applies what it took: // [verify — DAW] applying loads/unloads plugins, which is ASSUMED to pump // the message loop, so a re-entrant switch can enqueue mid-apply. Iterating // the live queue would dangle on the push_back and clearing it afterwards // would discard whatever arrived. Correct either way; only the need for it // is unconfirmed. std::vector takeRestores() { std::vector taken; std::vector kept; for (FxParkIntent& i : pending_) { if (i.park) kept.push_back(std::move(i)); else taken.push_back(std::move(i)); } pending_.swap(kept); return taken; } // The park the drain should work next, in enqueue order; empty when none is // pending. A PEEK, by value — an apply can reshape the queue under a pointer // — and it marks nothing: a park still cancellable for free stays that way // until markPartial says a write is imminent. std::string nextParkGuid() const { for (const FxParkIntent& i : pending_) if (i.park) return i.guid; return {}; } // Records that a park write is ABOUT TO land on `guid` — set before the // write, because the write may pump the message loop and an intent arriving // in that window must already see a chain that matches neither endpoint. void markPartial(const std::string& guid) { if (FxParkIntent* held = find(guid)) held->partial = true; } // Retires a park with nothing left to offline. A no-op when a restore // replaced it mid-apply — that restore is the newer intent and owns the // chain from here. void finishPark(const std::string& guid) { FxParkIntent* held = find(guid); if (held && held->park) erase(held); } private: FxParkIntent* find(const std::string& guid) { for (FxParkIntent& i : pending_) if (i.guid == guid) return &i; return nullptr; } void erase(FxParkIntent* held) { pending_.erase(pending_.begin() + (held - pending_.data())); } std::vector pending_; }; // The FX half of a snapshot, with the keying it must be read back under. struct PreParkFx { std::vector states; FxKeying keying = FxKeying::Identity; }; // The FX half a fresh pre-park snapshot must carry when the park CANCELLED a // pending restore: those ops are the only surviving record of the pre-park // state, because the chain still reads the parked values until that restore // drains — and it never will, the cancel dropped it. Empty in (nothing was // cancelled) means the caller reads the live chain instead. The restore's own // keying travels with it so a slot-keyed snapshot lifted from a legacy // view_state does not silently become an identity-keyed one with no identities. // // FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was // at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX // added while the track was parked (a floating FX-chain window, ReaScript) is // absent from this reconstruction yet IS offlined by the cancelling park's // drain — and so never comes back online. Rare, and recoverable by hand in the // FX chain, but specific to the deferral. // // `offline` is already boolean by the time it arrives: makeRestorePlan narrowed // FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to // int restores the type, not lost information. inline PreParkFx preParkFxFromCancelledRestore(const std::vector& cancelled) { PreParkFx out; if (cancelled.empty()) return out; out.keying = cancelled.front().keying; out.states.reserve(cancelled.size()); for (const FxOfflineOp& op : cancelled) out.states.push_back(FxOfflineState{op.fxGuid, op.offline ? 1 : 0}); return out; } // The FX half of a fresh pre-park snapshot for `tr`: whatever the accompanying // park cancelled, else a live read of the chain. PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector& cancelled); // Enqueue against `proj` (nullptr = current project). An enqueue naming a // different project than the pending intents discards those unapplied. The park // returns whatever pending restore it cancelled, per FxParkQueue::park. std::vector deferFxPark(ReaProject* proj, const std::string& guid); void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector ops); // The two halves of the drain, split because only ONE of them is unsafe to // persist over. Both touch NO model state — a restore's snapshot is dropped // where the restore is planned (applyMode) — both discard the queue unapplied if // the project it was enqueued against is no longer current (close / switch), // both prune an intent whose track is gone, both cost one empty-queue test when // idle, and both early-out re-entrantly: one drain at a time, the outer one owns // the queue. // // RESTORES, applied whole, synchronously. Called on the idle tick AND before the // view model is serialized (persistViewState, render_in_place). The second call // is not an optimization: between a restore's synchronous flag writes and its // drain the FX are still offline while the model has already dropped the // snapshot that would replan them, so a save inside that window — deterministic // under a custom action chain like "toggle mode; save project" — records offline // FX beside a snapshot-free model, and nothing on reopen brings them back // online. The cost is that an action-driven switch pays the RELOAD hitch before // it returns; its repaint and undo block have both closed by then, which is what // the deferral was for. void drainDeferredFxRestores(); // PARKS, one FX per idle tick behind a short coalescing delay. Nothing waits on // a park, and unlike a restore it is safe to persist over: the flags are already // parked and the snapshot already stored, so a .rpp saved mid-window reopens with // the FX online, applyMode replans a park for that inactive leaf, and the drain // offlines it. It converges on its own, which is why this half does not force. // // One FX, not one track: a single convolution reverb or loaded sampler is the // unit of cost, so per-track chunking would not bound the hitch. // // The delay's cost is the width of a documented hazard, not a new one. applyPark // enumerates the chain LIVE at drain time while the pre-park snapshot was taken // at switch time, so an FX added inside the window is offlined carrying no // snapshot entry — resolveFxRestore then has no op for it and it stays offline. // Same failure as the cancelled-restore case below, over a longer window; keep // the delay short. void tickDeferredFxParks(); // Drops every pending intent without applying it. Called when the model the // intents were planned against has been replaced (project load/switch, undo/redo // state restore) — applying them then would write the pre-reload plan over the // project that replaced it. // // Not pure loss: the caller reapplies the active mode over the reloaded model // immediately after, which re-plans a park for every inactive leaf, so parked FX // converge on the following drain. The case that does NOT self-heal is a track // whose reloaded model carries no snapshot — nothing plans a restore for it, so // FX left offline stay offline. void discardDeferredFxParks(); } // namespace reasampler