Merge Design View FX-GUID keying: parked FX-offline state follows the plugin, not the slot
This commit is contained in:
@@ -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 — see the `FxKeying` and `resolveFxRestore` comments in `fx_offline.h` for why. 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#include "core/view/fx_offline.h"
|
||||
|
||||
#include <cstddef>
|
||||
#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) {
|
||||
if (op.fxGuid.empty()) {
|
||||
// REAPER reported no GUID at capture time — distinct from a real
|
||||
// captured identity going missing: the FX may still be live, we
|
||||
// just never had a name for it. Counted separately so the report
|
||||
// never claims it was deleted (see describeFxRestoreDrops).
|
||||
++res.drops.unidentified;
|
||||
continue;
|
||||
}
|
||||
auto it = byGuid.find(op.fxGuid);
|
||||
if (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) -- ";
|
||||
|
||||
std::vector<std::string> clauses;
|
||||
if (drops.missingIdentity > 0) {
|
||||
clauses.push_back(std::to_string(drops.missingIdentity) +
|
||||
" FX no longer in the chain (deleted or replaced while parked)");
|
||||
}
|
||||
if (drops.unidentified > 0) {
|
||||
clauses.push_back(std::to_string(drops.unidentified) +
|
||||
" FX REAPER could not identify at capture time (no GUID reported), "
|
||||
"so it could not be matched now");
|
||||
}
|
||||
if (drops.slotOutOfRange > 0) {
|
||||
clauses.push_back(std::to_string(drops.slotOutOfRange) +
|
||||
" from a project saved before FX identity was recorded, whose slot no longer exists");
|
||||
}
|
||||
for (std::size_t i = 0; i < clauses.size(); ++i) {
|
||||
if (i) msg += ", ";
|
||||
msg += clauses[i];
|
||||
}
|
||||
|
||||
// Every dropped entry is still sitting exactly where park left it — offline
|
||||
// — because the restore that would have flipped it back never ran. Say
|
||||
// that, not the reassuring-but-wrong "left as it was" (park itself was the
|
||||
// change; restore is what didn't happen for these).
|
||||
msg += ". Each was left offline, as park left it, with no snapshot left to "
|
||||
"restore it -- switch it back on by hand.\n";
|
||||
return msg;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,93 @@
|
||||
#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; see
|
||||
// FxKeying above and resolveFxRestore below for why a missing identity is
|
||||
// never re-pointed at a slot.
|
||||
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. All three 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 unidentified = 0; // identity-keyed entry whose captured fxGuid was itself empty
|
||||
int slotOutOfRange = 0; // slot-keyed entry whose capture-time slot no longer exists
|
||||
|
||||
int total() const { return missingIdentity + unidentified + slotOutOfRange; }
|
||||
|
||||
void add(const FxRestoreDrops& o) {
|
||||
missingIdentity += o.missingIdentity;
|
||||
unidentified += o.unidentified;
|
||||
slotOutOfRange += o.slotOutOfRange;
|
||||
}
|
||||
|
||||
bool operator==(const FxRestoreDrops& o) const {
|
||||
return missingIdentity == o.missingIdentity && unidentified == o.unidentified &&
|
||||
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
|
||||
@@ -196,8 +196,13 @@ 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. In production fxCount
|
||||
// is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
|
||||
// expands the real writes itself via TrackFX_GetCount (parkFxOffline in
|
||||
// shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
|
||||
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 +214,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 +369,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 +438,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 +453,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 +586,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 +621,10 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
|
||||
std::string guid;
|
||||
TrackSnapshot snap;
|
||||
bool haveGuid = false;
|
||||
std::vector<int> bySlot;
|
||||
bool haveSlot = false;
|
||||
std::vector<FxOfflineState> byIdentity;
|
||||
bool haveIdentity = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
@@ -554,12 +633,33 @@ 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; haveSlot = true; }
|
||||
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;
|
||||
|
||||
// A blob carrying both arrays at different lengths is not something this
|
||||
// writer (or any prior version) produces — reject rather than silently
|
||||
// trusting "fx" over a slot array that disagrees with it; an unreadable
|
||||
// view_state falls back to a default model per the version-ladder note
|
||||
// above, which is the same leniency-direction call already made there.
|
||||
if (haveIdentity && haveSlot && byIdentity.size() != bySlot.size()) return false;
|
||||
|
||||
// "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(']');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
@@ -100,3 +103,12 @@ applies the resulting lane state to live tracks.
|
||||
under whatever mode id is active at that point, not the one the user undid back
|
||||
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
|
||||
the solo cache inherits it rather than introducing it. Not fixed here.
|
||||
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
|
||||
attached to its plugin across a chain mutation while parked. That is
|
||||
`[verify — DAW]` (see `fxGuidString` in `view.cpp`) and SWS issue #802 is a
|
||||
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
|
||||
the FXID lines behind on reorder rather than moving them with the plugin. If
|
||||
confirmed, an SWS-driven reorder of a parked track's chain — not a native
|
||||
drag-reorder — can produce wrong-plugin restores or mass drops through
|
||||
`resolveFxRestore`. Do not design around this pre-emptively; if native
|
||||
reorder is clean (the likely case), only the SWS path degrades.
|
||||
|
||||
+71
-20
@@ -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,40 @@ 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. 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);
|
||||
}
|
||||
|
||||
// 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 +170,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 +194,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 +483,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 +492,27 @@ 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. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
|
||||
// so it never force-opens the console window: applyMode's reapply path also
|
||||
// runs unattended on project load (see the reconcile comment above), and this
|
||||
// one call site can't tell that case apart from an interactive toggle/tag-edit
|
||||
// reapply — both call in with target == active — so splitting loud-on-toggle
|
||||
// from quiet-on-load would need a flag threaded from every caller, several of
|
||||
// which are outside this change. Quiet-always is the safe default: the message
|
||||
// still lands in the console for whoever opens it, on every path.
|
||||
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
|
||||
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + 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.
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Standalone tests for reasampler's per-FX offline restore resolution — no
|
||||
// REAPER, no test framework.
|
||||
//
|
||||
// The property under test: a captured FX-offline state lands on the FX it was
|
||||
// captured FROM, whatever that FX's slot has become while the track was parked.
|
||||
// Every scenario below is a chain mutation performed while parked.
|
||||
|
||||
#include "../src/core/view/fx_offline.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// -- helpers -----------------------------------------------------------------
|
||||
|
||||
// The ops the restore planner emits for one track from an identity-keyed
|
||||
// snapshot: capture-order entries, each carrying the FX's own identity.
|
||||
static std::vector<FxOfflineOp> identityOps(
|
||||
const std::vector<std::pair<std::string, bool>>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, captured[i].first,
|
||||
static_cast<int>(i), captured[i].second});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
// The ops a snapshot lifted from a pre-identity project yields: no identities,
|
||||
// position is the slot.
|
||||
static std::vector<FxOfflineOp> slotOps(const std::vector<bool>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Slot, {},
|
||||
static_cast<int>(i), captured[i]});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
static bool hasWrite(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool writesTouch(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- 1. Chain reordered while parked -----------------------------------------
|
||||
|
||||
static void testReorderedChainRestoresEachPluginItsOwnState() {
|
||||
// Captured with A, B, C in slots 0,1,2 — B was already offline pre-park.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// While parked the user dragged C to the front: the chain is now C, A, B.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{C}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A, now at slot 1
|
||||
CHECK(hasWrite(res, 2, true)); // B's offline state followed B to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // C, now at slot 0
|
||||
|
||||
// The slot-keyed reading of the SAME capture is what the old code did: it
|
||||
// would have written B's `true` to slot 1, which is now A. Pinning the
|
||||
// divergence keeps a well-meant "just use the index" from coming back.
|
||||
const FxRestoreResolution bySlot =
|
||||
resolveFxRestore(slotOps({false, true, false}), {"{C}", "{A}", "{B}"});
|
||||
CHECK(hasWrite(bySlot, 1, true)); // the defect, reproduced deliberately
|
||||
}
|
||||
|
||||
// -- 2. FX deleted while parked ----------------------------------------------
|
||||
|
||||
static void testDeletedFxDropsExplicitlyAndTouchesNothingElse() {
|
||||
// B (true) and C (false) carry OPPOSITE captured states — the discriminator.
|
||||
// A test where both carried `true` couldn't tell "C's own state followed it
|
||||
// to slot 1" apart from "B's dropped state leaked onto whatever moved there".
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// B was deleted while parked; A and C closed the gap.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(res.drops.unidentified == 0);
|
||||
CHECK(res.drops.slotOutOfRange == 0);
|
||||
CHECK(hasWrite(res, 0, false)); // A
|
||||
// C's OWN captured `false` landed at slot 1, not B's dropped `true`.
|
||||
CHECK(hasWrite(res, 1, false));
|
||||
|
||||
// The drop is reportable, not silent.
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(!msg.empty());
|
||||
CHECK(msg.find("1 captured FX offline state(s) on 1 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.back() == '\n');
|
||||
|
||||
// Nothing dropped ⇒ nothing said.
|
||||
CHECK(describeFxRestoreDrops(FxRestoreDrops{}, 0).empty());
|
||||
}
|
||||
|
||||
static void testDescribeNamesAllThreeDropKinds() {
|
||||
FxRestoreDrops drops;
|
||||
drops.missingIdentity = 2;
|
||||
drops.unidentified = 1;
|
||||
drops.slotOutOfRange = 3;
|
||||
CHECK(drops.total() == 6);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(drops, /*trackCount=*/2);
|
||||
CHECK(msg.find("6 captured FX offline state(s) on 2 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("2 FX no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("3 from a project saved before FX identity was recorded")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
// -- 3. FX added while parked -------------------------------------------------
|
||||
|
||||
static void testAddedFxIsNotTouched() {
|
||||
const std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}, {"{B}", true}});
|
||||
|
||||
// D was inserted at the FRONT while parked — the case where an index-keyed
|
||||
// restore would have written every captured state onto the wrong plugin.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{D}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A
|
||||
CHECK(hasWrite(res, 2, true)); // B
|
||||
CHECK(!writesTouch(res, 0)); // D is never written at all
|
||||
}
|
||||
|
||||
// -- 4. Two instances of the SAME plugin type --------------------------------
|
||||
|
||||
static void testTwoInstancesOfOnePluginKeyIndependently() {
|
||||
// Two copies of one plugin: distinct instances, distinct identities, and the
|
||||
// two carry OPPOSITE captured states — a scheme keyed on plugin type or name
|
||||
// could not tell them apart and would restore both the same way.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{EQ-1}", true}, {"{EQ-2}", false}, {"{COMP}", false}});
|
||||
|
||||
// Swapped while parked: EQ-2, COMP, EQ-1.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{EQ-2}", "{COMP}", "{EQ-1}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 2, true)); // EQ-1's offline=true followed it to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // EQ-2 stayed online at slot 0
|
||||
CHECK(hasWrite(res, 1, false)); // COMP
|
||||
}
|
||||
|
||||
// -- 5. Slot-keyed (pre-identity) snapshots ----------------------------------
|
||||
|
||||
static void testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks() {
|
||||
// All a pre-identity blob's bytes can support: position addressing.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(slotOps({false, true}), {"{A}", "{B}", "{C}"});
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 2)); // an FX the snapshot never covered stays untouched
|
||||
|
||||
// A slot that no longer exists is dropped and counted, never clamped.
|
||||
const FxRestoreResolution shrunk =
|
||||
resolveFxRestore(slotOps({false, true, true}), {"{A}"});
|
||||
CHECK(shrunk.writes.size() == 1);
|
||||
CHECK(shrunk.drops.slotOutOfRange == 2);
|
||||
CHECK(shrunk.drops.missingIdentity == 0);
|
||||
}
|
||||
|
||||
// -- 6. Degenerate inputs -----------------------------------------------------
|
||||
|
||||
static void testUnresolvableIdentityNeverFallsBackToItsSlot() {
|
||||
// An identity-keyed entry with NO identity (REAPER reported none at capture)
|
||||
// is a drop — the slot it happens to carry must not be used as a substitute.
|
||||
// Counted as `unidentified`, not `missingIdentity`: unlike a real captured
|
||||
// identity going missing, this FX may still be sitting right there — we
|
||||
// simply never had a name for it, and the report must say that, not "no
|
||||
// longer in the chain".
|
||||
std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}});
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "", /*slot=*/1, /*offline=*/true});
|
||||
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{B}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(!writesTouch(res, 1)); // {B} would have been the slot-1 victim
|
||||
CHECK(res.drops.missingIdentity == 0);
|
||||
CHECK(res.drops.unidentified == 1);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") == std::string::npos); // not this FX's story
|
||||
}
|
||||
|
||||
static void testLiveFxWithNoIdentityIsNeverARestoreTarget() {
|
||||
// The mirror case: a live FX REAPER reports no GUID for cannot be matched by
|
||||
// an empty captured identity either.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 0));
|
||||
CHECK(res.drops.total() == 0);
|
||||
}
|
||||
|
||||
static void testEmptyInputsProduceNoWrites() {
|
||||
CHECK(resolveFxRestore({}, {"{A}"}).writes.empty());
|
||||
CHECK(resolveFxRestore({}, {}).drops.total() == 0);
|
||||
|
||||
// Every FX gone (the whole chain cleared while parked): all dropped, none written.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}, {"{B}", false}}), {});
|
||||
CHECK(res.writes.empty());
|
||||
CHECK(res.drops.missingIdentity == 2);
|
||||
}
|
||||
|
||||
static void testDuplicateLiveIdentityResolvesToTheFirstSlotOnly() {
|
||||
// Not producible by REAPER (one GUID per instance) — pinned so a corrupt or
|
||||
// hand-edited chain writes once, deterministically, instead of twice.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"{A}", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, true));
|
||||
CHECK(!writesTouch(res, 1));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testReorderedChainRestoresEachPluginItsOwnState();
|
||||
testDeletedFxDropsExplicitlyAndTouchesNothingElse();
|
||||
testDescribeNamesAllThreeDropKinds();
|
||||
testAddedFxIsNotTouched();
|
||||
testTwoInstancesOfOnePluginKeyIndependently();
|
||||
testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks();
|
||||
testUnresolvableIdentityNeverFallsBackToItsSlot();
|
||||
testLiveFxWithNoIdentityIsNeverARestoreTarget();
|
||||
testEmptyInputsProduceNoWrites();
|
||||
testDuplicateLiveIdentityResolvesToTheFirstSlotOnly();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
// 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash).
|
||||
// 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active.
|
||||
// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract).
|
||||
// 8. Per-FX offline is keyed by FX identity, and the v1 (slot-keyed) blob lifts
|
||||
// into that keying without losing its restore.
|
||||
// 9. Nested-folder toggle: the snapshot store/clear lifecycle survives a re-park
|
||||
// (park-while-parked) so untagged leaves return to visible after toggling back;
|
||||
// guards the in-DAW "all leaves hidden after toggling twice" regression.
|
||||
@@ -64,7 +66,7 @@ static int flagValue(const TrackPlan& p, Flag f) {
|
||||
static void testSerializeGoldenLiteral() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.serialize() ==
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"{\"version\":2,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
|
||||
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
|
||||
@@ -284,7 +286,9 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
snap.showInMixer = 1;
|
||||
snap.mainSend = 0; // user had it OUT of the mix for their own reason
|
||||
snap.fxEnable = 1;
|
||||
snap.fxOffline = {0, 1, 0}; // slot 1 was already offline before parking
|
||||
// Slot 1's plugin was already offline before parking; each entry carries the
|
||||
// identity of the FX it came from.
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
|
||||
TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3);
|
||||
CHECK(flagValue(park, Flag::ShowInTcp) == 0);
|
||||
@@ -304,6 +308,10 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
CHECK(restore.fxOffline[0].offline == false);
|
||||
CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline
|
||||
CHECK(restore.fxOffline[2].offline == false);
|
||||
// Each restore op names the FX it was captured from, not just a position —
|
||||
// resolveFxRestore has something to key on even if the chain moved.
|
||||
CHECK(restore.fxOffline[1].keying == FxKeying::Identity);
|
||||
CHECK(restore.fxOffline[1].fxGuid == "{FX-B}");
|
||||
|
||||
// A snapshot entirely at 0 must restore entirely to 0 (no default leaks in).
|
||||
TrackSnapshot zero; // all zeros, empty fxOffline
|
||||
@@ -419,7 +427,8 @@ static void testJsonRoundTrip() {
|
||||
|
||||
// Snapshots: one full, one with a per-FX vector, including the tricky 0-values.
|
||||
TrackSnapshot s1; s1.showInTcp = 1; s1.showInMixer = 0; s1.mainSend = 1;
|
||||
s1.fxEnable = 0; s1.fxOffline = {1, 0, 1, 1};
|
||||
s1.fxEnable = 0;
|
||||
s1.fxOffline = {{"{FX-1}", 1}, {"{FX-2}", 0}, {"{FX-3}", 1}, {"{FX-4}", 1}};
|
||||
vm.storeSnapshot("{D}", s1);
|
||||
TrackSnapshot s2; // all zeros, empty fx vector
|
||||
vm.storeSnapshot("{A}", s2);
|
||||
@@ -445,7 +454,11 @@ static void testJsonRoundTrip() {
|
||||
CHECK(mm && mm->modeIds.size() == 2 && mm->modeIds.count("mixdown"));
|
||||
const TrackSnapshot* snap = back->snapshot("{D}");
|
||||
CHECK(snap && snap->mainSend == 1 && snap->fxEnable == 0);
|
||||
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1] == 0);
|
||||
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1].offline == 0);
|
||||
// The FX identities survive the round-trip — without them the restore is
|
||||
// back to guessing at slots.
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Identity);
|
||||
CHECK(snap && snap->fxOffline[3].fxGuid == "{FX-4}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +489,9 @@ static void testMalformedJson() {
|
||||
"{\"membership\":[{\"guid\":\"\"}]}", // empty guid
|
||||
"{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}",
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"guid\":\"{F}\"}]}]}", // fx without offline
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"offline\":1}]}]}", // fx without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[", // truncated fx
|
||||
"{\"modes\":[]}trailing", // trailing garbage
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
@@ -557,7 +573,7 @@ static void testUntaggedLeavesManagedByModeSystem() {
|
||||
// Arrange restores it from that snapshot verbatim, never a hardcoded default.
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 0; snap.fxEnable = 1;
|
||||
snap.fxOffline = {0, 1};
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}};
|
||||
vm.storeSnapshot("{U1}", snap); // as the shell would, before parking it in Design
|
||||
auto backToArrange = vm.planToggle(tree, kArrangeModeId);
|
||||
const TrackPlan* r = restoreFor(backToArrange, "{U1}");
|
||||
@@ -609,7 +625,8 @@ static void testReconcilePrunesOrphanedSnapshots() {
|
||||
// Two parked tracks (both snapshotted + tagged); {DEL} is about to be deleted.
|
||||
vm.membership().tag("{LIVE}", kDesignModeId);
|
||||
vm.membership().tag("{DEL}", kDesignModeId);
|
||||
TrackSnapshot sLive; sLive.showInTcp = 1; sLive.fxOffline = {0, 1};
|
||||
TrackSnapshot sLive; sLive.showInTcp = 1;
|
||||
sLive.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}};
|
||||
TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1;
|
||||
vm.storeSnapshot("{LIVE}", sLive);
|
||||
vm.storeSnapshot("{DEL}", sDel);
|
||||
@@ -1848,7 +1865,7 @@ static void testSoloCacheJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}});
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {{"{FX-A}", 0}, {"{FX-B}", 1}}});
|
||||
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
|
||||
|
||||
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
|
||||
@@ -1924,6 +1941,175 @@ static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
|
||||
CHECK(design && design->count("{LIVE}") == 1);
|
||||
}
|
||||
|
||||
// -- Per-FX offline: identity keying and the v1 -> v2 snapshot ladder ---------
|
||||
//
|
||||
// The restore path end-to-end, at the seam the shell actually uses: a stored
|
||||
// snapshot -> planToggle -> makeRestorePlan ops -> resolveFxRestore against the
|
||||
// chain as it stands now. The chain mutations happen while the track is parked,
|
||||
// which is the whole reason a slot cannot be the key.
|
||||
|
||||
namespace {
|
||||
|
||||
// The plan's restore ops for one parked-then-reactivated leaf.
|
||||
std::vector<FxOfflineOp> restoreOpsFor(ViewModeModel& vm, const std::string& guid) {
|
||||
FolderTree tree;
|
||||
tree.nodes.push_back(FolderNode{guid, "", false});
|
||||
const TogglePlan plan = vm.planToggle(tree, kDesignModeId);
|
||||
const TrackPlan* r = restoreFor(plan, guid);
|
||||
return r ? r->fxOffline : std::vector<FxOfflineOp>{};
|
||||
}
|
||||
|
||||
bool writeAt(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool anyWriteAt(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A leaf tagged Design, parked with three identified FX — the state every case
|
||||
// below starts from.
|
||||
ViewModeModel parkedWithThreeFx() {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1;
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
vm.storeSnapshot("{T}", snap);
|
||||
return vm;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void testParkReorderRestoreLandsEachPluginItsOwnState() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// Reordered to C, A, B while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-C}", "{FX-A}", "{FX-B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 1, false)); // A
|
||||
CHECK(writeAt(res, 2, true)); // B's captured offline followed B, not slot 1
|
||||
CHECK(writeAt(res, 0, false)); // C
|
||||
}
|
||||
|
||||
static void testParkDeleteFxRestoreDropsExplicitly() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// B deleted while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-A}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(writeAt(res, 0, false)); // A
|
||||
CHECK(writeAt(res, 1, false)); // C — and NOT B's captured `true`
|
||||
CHECK(!describeFxRestoreDrops(res.drops, 1).empty());
|
||||
}
|
||||
|
||||
static void testParkAddFxRestoreLeavesItAlone() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// A new plugin inserted at the head while parked.
|
||||
const FxRestoreResolution res = resolveFxRestore(
|
||||
restoreOpsFor(vm, "{T}"), {"{FX-NEW}", "{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(!anyWriteAt(res, 0)); // the added FX is never written
|
||||
CHECK(writeAt(res, 1, false));
|
||||
CHECK(writeAt(res, 2, true));
|
||||
CHECK(writeAt(res, 3, false));
|
||||
}
|
||||
|
||||
static void testV2WritesTheLegacySlotArrayBesideIdentities() {
|
||||
// The downgrade half of the ladder: a build that predates identity keying
|
||||
// reads "fxOffline" and skips "fx", so it keeps exactly the behavior it had
|
||||
// instead of losing every captured FX state to an unknown key.
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
const std::string json = vm.serialize();
|
||||
|
||||
CHECK(json.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
CHECK(json.find("\"fx\":[{\"guid\":\"{FX-A}\",\"offline\":0},"
|
||||
"{\"guid\":\"{FX-B}\",\"offline\":1},"
|
||||
"{\"guid\":\"{FX-C}\",\"offline\":0}]") != std::string::npos);
|
||||
|
||||
auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm);
|
||||
|
||||
// The downgrade path itself, at the only point it can be reached from here:
|
||||
// an unknown array-of-objects key beside "fxOffline" is skipped and the slot
|
||||
// array is still read — the same skipValue branch an older build takes on
|
||||
// "fx". (An actual older binary is not runnable from this test.)
|
||||
auto asOlder = ViewModeModel::deserialize(
|
||||
"{\"snapshots\":[{\"guid\":\"{T}\",\"fxOffline\":[0,1,0],"
|
||||
"\"futureKey\":[{\"guid\":\"{FX-A}\",\"offline\":0}]}]}");
|
||||
CHECK(asOlder.has_value());
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline.size() == 3);
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline[1].offline == 1);
|
||||
}
|
||||
|
||||
static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() {
|
||||
// A view_state written before FX identity existed: no "fx" key anywhere.
|
||||
const char* v1 =
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
|
||||
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[{\"guid\":\"{T}\","
|
||||
"\"showInTcp\":1,\"showInMixer\":1,\"mainSend\":1,\"fxEnable\":1,"
|
||||
"\"fxOffline\":[0,1,0]}],\"lanes\":[]}";
|
||||
|
||||
auto loaded = ViewModeModel::deserialize(v1);
|
||||
CHECK(loaded.has_value());
|
||||
if (!loaded) return;
|
||||
|
||||
const TrackSnapshot* snap = loaded->snapshot("{T}");
|
||||
CHECK(snap != nullptr);
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Slot); // no identities to key on
|
||||
CHECK(snap && snap->fxOffline.size() == 3);
|
||||
CHECK(snap && snap->fxOffline[1].offline == 1);
|
||||
CHECK(snap && snap->fxOffline[1].fxGuid.empty());
|
||||
CHECK(snap && snap->showInTcp == 1 && snap->mainSend == 1);
|
||||
|
||||
// It still restores — by position, which is all its bytes can support, and
|
||||
// is exactly what the pre-change build would have done with them.
|
||||
const std::vector<FxOfflineOp> ops = restoreOpsFor(*loaded, "{T}");
|
||||
CHECK(ops.size() == 3);
|
||||
CHECK(!ops.empty() && ops[0].keying == FxKeying::Slot);
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{FX-X}", "{FX-Y}", "{FX-Z}"});
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 0, false));
|
||||
CHECK(writeAt(res, 1, true));
|
||||
CHECK(writeAt(res, 2, false));
|
||||
|
||||
// Re-saving a lifted snapshot does NOT invent identities for it: the "fx"
|
||||
// key stays absent, and a second load reads the same slot-keyed shape.
|
||||
const std::string resaved = loaded->serialize();
|
||||
CHECK(resaved.find("\"fx\":") == std::string::npos);
|
||||
CHECK(resaved.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
auto again = ViewModeModel::deserialize(resaved);
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *loaded);
|
||||
CHECK(again && again->snapshot("{T}") &&
|
||||
again->snapshot("{T}")->fxKeying == FxKeying::Slot);
|
||||
|
||||
// And the lift is one-shot: a restore consumes the snapshot, so the next park
|
||||
// captures identities and the project leaves the legacy shape behind.
|
||||
loaded->clearSnapshot("{T}");
|
||||
TrackSnapshot fresh;
|
||||
fresh.fxOffline = {{"{FX-X}", 1}};
|
||||
loaded->storeSnapshot("{T}", fresh);
|
||||
CHECK(loaded->serialize().find("\"fx\":[{\"guid\":\"{FX-X}\",\"offline\":1}]")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSerializeGoldenLiteral();
|
||||
testNModeRegistryAndMembership();
|
||||
@@ -1974,6 +2160,13 @@ int main() {
|
||||
testSoloCacheMalformedJson();
|
||||
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
|
||||
|
||||
// Per-FX offline identity keying + the v1 -> v2 snapshot ladder
|
||||
testParkReorderRestoreLandsEachPluginItsOwnState();
|
||||
testParkDeleteFxRestoreDropsExplicitly();
|
||||
testParkAddFxRestoreLeavesItAlone();
|
||||
testV2WritesTheLegacySlotArrayBesideIdentities();
|
||||
testLegacyBlobLiftsToSlotKeyingAndStillRestores();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user