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
+200 -7
View File
@@ -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;
}