From 2423225f0c8d31b3ac8c451445163e5613b8f38c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 16:31:59 -0400 Subject: [PATCH] Split FX out of the park plan: flag-only plans, derived online-first FX work list, two-half record release, applied-state fingerprint --- src/app/CMakeLists.txt | 2 +- src/core/view/CMakeLists.txt | 6 + src/core/view/applied_state.cpp | 16 ++ src/core/view/applied_state.h | 51 +++++ src/core/view/fx_state.cpp | 91 +++++++++ src/core/view/fx_state.h | 70 +++++++ src/core/view/view_mode_model.cpp | 80 ++++++-- src/core/view/view_mode_model.h | 56 ++++-- src/shell/view/view.cpp | 4 +- src/shell/view/view_fx_park.cpp | 8 +- tests/test_applied_state.cpp | 147 ++++++++++++++ tests/test_fx_state.cpp | 307 ++++++++++++++++++++++++++++++ tests/test_view_fx_park.cpp | 4 +- tests/test_view_mode_model.cpp | 219 ++++++++++----------- 14 files changed, 897 insertions(+), 164 deletions(-) create mode 100644 src/core/view/applied_state.cpp create mode 100644 src/core/view/applied_state.h create mode 100644 src/core/view/fx_state.cpp create mode 100644 src/core/view/fx_state.h create mode 100644 tests/test_applied_state.cpp create mode 100644 tests/test_fx_state.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 3512a0d..f07e6aa 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -56,7 +56,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) # NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The # instrument renders its own bake in its own process, which is what keeps the extension's # link graph free of the voice engine — a link edge to it here means the design drifted. diff --git a/src/core/view/CMakeLists.txt b/src/core/view/CMakeLists.txt index 8d8df40..5364578 100644 --- a/src/core/view/CMakeLists.txt +++ b/src/core/view/CMakeLists.txt @@ -19,6 +19,12 @@ reasampler_test(view_mode_model LINK view_mode_model) reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model) reasampler_test(view_tree LINK view_tree) +reasampler_pure_library(fx_state SOURCES fx_state.cpp LINK PUBLIC view_mode_model) +reasampler_test(fx_state LINK fx_state) + +reasampler_pure_library(applied_state SOURCES applied_state.cpp LINK PUBLIC view_mode_model) +reasampler_test(applied_state LINK applied_state) + reasampler_pure_library(guid_diff SOURCES guid_diff.cpp) reasampler_test(guid_diff LINK guid_diff) diff --git a/src/core/view/applied_state.cpp b/src/core/view/applied_state.cpp new file mode 100644 index 0000000..624a508 --- /dev/null +++ b/src/core/view/applied_state.cpp @@ -0,0 +1,16 @@ +#include "core/view/applied_state.h" + +namespace reasampler::view { + +AppliedState appliedState(const ViewModeModel& model) { + AppliedState out; + out.activeModeId = model.activeModeId(); + for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order + if (!snap.flagsHeld) continue; + out.parked.push_back( + AppliedTrack{guid, snap.showInTcp, snap.showInMixer, snap.mainSend, snap.fxEnable}); + } + return out; +} + +} // namespace reasampler::view diff --git a/src/core/view/applied_state.h b/src/core/view/applied_state.h new file mode 100644 index 0000000..5194f22 --- /dev/null +++ b/src/core/view/applied_state.h @@ -0,0 +1,51 @@ +#pragma once +// A comparable fingerprint of what an apply put into the project, so a reconcile +// can ask "does the model differ from what we applied?" rather than "did an undo +// happen?". The second question is not the same one: an undo that touched +// nothing we drive still answers yes, and repairing on it re-enables state the +// user disabled by hand. + +#include +#include + +#include "core/view/view_mode_model.h" + +namespace reasampler::view { + +// One track the tool currently holds parked, and the flag values a restore would +// drive it back to. Per-FX offline is deliberately absent: it is derived, never +// persisted and never in an undo record, so a difference there is evidence of +// nothing about the model. +struct AppliedTrack { + std::string guid; + int showInTcp = 0; + int showInMixer = 0; + int mainSend = 0; + int fxEnable = 0; + + bool operator==(const AppliedTrack& o) const { + return guid == o.guid && showInTcp == o.showInTcp && + showInMixer == o.showInMixer && mainSend == o.mainSend && + fxEnable == o.fxEnable; + } + bool operator!=(const AppliedTrack& o) const { return !(*this == o); } +}; + +// Membership edits, lane ownership and the solo cache are all deliberately out: +// they change constantly without changing what the apply drove, and folding them +// in would put a reconcile back to firing on every unrelated edit. +struct AppliedState { + std::string activeModeId; + std::vector parked; // GUID order + + bool operator==(const AppliedState& o) const { + return activeModeId == o.activeModeId && parked == o.parked; + } + bool operator!=(const AppliedState& o) const { return !(*this == o); } +}; + +// A track appears iff its park record still HOLDS its flag half — that is +// precisely the set the tool is currently driving away from the user's values. +AppliedState appliedState(const ViewModeModel& model); + +} // namespace reasampler::view diff --git a/src/core/view/fx_state.cpp b/src/core/view/fx_state.cpp new file mode 100644 index 0000000..986fae0 --- /dev/null +++ b/src/core/view/fx_state.cpp @@ -0,0 +1,91 @@ +#include "core/view/fx_state.h" + +#include +#include + +namespace reasampler::view { + +namespace { + +// Everything up to and including the first op naming `resumeAfter` is already +// done. Not found ⇒ nothing dropped: the identity left the chain between ticks, +// and re-walking a compare-before-write list beats trusting a cursor that no +// longer points at anything. +std::vector remainingAfter(const std::vector& ops, + const std::string& resumeAfter) { + if (resumeAfter.empty()) return ops; + for (std::size_t i = 0; i < ops.size(); ++i) { + if (ops[i].fxGuid == resumeAfter) + return std::vector(ops.begin() + static_cast(i) + 1, + ops.end()); + } + return ops; +} + +// Slot keying, deliberately: the live chain IS the target set, so its positions +// are current by construction. Identity keying would drop an FX REAPER reports no +// GUID for, and reclaim has to reach those too — the fxGuid rides along only so +// the resume point has something to match. +std::vector offlineTargets(const std::string& trackGuid, + const std::vector& liveFxGuids) { + std::vector ops; + ops.reserve(liveFxGuids.size()); + for (std::size_t i = 0; i < liveFxGuids.size(); ++i) + ops.push_back(FxOfflineOp{trackGuid, FxKeying::Slot, liveFxGuids[i], + static_cast(i), true}); + return ops; +} + +} // namespace + +std::vector fxRestoreTargets(const ViewModeModel& model, + const std::string& trackGuid) { + std::vector ops; + if (!model.holdsSnapshotFx(trackGuid)) return ops; + + const TrackSnapshot* snap = model.snapshot(trackGuid); + ops.reserve(snap->fxOffline.size()); + for (std::size_t i = 0; i < snap->fxOffline.size(); ++i) { + const FxOfflineState& fx = snap->fxOffline[i]; + ops.push_back(FxOfflineOp{trackGuid, snap->fxKeying, fx.fxGuid, + static_cast(i), fx.offline != 0}); + } + return ops; +} + +std::vector desiredFxState(const ViewModeModel& model, + const std::set& liveTrackGuids) { + std::vector online; + std::vector offline; + + const std::string& activeMode = model.activeModeId(); + for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order + if (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write + + if (!model.leafBelongsToMode(guid, activeMode)) { + offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}, {}}); + continue; + } + // Active with the FX half already released: the chain is back online and + // the record is only waiting on its flags. Nothing owed either way. + if (!snap.fxHeld) continue; + online.push_back( + FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid), {}}); + } + + online.reserve(online.size() + offline.size()); + for (FxTrackWork& w : offline) online.push_back(std::move(w)); + return online; +} + +FxRestoreResolution resolveFxWork(const FxTrackWork& work, + const std::vector& liveFxGuids) { + if (work.kind == FxWorkKind::Offline) { + return resolveFxRestore( + remainingAfter(offlineTargets(work.trackGuid, liveFxGuids), work.resumeAfterFxGuid), + liveFxGuids); + } + return resolveFxRestore(remainingAfter(work.targets, work.resumeAfterFxGuid), liveFxGuids); +} + +} // namespace reasampler::view diff --git a/src/core/view/fx_state.h b/src/core/view/fx_state.h new file mode 100644 index 0000000..849835c --- /dev/null +++ b/src/core/view/fx_state.h @@ -0,0 +1,70 @@ +#pragma once +// What the FX chains SHOULD be, derived from view state: an ordered per-track +// work list plus its resolution against a live chain. Derived, never persisted +// and never in an undo record — the park record's captured FX half is the only +// persisted half. Pure: FX identities are opaque strings the shell reads from +// REAPER (TrackFX_GetFXGUID) and hands in. + +#include +#include +#include + +#include "core/view/fx_offline.h" +#include "core/view/view_mode_model.h" + +namespace reasampler::view { + +enum class FxWorkKind { + Online, // back to the park record's captured per-FX state, verbatim + Offline, // reclaim: every live FX offline +}; + +// One track's whole FX move. Track-granular so a drain may stop between tracks, +// and resumable WITHIN a track by FX identity: a slot cursor would silently skip +// or repeat a plugin if the chain were edited between two ticks. +struct FxTrackWork { + std::string trackGuid; + FxWorkKind kind = FxWorkKind::Offline; + + // Online: the captured targets, verbatim — never a blanket "online", which is + // exactly what would lose the plugins the USER had offline before the park. + // Offline: empty. The live chain is the target set, and only the shell can + // read it, so resolveFxWork synthesises the targets from what it is handed. + std::vector targets; + + // Identity of the last FX this item's drain completed; empty ⇒ none yet. An + // optimisation only: every write is compare-before-write, so an identity that + // has since left the chain safely restarts the item rather than stranding it. + std::string resumeAfterFxGuid; + + bool operator==(const FxTrackWork& o) const { + return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets && + resumeAfterFxGuid == o.resumeAfterFxGuid; + } +}; + +// The whole project's FX work, from view state alone — no folder tree, because a +// park record is only ever written for a track the planner already decided to +// park (parents and show-both leaves never get one). +// +// ONLINE WORK SORTS FIRST: onlining restores state the user owns, offlining is +// opportunistic reclaim that may be deferred indefinitely without losing +// anything. Within a kind, GUID order — deterministic, so a drain's position in +// the list survives a re-derivation. +std::vector desiredFxState(const ViewModeModel& model, + const std::set& liveTrackGuids); + +// One track's Online targets from its park record's FX half; empty when the +// record holds none. Each target carries the record's keying, so a snapshot +// lifted from a pre-identity project still restores by slot. +std::vector fxRestoreTargets(const ViewModeModel& model, + const std::string& trackGuid); + +// Resolves one work item against the chain as it stands, where `liveFxGuids[i]` +// is the identity of the FX at slot `i` right now (empty when REAPER reported +// none). Honors the resume point, then matches by identity: an identity that is +// not live is DROPPED, never re-pointed at a slot. +FxRestoreResolution resolveFxWork(const FxTrackWork& work, + const std::vector& liveFxGuids); + +} // namespace reasampler::view diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index d7ce350..5537e9c 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -188,7 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, return plan; } -TrackPlan makeParkPlan(const std::string& guid, int fxCount) { +TrackPlan makeParkPlan(const std::string& guid) { TrackPlan p; p.flags = { {guid, Flag::ShowInTcp, 0}, @@ -196,13 +196,6 @@ 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(FxOfflineOp{guid, FxKeying::Slot, {}, i, true}); return p; } @@ -214,11 +207,6 @@ 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) { - const FxOfflineState& fx = snap.fxOffline[i]; - p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid, - static_cast(i), fx.offline != 0}); - } return p; } @@ -253,6 +241,47 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { return it == snapshots_.end() ? nullptr : &it->second; } +bool ViewModeModel::holdsSnapshotFlags(const std::string& guid) const { + const TrackSnapshot* s = snapshot(guid); + return s && s->flagsHeld; +} + +bool ViewModeModel::holdsSnapshotFx(const std::string& guid) const { + const TrackSnapshot* s = snapshot(guid); + return s && s->fxHeld; +} + +namespace { + +// A record with neither half left records nothing — keeping it would make +// snapshot() answer yes to a track the model no longer describes. +void eraseIfSpent(std::map& snapshots, + std::map::iterator it) { + if (!it->second.flagsHeld && !it->second.fxHeld) snapshots.erase(it); +} + +} // namespace + +void ViewModeModel::releaseSnapshotFlags(const std::string& guid) { + auto it = snapshots_.find(guid); + if (it == snapshots_.end()) return; + it->second.flagsHeld = false; + it->second.showInTcp = 0; + it->second.showInMixer = 0; + it->second.mainSend = 0; + it->second.fxEnable = 0; + eraseIfSpent(snapshots_, it); +} + +void ViewModeModel::releaseSnapshotFx(const std::string& guid) { + auto it = snapshots_.find(guid); + if (it == snapshots_.end()) return; + it->second.fxHeld = false; + it->second.fxOffline.clear(); + it->second.fxKeying = FxKeying::Identity; + eraseIfSpent(snapshots_, it); +} + std::size_t ViewModeModel::reconcile(const std::set& liveGuids) { // See header: snapshots are pruned, membership is not (undo-delete rationale). soloCache_.reconcile(liveGuids); @@ -322,11 +351,12 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& const bool active = leafBelongsToMode(guid, targetMode); if (active) { - if (const TrackSnapshot* snap = snapshot(guid)) - plan.restore.push_back(makeRestorePlan(guid, *snap)); + // holdsSnapshotFlags, not snapshot(): a record whose FX half is still + // draining carries zeroed flags, and restoring THOSE would re-park it. + if (holdsSnapshotFlags(guid)) + plan.restore.push_back(makeRestorePlan(guid, *snapshot(guid))); } else { - // fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount. - plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0)); + plan.park.push_back(makeParkPlan(guid)); } } @@ -370,11 +400,15 @@ 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 +// v3 adds "flagsHeld"/"fxHeld", each written ONLY when false. A record's two +// halves are released independently, and absent-means-held keeps every v1/v2 +// blob (and every fully-held v3 record) byte-identical to what v2 wrote. A v2 +// reader sees a half-released record as fully held, which restores flags it +// should not — the same leniency direction the version field itself takes. // // 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 @@ -387,7 +421,7 @@ using ObjWriter = json::Writer; // 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; +constexpr int kViewStateVersion = 3; } // namespace @@ -439,7 +473,7 @@ std::string ViewModeModel::serialize() const { out += ']'; // snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, - // fxOffline[], fx[] } — see the version ladder above. + // fxOffline[], flagsHeld?, fxHeld?, fx[] } — see the version ladder above. root.keyBegin("snapshots"); out += '['; { @@ -460,6 +494,10 @@ std::string ViewModeModel::serialize() const { e.keyBegin("fxOffline"); writeIntArray(out, bySlot); + // Absent means held — see the version ladder above. + if (!snap.flagsHeld) e.keyRaw("flagsHeld", "false"); + if (!snap.fxHeld) e.keyRaw("fxHeld", "false"); + // 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) { @@ -634,6 +672,8 @@ bool parseSnapshots(json::Reader& r, std::map& snaps 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(bySlot)) return false; haveSlot = true; } + else if (k == "flagsHeld") { if (!r.parseBool(snap.flagsHeld)) return false; } + else if (k == "fxHeld") { if (!r.parseBool(snap.fxHeld)) return false; } else if (k == "fx") { if (!parseFxStates(r, byIdentity)) return false; haveIdentity = true; diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index b35d6ac..75013dd 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -196,6 +196,11 @@ struct FolderTree { // parking — restore's source of truth. Ints, not bools, so a snapshot // faithfully round-trips whatever REAPER reported (defensive against // non-0/1 values). +// +// The two halves are released INDEPENDENTLY. The flag half is spent the moment a +// restore writes it back; the FX half has to outlive that, because it is the only +// record of which plugins the USER had offline before the park — release it with +// the flag half and a later online blanket-enables them. struct TrackSnapshot { int showInTcp = 0; // B_SHOWINTCP prior value int showInMixer = 0; // B_SHOWINMIXER prior value @@ -208,15 +213,19 @@ struct TrackSnapshot { std::vector fxOffline; FxKeying fxKeying = FxKeying::Identity; + bool flagsHeld = true; // false ⇒ a restore already wrote the flags back + bool fxHeld = true; // false ⇒ the chain is back online; nothing left to restore + bool operator==(const TrackSnapshot& o) const { return showInTcp == o.showInTcp && showInMixer == o.showInMixer && mainSend == o.mainSend && fxEnable == o.fxEnable && - fxOffline == o.fxOffline && fxKeying == o.fxKeying; + fxOffline == o.fxOffline && fxKeying == o.fxKeying && + flagsHeld == o.flagsHeld && fxHeld == o.fxHeld; } }; -// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it -// is per-slot, variable length) — see TrackParkPlan::fxOffline. +// Which scalar flag a TrackFlagOp drives. FX-offline is not one of these and is +// not planned beside them — it is derived from view state (core/view/fx_state). enum class Flag { ShowInTcp, // B_SHOWINTCP ShowInMixer, // B_SHOWINMIXER @@ -249,14 +258,12 @@ 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. 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. +// One leaf's FLAG-DOMAIN plan: park writes fixed zeros, restore writes the +// snapshot's captured values. Per-FX offline is deliberately absent — it is a +// derived function of view state (core/view/fx_state), not a peer of the flags, +// and carrying it here only ever produced an always-empty park-side vector. struct TrackPlan { - std::vector flags; - std::vector fxOffline; + std::vector flags; }; // The plan for a toggle to a target mode. Parents and show-both leaves never @@ -295,11 +302,27 @@ public: bool setActiveMode(const std::string& modeId); // The shell calls store before it parks a track, so restore survives a save. + // Both halves are held from here; each is released by the act that spends it. void storeSnapshot(const std::string& guid, const TrackSnapshot& snap); - void clearSnapshot(const std::string& guid); + void clearSnapshot(const std::string& guid); // both halves at once const TrackSnapshot* snapshot(const std::string& guid) const; const std::map& snapshots() const { return snapshots_; } + // A held half is one nothing has spent yet. `snapshot()` returning non-null is + // NOT that question: a record outlives its flag half, so a caller that reads + // the flag values off a record without asking here will drive stale ones. + bool holdsSnapshotFlags(const std::string& guid) const; + bool holdsSnapshotFx(const std::string& guid) const; + + // Spent by the restore that writes the captured flags back. Zeroes them, so a + // record that no longer claims them cannot be read for them either. + void releaseSnapshotFlags(const std::string& guid); + + // Spent only once the chain is ACTUALLY back online — not by the flag restore. + // Releasing both together destroys the only record of which plugins the user + // had offline before the park, and a later online then blanket-enables them. + void releaseSnapshotFx(const std::string& guid); + // Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo // cache the same way (see SoloCache::reconcile). Returns the count of // SNAPSHOTS removed — the solo cache's own count is available from it directly. @@ -330,10 +353,7 @@ public: // restored from it. Parents and show-both leaves are never parked. // Untagged leaves are Arrange members and park/restore accordingly. Tree // membership is the enumeration source, so stale membership GUIDs absent - // from the tree are ignored. - // - // Park plans here carry an empty fxOffline vector — the D2 shell expands - // per-FX offline writes via TrackFX_GetCount (not available to the pure model). + // from the tree are ignored. Flag domain only; FX comes from desiredFxState. TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; // Managed lanes only, from the ownership index — the set a toggle may @@ -357,11 +377,11 @@ private: view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO }; -// Fixed-zero park plan for one leaf, offlining `fxCount` slots. -TrackPlan makeParkPlan(const std::string& guid, int fxCount); +// Fixed-zero park plan for one leaf. +TrackPlan makeParkPlan(const std::string& guid); // Restore plan for one leaf from its snapshot — every flag to its captured -// value, never a default. +// value, never a default. Callers must have checked holdsSnapshotFlags. TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); // Auto-tag decision: new content takes the active mode at creation; the diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index dd5aee2..324520a 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -17,6 +17,7 @@ #include "shell/capture/item_read.h" #include "core/view/fx_offline.h" +#include "core/view/fx_state.h" #include "core/view/lane_keys.h" #include "core/view/solo_cache.h" #include "shell/capture/track_guid.h" @@ -471,7 +472,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject MediaTrack* tr = resolve(trackByGuid, guid); if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune - const TrackApplyResult r = restoreTrack(model, guid, tr, tp.fxOffline, tp.flags); + const std::vector fxOps = view::fxRestoreTargets(model, guid); + const TrackApplyResult r = restoreTrack(model, guid, tr, fxOps, tp.flags); if (r.wrote) wrote = true; if (r.fxMoved) undoMask |= UNDO_STATE_FX; if (r.drops.total() > 0) { fxDrops.add(r.drops); ++fxDropTracks; } diff --git a/src/shell/view/view_fx_park.cpp b/src/shell/view/view_fx_park.cpp index db76d54..6f01da7 100644 --- a/src/shell/view/view_fx_park.cpp +++ b/src/shell/view/view_fx_park.cpp @@ -203,15 +203,17 @@ TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, Med for (const FxOfflineWrite& w : res.writes) if (setOfflineIfChanged(tr, w.fxIndex, w.offline)) out.fxMoved = out.wrote = true; out.drops = res.drops; + // The chain is back online HERE, which is the only thing that spends the FX half. + model.releaseSnapshotFx(guid); if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true; + model.releaseSnapshotFlags(guid); - // Unconditional, even when every write above was elided: the snapshot is consumed + // Unconditional, even when every write above was elided: the record is consumed // here, so this apply changed state and is not repeatable — and `wrote`, whose one // reader asks exactly that, would be a lie at false. NOT a claim about what the - // undo record holds: the block never carries the snapshot either way, that being + // undo record holds: the block never carries the record either way, that being // model state persisted after Undo_EndBlock2 (src/shell/view/CLAUDE.md). - model.clearSnapshot(guid); out.wrote = true; return out; } diff --git a/tests/test_applied_state.cpp b/tests/test_applied_state.cpp new file mode 100644 index 0000000..ccf0c52 --- /dev/null +++ b/tests/test_applied_state.cpp @@ -0,0 +1,147 @@ +// Standalone tests for reasampler::view::appliedState — no REAPER, no test +// framework. The fingerprint's whole job is discriminating "the model differs +// from what we applied" from "something happened"; the negative cases below are +// the load-bearing half. + +#include "../src/core/view/applied_state.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +TrackSnapshot record(int tcp, int mixer, int mainSend, int fxEnable) { + TrackSnapshot snap; + snap.showInTcp = tcp; + snap.showInMixer = mixer; + snap.mainSend = mainSend; + snap.fxEnable = fxEnable; + snap.fxOffline = {{"{FX-A}", 0}}; + return snap; +} + +// Two leaves parked out of Design, one of which the user had OUT of the mix by +// hand (mainSend 0) — the value the sunk attempt re-enabled. +ViewModeModel parkedPair() { + ViewModeModel vm; + vm.membership().tag("{T1}", kDesignModeId); + vm.membership().tag("{T2}", kDesignModeId); + vm.storeSnapshot("{T1}", record(1, 1, 1, 1)); + vm.storeSnapshot("{T2}", record(1, 1, 0, 1)); + return vm; +} + +} // namespace + +static void testSameModelFingerprintsEqual() { + const AppliedState a = appliedState(parkedPair()); + const AppliedState b = appliedState(parkedPair()); + CHECK(a == b); + CHECK(!(a != b)); + + CHECK(a.activeModeId == kArrangeModeId); + CHECK(a.parked.size() == 2); + CHECK(a.parked.size() == 2 && a.parked[0].guid == "{T1}"); // GUID order + CHECK(a.parked.size() == 2 && a.parked[1].mainSend == 0); // captured, not driven +} + +static void testACapturedValueChangingIsADifference() { + // The reconcile case: a rolled-back view_state carries a different record for + // the same track, so what a restore would drive is no longer what we applied. + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + vm.storeSnapshot("{T2}", record(1, 1, 1, 1)); // mainSend the user had at 0 + CHECK(appliedState(vm) != before); +} + +static void testTheParkedSetChangingIsADifference() { + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + + vm.clearSnapshot("{T2}"); + CHECK(appliedState(vm) != before); + CHECK(appliedState(vm).parked.size() == 1); + + ViewModeModel grown = parkedPair(); + grown.storeSnapshot("{T3}", record(1, 1, 1, 1)); + CHECK(appliedState(grown) != before); +} + +static void testActiveModeChangingIsADifference() { + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + CHECK(vm.setActiveMode(kDesignModeId)); + CHECK(appliedState(vm) != before); +} + +static void testASpentFlagHalfLeavesTheFingerprint() { + // A restored track is no longer one the tool holds away from the user's + // values; its zeroed record must not read as a parked track at all zeros. + ViewModeModel vm = parkedPair(); + vm.releaseSnapshotFlags("{T1}"); + + const AppliedState after = appliedState(vm); + CHECK(after.parked.size() == 1); + CHECK(after.parked.size() == 1 && after.parked[0].guid == "{T2}"); + + // The FX half outliving the flag half does NOT put it back. + CHECK(vm.holdsSnapshotFx("{T1}")); + CHECK(appliedState(vm) == after); +} + +static void testEditsTheApplyDidNotDriveAreNotDifferences() { + // The bug that sank the previous attempt was repairing on evidence of nothing. + // Each edit below changes the model without changing what the apply drove. + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + + vm.membership().tag("{OTHER}", kDesignModeId); + CHECK(appliedState(vm) == before); + + vm.membership().setShowBoth("{OTHER}", true); + CHECK(appliedState(vm) == before); + + vm.lanes().setManaged("{T1}", "reasampler:design", kDesignModeId); + CHECK(appliedState(vm) == before); + + vm.soloCache().store(kDesignModeId, {{"{T1}", 1}}); + CHECK(appliedState(vm) == before); + + // Per-FX offline is DERIVED — never persisted, never in an undo record — so a + // difference there is evidence of nothing about the model. + TrackSnapshot fxMoved = record(1, 1, 1, 1); + fxMoved.fxOffline = {{"{FX-A}", 1}, {"{FX-B}", 0}}; + vm.storeSnapshot("{T1}", fxMoved); + CHECK(appliedState(vm) == before); + + vm.releaseSnapshotFx("{T2}"); + CHECK(appliedState(vm) == before); +} + +static void testEmptyModelFingerprintsEqualAndNotParked() { + ViewModeModel a; + ViewModeModel b; + CHECK(appliedState(a) == appliedState(b)); + CHECK(appliedState(a).parked.empty()); + CHECK(appliedState(a) != appliedState(parkedPair())); +} + +int main() { + testSameModelFingerprintsEqual(); + testACapturedValueChangingIsADifference(); + testTheParkedSetChangingIsADifference(); + testActiveModeChangingIsADifference(); + testASpentFlagHalfLeavesTheFingerprint(); + testEditsTheApplyDidNotDriveAreNotDifferences(); + testEmptyModelFingerprintsEqualAndNotParked(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_fx_state.cpp b/tests/test_fx_state.cpp new file mode 100644 index 0000000..06e8964 --- /dev/null +++ b/tests/test_fx_state.cpp @@ -0,0 +1,307 @@ +// Standalone tests for reasampler::view's derived FX state — no REAPER, no test +// framework. Two halves: what desiredFxState decides is owed, and how one work +// item resolves against the chain as it stands (the chain mutates while a track +// is parked, which is the whole reason a slot cannot be the key). + +#include "../src/core/view/fx_state.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +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 ----------------------------------------------------------------- + +namespace { + +TrackSnapshot recordWithFx(std::vector fx) { + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = std::move(fx); + return snap; +} + +// A Design leaf parked with three identified FX, the middle one offline before +// the park because the USER put it there. +ViewModeModel parkedWithThreeFx() { + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}})); + return vm; +} + +const FxTrackWork* workFor(const std::vector& work, const std::string& guid) { + for (const FxTrackWork& w : work) + if (w.trackGuid == guid) return &w; + return nullptr; +} + +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; +} + +} // namespace + +// -- desiredFxState: what is owed -------------------------------------------- + +static void testActiveTrackIsOwedOnlineInactiveIsOwedOffline() { + ViewModeModel vm = parkedWithThreeFx(); + vm.membership().tag("{P}", kArrangeModeId); + vm.storeSnapshot("{P}", recordWithFx({{"{FX-P}", 0}})); + + // Active mode is Arrange: {T} (Design) stays parked, {P} is coming back. + const std::vector work = desiredFxState(vm, {"{T}", "{P}"}); + CHECK(work.size() == 2); + CHECK(workFor(work, "{P}") && workFor(work, "{P}")->kind == FxWorkKind::Online); + CHECK(workFor(work, "{T}") && workFor(work, "{T}")->kind == FxWorkKind::Offline); + + // Offline work names no targets: only the shell can read the live chain, and + // that chain IS the target set. + CHECK(workFor(work, "{T}") && workFor(work, "{T}")->targets.empty()); + CHECK(workFor(work, "{P}") && workFor(work, "{P}")->targets.size() == 1); +} + +static void testOnlineWorkSortsBeforeOfflineWork() { + // GUIDs chosen so plain GUID order would put every Offline track FIRST — the + // ordering under test has to beat the map's own order to pass. + ViewModeModel vm; + vm.membership().tag("{A-park}", kDesignModeId); + vm.membership().tag("{B-park}", kDesignModeId); + vm.membership().tag("{Y-back}", kArrangeModeId); + vm.membership().tag("{Z-back}", kArrangeModeId); + for (const char* g : {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}) + vm.storeSnapshot(g, recordWithFx({{"{FX}", 0}})); + + const std::vector work = + desiredFxState(vm, {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}); + CHECK(work.size() == 4); + // Onlining restores state the user owns and is 2-6x the cost of offlining; + // offlining is opportunistic reclaim and may be deferred indefinitely. + CHECK(work[0].trackGuid == "{Y-back}" && work[0].kind == FxWorkKind::Online); + CHECK(work[1].trackGuid == "{Z-back}" && work[1].kind == FxWorkKind::Online); + CHECK(work[2].trackGuid == "{A-park}" && work[2].kind == FxWorkKind::Offline); + CHECK(work[3].trackGuid == "{B-park}" && work[3].kind == FxWorkKind::Offline); + + // Deterministic: a re-derivation yields the identical list, so a drain's + // position in it survives one. + CHECK(desiredFxState(vm, {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}) == work); +} + +static void testUserOfflinedPluginIsNeverBlanketOnlined() { + // The property this whole split exists for. {FX-B} was offline before the + // park because the user offlined it; coming back must leave it offline. + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + CHECK(work[0].kind == FxWorkKind::Online); + + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}); + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(writeAt(res, 0, false)); + CHECK(writeAt(res, 1, true)); // stays offline — NOT blanket-onlined + CHECK(writeAt(res, 2, false)); +} + +static void testReleasedFxHalfIsOwedNothing() { + // The chain is already back online; the record survives only for its flags. + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + vm.releaseSnapshotFx("{T}"); + + CHECK(desiredFxState(vm, {"{T}"}).empty()); + CHECK(fxRestoreTargets(vm, "{T}").empty()); + + // ...and the mirror: a record whose FLAG half is spent still owes its FX. + ViewModeModel vm2 = parkedWithThreeFx(); + CHECK(vm2.setActiveMode(kDesignModeId)); + vm2.releaseSnapshotFlags("{T}"); + const std::vector work = desiredFxState(vm2, {"{T}"}); + CHECK(work.size() == 1); + CHECK(work.size() == 1 && work[0].targets.size() == 3); +} + +static void testDeadAndUnparkedTracksAreOwedNothing() { + ViewModeModel vm = parkedWithThreeFx(); + // The track is gone from the project: there is nothing to write to. + CHECK(desiredFxState(vm, {}).empty()); + + // A track with no park record is owed nothing either way — that is also how a + // REFUSED park stays refused: it wrote no record, so no reclaim targets it. + ViewModeModel bare; + bare.membership().tag("{U}", kDesignModeId); + CHECK(desiredFxState(bare, {"{U}"}).empty()); +} + +// -- resolveFxWork: against the chain as it stands ---------------------------- + +static void testOfflineWorkTargetsEveryLiveSlot() { + ViewModeModel vm = parkedWithThreeFx(); // active mode Arrange ⇒ {T} parks + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // Two plugins added while parked, one of which REAPER reports no GUID for. + // Reclaim has to reach those too, which is why offline work keys by slot. + const FxRestoreResolution res = + resolveFxWork(work[0], {"{FX-A}", "", "{FX-B}", "{FX-C}", "{FX-NEW}"}); + CHECK(res.writes.size() == 5); + CHECK(res.drops.total() == 0); + for (int i = 0; i < 5; ++i) CHECK(writeAt(res, i, true)); +} + +static void testReorderRestoreLandsEachPluginItsOwnState() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // Reordered to C, A, B while parked. + const FxRestoreResolution res = resolveFxWork(work[0], {"{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 testDeletedFxIsDroppedExplicitly() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // B deleted while parked. + const FxRestoreResolution res = resolveFxWork(work[0], {"{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 testAddedFxIsLeftAlone() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + const FxRestoreResolution res = + resolveFxWork(work[0], {"{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 testSlotKeyedRecordStillRestoresByPosition() { + // A record lifted from a project saved before FX identity existed: position + // is all its bytes can support, and the targets carry that keying through. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + CHECK(vm.setActiveMode(kDesignModeId)); + TrackSnapshot snap = recordWithFx({{"", 0}, {"", 1}, {"", 0}}); + snap.fxKeying = FxKeying::Slot; + vm.storeSnapshot("{T}", snap); + + const std::vector targets = fxRestoreTargets(vm, "{T}"); + CHECK(targets.size() == 3); + CHECK(!targets.empty() && targets[0].keying == FxKeying::Slot); + + const std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + const FxRestoreResolution res = resolveFxWork(work[0], {"{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)); +} + +// -- the resume point --------------------------------------------------------- + +static void testResumePointSkipsWhatTheDrainAlreadyDid() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + work[0].resumeAfterFxGuid = "{FX-A}"; + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}); + CHECK(res.writes.size() == 2); + CHECK(!anyWriteAt(res, 0)); // A is done + CHECK(writeAt(res, 1, true)); + CHECK(writeAt(res, 2, false)); + + // Resuming after the LAST identity leaves nothing owed — the item is finished. + work[0].resumeAfterFxGuid = "{FX-C}"; + CHECK(resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.empty()); +} + +static void testResumePointThatLeftTheChainRestartsRatherThanStrands() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + std::vector work = desiredFxState(vm, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // The drain's cursor named a plugin the user has since deleted. Every write is + // compare-before-write, so re-walking the item is cheap and correct; honoring a + // cursor that matches nothing would strand the rest of the chain. + work[0].resumeAfterFxGuid = "{FX-GONE}"; + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}); + CHECK(res.writes.size() == 3); + CHECK(writeAt(res, 1, true)); + + // Same rule on the offline side, where the cursor keys into the live chain. + ViewModeModel parked = parkedWithThreeFx(); // Arrange active ⇒ {T} parks + std::vector offlineWork = desiredFxState(parked, {"{T}"}); + CHECK(offlineWork.size() == 1); + if (offlineWork.empty()) return; + offlineWork[0].resumeAfterFxGuid = "{FX-A}"; + CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 2); + offlineWork[0].resumeAfterFxGuid = "{FX-GONE}"; + CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 3); +} + +int main() { + testActiveTrackIsOwedOnlineInactiveIsOwedOffline(); + testOnlineWorkSortsBeforeOfflineWork(); + testUserOfflinedPluginIsNeverBlanketOnlined(); + testReleasedFxHalfIsOwedNothing(); + testDeadAndUnparkedTracksAreOwedNothing(); + + testOfflineWorkTargetsEveryLiveSlot(); + testReorderRestoreLandsEachPluginItsOwnState(); + testDeletedFxIsDroppedExplicitly(); + testAddedFxIsLeftAlone(); + testSlotKeyedRecordStillRestoresByPosition(); + + testResumePointSkipsWhatTheDrainAlreadyDid(); + testResumePointThatLeftTheChainRestartsRatherThanStrands(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_view_fx_park.cpp b/tests/test_view_fx_park.cpp index 57e5e52..00a168b 100644 --- a/tests/test_view_fx_park.cpp +++ b/tests/test_view_fx_park.cpp @@ -45,14 +45,14 @@ struct FlagValues { static void testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked() { // The four zeros are what a parked track's driven flags actually read; if // makeParkPlan ever writes something else, this is the test that says so. - const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0); + const TrackPlan park = makeParkPlan("{A}"); CHECK(park.flags.size() == 4); CHECK(parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 0})); } static void testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain() { - const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0); + const TrackPlan park = makeParkPlan("{A}"); // Each case varies exactly one flag, so together they also prove all four are // in the plan: a missing op makes its case read parked and fail here. diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index a9e4427..41494a0 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -10,7 +10,7 @@ // 4. show-both leaf never appears in a park op-list and is visible in all modes. // 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). +// 7. planToggle plans the FLAG domain only — FX is derived (core/view/fx_state). // 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 @@ -66,7 +66,7 @@ static int flagValue(const TrackPlan& p, Flag f) { static void testSerializeGoldenLiteral() { ViewModeModel vm; CHECK(vm.serialize() == - "{\"version\":2,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," + "{\"version\":3,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[]," "\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}"); @@ -287,16 +287,15 @@ static void testRestoreRoundTripSnapshotValues() { snap.mainSend = 0; // user had it OUT of the mix for their own reason snap.fxEnable = 1; // Slot 1's plugin was already offline before parking; each entry carries the - // identity of the FX it came from. + // identity of the FX it came from. The FX half's own restore is fx_state's — + // test_fx_state covers it; here it only has to survive alongside the flags. snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}}; - TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3); + TrackPlan park = makeParkPlan("{T}"); CHECK(flagValue(park, Flag::ShowInTcp) == 0); CHECK(flagValue(park, Flag::ShowInMixer) == 0); CHECK(flagValue(park, Flag::MainSend) == 0); CHECK(flagValue(park, Flag::FxEnable) == 0); - CHECK(park.fxOffline.size() == 3); - for (const auto& op : park.fxOffline) CHECK(op.offline == true); TrackPlan restore = makeRestorePlan("{T}", snap); // Every flag returns to its CAPTURED value — not a hardcoded "on". @@ -304,14 +303,6 @@ static void testRestoreRoundTripSnapshotValues() { CHECK(flagValue(restore, Flag::ShowInMixer) == 1); CHECK(flagValue(restore, Flag::MainSend) == 0); // the "already at 0 stays 0" case CHECK(flagValue(restore, Flag::FxEnable) == 1); - CHECK(restore.fxOffline.size() == 3); - 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 @@ -320,7 +311,6 @@ static void testRestoreRoundTripSnapshotValues() { CHECK(flagValue(rz, Flag::ShowInMixer) == 0); CHECK(flagValue(rz, Flag::MainSend) == 0); CHECK(flagValue(rz, Flag::FxEnable) == 0); - CHECK(rz.fxOffline.empty()); // End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in // Arrange, then restored when we toggle back to Design. @@ -500,29 +490,98 @@ static void testMalformedJson() { } } -// -- 7. planToggle park path: fxOffline is empty (shell-expands-FX contract) -- +// -- 7. planToggle plans the FLAG domain only -------------------------------- // -// planToggle calls makeParkPlan(guid, /*fxCount=*/0) for each inactive leaf. -// The D2 shell is responsible for expanding per-FX offline ops using -// TrackFX_GetCount — the pure model has no access to REAPER FX counts at plan -// time. This test pins that contract so a regression that passes a non-zero -// count (and emits FX ops prematurely) is caught immediately. -// The direct makeParkPlan(guid, 3) path (non-zero fxCount) is covered by -// testRestoreRoundTripSnapshotValues above. +// A TogglePlan carries the four driven scalars and nothing else; per-FX offline +// is derived from view state (core/view/fx_state), not planned beside them. -static void testPlanToggleParkHasEmptyFxOffline() { +static void testPlanTogglePlansFlagsOnly() { ViewModeModel vm; FolderTree tree; tree.nodes.push_back(FolderNode{"{LEAF}", "", false}); + tree.nodes.push_back(FolderNode{"{BACK}", "", false}); vm.membership().tag("{LEAF}", kDesignModeId); + vm.membership().tag("{BACK}", kArrangeModeId); + TrackSnapshot snap; + snap.showInTcp = 1; + snap.fxOffline = {{"{FX-A}", 1}}; + vm.storeSnapshot("{BACK}", snap); - // Toggle to Arrange: {LEAF} is inactive ⇒ park plan emitted. + // Toggle to Arrange: {LEAF} is inactive ⇒ parked; {BACK} returns ⇒ restored. auto plan = vm.planToggle(tree, kArrangeModeId); CHECK(plan.park.size() == 1); - // The park plan must have an empty fxOffline — the shell expands FX ops. - CHECK(plan.park[0].fxOffline.empty()); - // Scalar flags must still be present (the four park zeros). - CHECK(plan.park[0].flags.size() == 4); + CHECK(plan.park[0].flags.size() == 4); // the four park zeros, nothing else + CHECK(plan.restore.size() == 1); + CHECK(plan.restore[0].flags.size() == 4); +} + +// A record whose flag half a restore already spent must never be replanned: its +// values are zeroed, and restoring THOSE would silently re-park the track. +static void testReleasedFlagHalfIsNotReplanned() { + ViewModeModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{T}", "", false}); + vm.membership().tag("{T}", kDesignModeId); + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = {{"{FX-A}", 1}}; + vm.storeSnapshot("{T}", snap); + + CHECK(vm.holdsSnapshotFlags("{T}")); + CHECK(vm.holdsSnapshotFx("{T}")); + CHECK(restoreFor(vm.planToggle(tree, kDesignModeId), "{T}") != nullptr); + + vm.releaseSnapshotFlags("{T}"); + // The FX half OUTLIVES the flag half — it is the only record of which plugins + // the user had offline before the park. + CHECK(!vm.holdsSnapshotFlags("{T}")); + CHECK(vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") != nullptr); + CHECK(vm.snapshot("{T}")->fxOffline.size() == 1); + CHECK(restoreFor(vm.planToggle(tree, kDesignModeId), "{T}") == nullptr); + + // Spending the second half is what retires the record. + vm.releaseSnapshotFx("{T}"); + CHECK(!vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") == nullptr); + + // The other release order retires it just the same. + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFx("{T}"); + CHECK(vm.holdsSnapshotFlags("{T}") && !vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") && vm.snapshot("{T}")->fxOffline.empty()); + vm.releaseSnapshotFlags("{T}"); + CHECK(vm.snapshot("{T}") == nullptr); + + // Releasing a half of a record that does not exist is a no-op, not a mint. + vm.releaseSnapshotFlags("{GONE}"); + vm.releaseSnapshotFx("{GONE}"); + CHECK(vm.snapshot("{GONE}") == nullptr); +} + +// A half-released record has to survive the .rpp: the FX half outliving the flag +// half is only useful if a save-while-draining reloads in the same state. +static void testHalfReleasedRecordRoundTrips() { + ViewModeModel vm; + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = {{"{FX-A}", 1}, {"{FX-B}", 0}}; + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFlags("{T}"); + + auto back = ViewModeModel::deserialize(vm.serialize()); + CHECK(back.has_value()); + CHECK(back && *back == vm); + CHECK(back && !back->holdsSnapshotFlags("{T}")); + CHECK(back && back->holdsSnapshotFx("{T}")); + CHECK(back && back->snapshot("{T}") && back->snapshot("{T}")->fxOffline.size() == 2); + + // Absent means held: a fully-held record writes neither key, so every v1/v2 + // blob still loads as one nothing has spent. + ViewModeModel held; + held.storeSnapshot("{T}", snap); + CHECK(held.serialize().find("\"flagsHeld\"") == std::string::npos); + CHECK(held.serialize().find("\"fxHeld\"") == std::string::npos); } // -- 7b. Untagged leaves are managed by the mode system ---------------------- @@ -583,9 +642,6 @@ static void testUntaggedLeavesManagedByModeSystem() { CHECK(flagValue(*r, Flag::ShowInMixer) == 1); CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0 CHECK(flagValue(*r, Flag::FxEnable) == 1); - CHECK(r->fxOffline.size() == 2); - CHECK(r->fxOffline[0].offline == false); - CHECK(r->fxOffline[1].offline == true); } } @@ -2022,38 +2078,14 @@ static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() { CHECK(design && design->count("{LIVE}") == 1); } -// -- Per-FX offline: identity keying and the v1 -> v2 snapshot ladder --------- +// -- Per-FX offline: 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. +// The persisted shape only. Resolving a captured FX state against the live chain +// moved to core/view/fx_state — test_fx_state owns those cases. namespace { -// The plan's restore ops for one parked-then-reactivated leaf. -std::vector 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{}; -} - -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. +// A leaf tagged Design, parked with three identified FX. ViewModeModel parkedWithThreeFx() { ViewModeModel vm; vm.membership().tag("{T}", kDesignModeId); @@ -2066,46 +2098,6 @@ ViewModeModel parkedWithThreeFx() { } // 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 @@ -2136,7 +2128,7 @@ static void testV2WritesTheLegacySlotArrayBesideIdentities() { asOlder->snapshot("{T}")->fxOffline[1].offline == 1); } -static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() { +static void testLegacyBlobLiftsToSlotKeying() { // A view_state written before FX identity existed: no "fx" key anywhere. const char* v1 = "{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," @@ -2157,18 +2149,8 @@ static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() { 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 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)); + // That it still RESTORES — by position, all its bytes can support — is + // test_fx_state's case; here the lift only has to preserve the keying. // 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. @@ -2202,7 +2184,9 @@ int main() { testJsonRoundTrip(); testEmptyModelRoundTrip(); testMalformedJson(); - testPlanToggleParkHasEmptyFxOffline(); + testPlanTogglePlansFlagsOnly(); + testReleasedFlagHalfIsNotReplanned(); + testHalfReleasedRecordRoundTrips(); testUntaggedLeavesManagedByModeSystem(); testTaggedLeafBehaviorUnchangedWithUntagged(); testNestedToggleSnapshotSurvivesRepark(); @@ -2242,12 +2226,9 @@ int main() { testSoloCacheMalformedJson(); testReconcilePrunesTheSoloCacheAlongsideSnapshots(); - // Per-FX offline identity keying + the v1 -> v2 snapshot ladder - testParkReorderRestoreLandsEachPluginItsOwnState(); - testParkDeleteFxRestoreDropsExplicitly(); - testParkAddFxRestoreLeavesItAlone(); + // Per-FX offline: the v1 -> v2 snapshot ladder testV2WritesTheLegacySlotArrayBesideIdentities(); - testLegacyBlobLiftsToSlotKeyingAndStillRestores(); + testLegacyBlobLiftsToSlotKeying(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0;