Key Design View's parked FX-offline state to the FX's own GUID, not its slot

view_state v2 writes identities beside the v1 slot array, so a downgrade keeps
what it had. An FX gone at restore time is dropped and reported, never restored
onto whatever took its place.
This commit is contained in:
2026-08-02 18:31:08 -04:00
parent 4231b2321c
commit 5f6efb7cc3
10 changed files with 780 additions and 58 deletions
+9 -1
View File
@@ -42,7 +42,9 @@ settled 2026-07-23):
reapply touches solo not at all.
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
`ViewModeModel::reconcile(liveGuids)`).
`ViewModeModel::reconcile(liveGuids)`). The same rule binds one level down: a
parked track's per-FX offline state keys on the FX's own identity
(`TrackFX_GetFXGUID`), never its slot — see `fx_offline`.
- **Relative/portable state only** in the persisted view section (GUID strings,
mode ids — no absolute paths, no index positions).
- **Show-both semantics.** A per-track "pin visible across modes" flag that
@@ -95,6 +97,7 @@ settled 2026-07-23):
## Modules
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
- `fx_offline` — the per-FX offline snapshot's KEY and its restore resolution: `FxKeying` (Identity / Slot), `FxOfflineState`, the planned `FxOfflineOp`, and `resolveFxRestore`, which matches each captured state to the FX it came from against the chain as it stands at restore time. An identity that is no longer live is DROPPED and counted (`FxRestoreDrops`, reported through `describeFxRestoreDrops`), never re-pointed at a slot — the slot fallback is precisely the reorder bug identity keying exists to close. Slot keying survives only for snapshots lifted from a pre-identity `view_state` and for park plans, where every live slot is the target by construction.
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header.
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
@@ -110,6 +113,11 @@ settled 2026-07-23):
- `kManagedLanePrefix` ("reasampler:") is stable-forever like an action-id
string — changing it strands the ownership of every already-minted lane in
every already-saved project.
- The `view_state` blob's version ladder lives beside `kViewStateVersion` in
`view_mode_model.cpp`. v2 writes the v1 slot array BESIDE the identity array so
a downgrade keeps the behavior it had; the version field is written but
deliberately not validated on read, because an unreadable `view_state` falls
back to a default model and loses every membership tag.
- `guid_diff::GuidBaseline` must have `reset()` called on every detected
project switch, or the next `observe()` will diff across two unrelated
projects and mass-tag (or miss) content.
+8 -4
View File
@@ -4,12 +4,16 @@ reasampler_test(lane_keys LINK lane_keys)
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
reasampler_test(solo_cache LINK solo_cache)
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
# consumer has to resolve those symbols too.
reasampler_pure_library(fx_offline SOURCES fx_offline.cpp)
reasampler_test(fx_offline LINK fx_offline)
# lane_keys, solo_cache and fx_offline are PUBLIC: the lane-minting plan names managed lanes
# through the one durable-key convention, ViewModeModel exposes the SoloCache by reference, and
# TrackSnapshot/TrackPlan carry the FX-offline types by value — so every consumer has to
# resolve those symbols too.
reasampler_pure_library(view_mode_model
SOURCES view_mode_model.cpp
LINK PRIVATE json PUBLIC lane_keys solo_cache)
LINK PRIVATE json PUBLIC lane_keys solo_cache fx_offline)
reasampler_test(view_mode_model LINK view_mode_model)
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
+69
View File
@@ -0,0 +1,69 @@
#include "core/view/fx_offline.h"
#include <map>
namespace reasampler {
namespace {
// Identity -> current slot. First occurrence wins: REAPER mints one GUID per FX
// instance, so a repeat can only come from a corrupt/hand-edited chain, and
// picking one deterministically beats writing twice.
std::map<std::string, int> slotByIdentity(const std::vector<std::string>& liveFxGuids) {
std::map<std::string, int> byGuid;
for (std::size_t i = 0; i < liveFxGuids.size(); ++i) {
if (liveFxGuids[i].empty()) continue; // unidentifiable FX is not a restore target
byGuid.emplace(liveFxGuids[i], static_cast<int>(i));
}
return byGuid;
}
} // namespace
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids) {
FxRestoreResolution res;
const std::map<std::string, int> byGuid = slotByIdentity(liveFxGuids);
const int liveCount = static_cast<int>(liveFxGuids.size());
for (const FxOfflineOp& op : planned) {
if (op.keying == FxKeying::Identity) {
// An empty identity (REAPER reported none at capture) is unresolvable
// like any other miss — it does not get to use its slot instead.
auto it = byGuid.find(op.fxGuid);
if (op.fxGuid.empty() || it == byGuid.end()) {
++res.drops.missingIdentity;
continue;
}
res.writes.push_back(FxOfflineWrite{it->second, op.offline});
} else {
if (op.slot < 0 || op.slot >= liveCount) {
++res.drops.slotOutOfRange;
continue;
}
res.writes.push_back(FxOfflineWrite{op.slot, op.offline});
}
}
return res;
}
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) {
if (drops.total() <= 0) return {};
std::string msg = "ReaSampler: Design View restore dropped " +
std::to_string(drops.total()) + " captured FX offline state(s) on " +
std::to_string(trackCount) + " track(s) -- ";
if (drops.missingIdentity > 0) {
msg += std::to_string(drops.missingIdentity) +
" FX no longer in the chain (deleted or replaced while parked)";
}
if (drops.slotOutOfRange > 0) {
if (drops.missingIdentity > 0) msg += ", ";
msg += std::to_string(drops.slotOutOfRange) +
" from a project saved before FX identity was recorded, whose slot no longer exists";
}
msg += ". Every other FX was left as it was.\n";
return msg;
}
} // namespace reasampler
+90
View File
@@ -0,0 +1,90 @@
#pragma once
// The per-FX offline snapshot's key and its restore resolution: what a captured
// FX state is keyed BY, and how that key resolves against the chain as it stands
// at restore time. Pure — the FX identity is an opaque string the shell reads
// from REAPER (TrackFX_GetFXGUID) and hands in.
#include <string>
#include <vector>
namespace reasampler {
// How a set of per-FX entries is keyed. `Slot` is the position-addressed shape:
// a park plan (every live slot, by construction) or a snapshot lifted from a
// project saved before identity was recorded. A live snapshot is always
// `Identity` — a chain reordered while the track is parked makes a slot a lie.
enum class FxKeying { Identity, Slot };
// One FX's captured offline state. Under Identity keying `fxGuid` is that FX's
// own durable identity; under Slot keying it is empty and the entry's POSITION
// in the snapshot is the slot it was captured from.
struct FxOfflineState {
std::string fxGuid;
int offline = 0; // int, not bool — mirrors TrackSnapshot's defensive contract
bool operator==(const FxOfflineState& o) const {
return fxGuid == o.fxGuid && offline == o.offline;
}
};
// One per-FX offline write as PLANNED. The keying travels with the op so an
// entry whose identity is missing can never silently degrade into a slot write
// — that fallback is the reorder bug this keying exists to close.
struct FxOfflineOp {
std::string guid; // track GUID
FxKeying keying = FxKeying::Identity;
std::string fxGuid; // FX identity, under Identity keying
int slot = 0; // write target under Slot keying only
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && keying == o.keying && fxGuid == o.fxGuid &&
slot == o.slot && offline == o.offline;
}
};
// One resolved write: TrackFX_SetOffline(track, fxIndex, offline).
struct FxOfflineWrite {
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineWrite& o) const {
return fxIndex == o.fxIndex && offline == o.offline;
}
};
// Captured state a restore could not apply. Both counts mean the same act: the
// entry was dropped and no FX was written in its place.
struct FxRestoreDrops {
int missingIdentity = 0; // identity-keyed entry with no live FX carrying that GUID
int slotOutOfRange = 0; // slot-keyed entry whose capture-time slot no longer exists
int total() const { return missingIdentity + slotOutOfRange; }
void add(const FxRestoreDrops& o) {
missingIdentity += o.missingIdentity;
slotOutOfRange += o.slotOutOfRange;
}
bool operator==(const FxRestoreDrops& o) const {
return missingIdentity == o.missingIdentity && slotOutOfRange == o.slotOutOfRange;
}
};
struct FxRestoreResolution {
std::vector<FxOfflineWrite> writes; // in planned order
FxRestoreDrops drops;
};
// Resolves each planned op against the live chain, where `liveFxGuids[i]` is the
// identity of the FX at slot `i` right now (empty when REAPER reported none).
// An identity that is not live is DROPPED, never re-pointed at a slot; a live FX
// no op names is left entirely alone.
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids);
// One console line describing a whole apply's drops, or "" when nothing was
// dropped — the degrade is reported rather than swallowed.
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount);
} // namespace reasampler
+97 -8
View File
@@ -196,8 +196,10 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
// Park has no identity question to answer: it offlines every slot that is
// live right now, so slot keying IS the addressing.
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back({guid, i, true});
p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
return p;
}
@@ -209,8 +211,11 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) {
const FxOfflineState& fx = snap.fxOffline[i];
p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return p;
}
@@ -361,13 +366,33 @@ using json::writeIntArray;
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
// v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT
// v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written
// "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
// had rather than losing every captured FX state to an unknown key. Reading, "fx"
// wins outright; a blob carrying only "fxOffline" lifts to a Slot-keyed snapshot
// and restores by slot ONCE, which is the only thing its bytes can support (the
// restore then clears it, so the next park captures identities).
//
// "version" is WRITTEN but deliberately not validated on read: an unreadable
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;
} // namespace
std::string ViewModeModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyRaw("version", intToStr(kViewStateVersion));
root.keyStr("activeMode", activeModeId_);
root.keyBegin("modes");
@@ -410,7 +435,8 @@ std::string ViewModeModel::serialize() const {
}
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable,
// fxOffline[], fx[] } — see the version ladder above.
root.keyBegin("snapshots");
out += '[';
{
@@ -424,8 +450,28 @@ std::string ViewModeModel::serialize() const {
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
e.keyRaw("mainSend", intToStr(snap.mainSend));
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
std::vector<int> bySlot;
bySlot.reserve(snap.fxOffline.size());
for (const FxOfflineState& fx : snap.fxOffline) bySlot.push_back(fx.offline);
e.keyBegin("fxOffline");
writeIntArray(out, snap.fxOffline);
writeIntArray(out, bySlot);
// A Slot-keyed snapshot has no identities to write — emitting an
// "fx" array for it would invent the very keys it lacks.
if (snap.fxKeying == FxKeying::Identity) {
e.keyBegin("fx");
out += '[';
bool firstFx = true;
for (const FxOfflineState& fx : snap.fxOffline) {
if (!firstFx) out += ',';
firstFx = false;
ObjWriter f(out);
f.keyStr("guid", fx.fxGuid);
f.keyRaw("offline", intToStr(fx.offline));
}
out += ']';
}
}
}
out += ']';
@@ -537,6 +583,32 @@ bool parseMembership(json::Reader& r, MembershipIndex& idx) {
return r.consume(']');
}
// The identity-keyed "fx" array. Both keys are mandatory (strict like parseLanes);
// an EMPTY guid is accepted, because a live capture records one when REAPER
// reported no identity for that FX — the entry is honest about being unresolvable
// rather than being silently dropped at write time.
bool parseFxStates(json::Reader& r, std::vector<FxOfflineState>& out) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
FxOfflineState fx;
bool haveGuid = false, haveOffline = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "guid") { if (!r.parseString(fx.fxGuid)) return false; haveGuid = true; }
else if (k == "offline") { if (!r.parseInt(fx.offline)) return false; haveOffline = true; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || !haveOffline) return false;
out.push_back(fx);
} while (r.consume(','));
return r.consume(']');
}
bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
if (!r.consume('[')) return false;
r.skipWs();
@@ -546,6 +618,9 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
std::string guid;
TrackSnapshot snap;
bool haveGuid = false;
std::vector<int> bySlot;
std::vector<FxOfflineState> byIdentity;
bool haveIdentity = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
@@ -554,12 +629,26 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; }
else if (k == "fx") {
if (!parseFxStates(r, byIdentity)) return false;
haveIdentity = true;
}
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap;
// "fx" wins outright — v2 writes the slot array beside it purely so an
// older build can still read something (see the version ladder above).
if (haveIdentity) {
snap.fxOffline = std::move(byIdentity);
snap.fxKeying = FxKeying::Identity;
} else {
for (int offline : bySlot) snap.fxOffline.push_back(FxOfflineState{{}, offline});
snap.fxKeying = FxKeying::Slot;
}
snaps[guid] = std::move(snap);
} while (r.consume(','));
return r.consume(']');
}
+11 -17
View File
@@ -12,6 +12,7 @@
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/solo_cache.h"
namespace reasampler {
@@ -201,13 +202,16 @@ struct TrackSnapshot {
int mainSend = 0; // B_MAINSEND prior value
int fxEnable = 0; // I_FXEN prior value
// Prior per-FX offline state, index = fx slot.
std::vector<int> fxOffline;
// Prior per-FX offline state in capture-time slot order, keyed per fxKeying:
// by the FX's own identity (live capture), or by position (a snapshot lifted
// from a project saved before identity was recorded).
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline;
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
}
};
@@ -231,17 +235,6 @@ struct TrackFlagOp {
}
};
// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
std::string guid;
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
}
};
// One managed-lane play/show write the shell must apply (translated into
// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes
// only — never a manual lane; enforced in planToggle and mirrored by
@@ -257,9 +250,10 @@ struct LanePlayOp {
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. fxOffline is
// per known FX slot: on park all slots go offline (from the snapshot's slot
// count); on restore each slot returns to its captured value.
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
+4 -1
View File
@@ -34,7 +34,10 @@ decide membership or mode rules.
value BEFORE parking; on toggle-back restore FROM the snapshot, never to a
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
to its captured value — the phase's trust anchor, the analog of the capture null
test.
test. Per-FX offline is snapshotted WITH each FX's identity (`TrackFX_GetFXGUID`)
and restored through `core/view/fx_offline`, so a chain reordered while the track
was parked cannot land one plugin's state on another; an FX gone at restore time
is dropped and reported to the console, never restored onto its old slot.
- **GUID-keyed, reorder-safe.** Membership/snapshot keys on track GUID
(`GetTrackGUID`), never track index; tolerates unknown/stale GUIDs (pruned on
reconcile).
+53 -20
View File
@@ -14,6 +14,7 @@
#include <vector>
#include "shell/capture/item_read.h"
#include "core/view/fx_offline.h"
#include "core/view/lane_keys.h"
#include "core/view/solo_cache.h"
#include "shell/capture/track_guid.h"
@@ -27,9 +28,12 @@
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows
@@ -123,6 +127,29 @@ MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& hand
return nullptr; // stale/deleted GUID — pruned by being skipped
}
// 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.
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);
}
// 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<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
return guids;
}
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
TrackSnapshot snapshotTrack(MediaTrack* tr) {
@@ -132,12 +159,13 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) {
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
int fxCount = TrackFX_GetCount(tr);
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) {
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
const std::vector<std::string> guids = liveFxGuids(tr);
snap.fxOffline.reserve(guids.size());
for (std::size_t fx = 0; fx < guids.size(); ++fx) {
snap.fxOffline.push_back(
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
}
return snap;
return snap; // fxKeying stays Identity — a live capture always knows the chain
}
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
@@ -155,20 +183,13 @@ void parkFxOffline(MediaTrack* tr) {
}
}
// Restores per-FX offline from the snapshot verbatim — each slot back to its
// captured value, never a blanket "online" — bounds-checked against the live
// FX count (prune-safe if the chain changed while parked).
//
// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not
// plugin identity. If the FX chain reshuffled while parked, snapshot slot k
// restores onto whatever plugin now occupies slot k. Accepted for now;
// identity-based reconciliation is future hardening.
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
int fxCount = TrackFX_GetCount(tr);
for (const FxOfflineOp& op : fxOffline) {
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
}
// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
return res.drops;
}
// Managed-lane application: the pure planner keys LanePlayOps by the lane's
@@ -451,6 +472,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
}
// RESTORE: apply verbatim, then drop the consumed snapshot.
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
@@ -458,10 +481,20 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (!tr) continue; // stale GUID — prune
applyFlags(tr, tp.flags);
restoreFxOffline(tr, tp.fxOffline);
const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
if (drops.total() > 0) {
fxDrops.add(drops);
++fxDropTracks;
}
model.clearSnapshot(guid);
}
// Captured FX state that could not be applied is REPORTED. Silence here would
// read to the user as "restore worked" while an FX sat at whatever state the
// park left it in.
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
if (!fxDropMsg.empty()) ShowConsoleMsg(fxDropMsg.c_str());
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
// and every other managed lane is silenced+hidden. Empty for a D1-only
// project, leaving that behavior byte-identical.