From c4038a9e9c196532bd12c99065268a24283e788e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 17:19:43 -0400 Subject: [PATCH 1/3] Strip FX writes from the Design View mode switch; split view.cpp into park/lanes/reconcile/tracks and add the record-scoped undo repair pass --- src/app/CMakeLists.txt | 22 +- src/app/main.cpp | 9 +- src/shell/actions/design_view_actions.cpp | 3 +- src/shell/capture/render_in_place.cpp | 3 +- src/shell/panel/panel_input.cpp | 3 +- src/shell/view/view.cpp | 513 ++-------------------- src/shell/view/view.h | 73 ++- src/shell/view/view_fx_park.cpp | 229 ---------- src/shell/view/view_fx_park.h | 185 -------- src/shell/view/view_lanes.cpp | 346 +++++++++++++++ src/shell/view/view_lanes.h | 43 ++ src/shell/view/view_park.cpp | 114 +++++ src/shell/view/view_park.h | 59 +++ src/shell/view/view_reconcile.cpp | 80 ++++ src/shell/view/view_reconcile.h | 58 +++ src/shell/view/view_solo.h | 10 +- src/shell/view/view_tracks.cpp | 47 ++ src/shell/view/view_tracks.h | 51 +++ tests/test_view_fx_park.cpp | 197 --------- tests/test_view_mode_model.cpp | 10 +- tests/test_view_park.cpp | 105 +++++ tests/test_view_reconcile.cpp | 158 +++++++ 22 files changed, 1172 insertions(+), 1146 deletions(-) delete mode 100644 src/shell/view/view_fx_park.cpp delete mode 100644 src/shell/view/view_fx_park.h create mode 100644 src/shell/view/view_lanes.cpp create mode 100644 src/shell/view/view_lanes.h create mode 100644 src/shell/view/view_park.cpp create mode 100644 src/shell/view/view_park.h create mode 100644 src/shell/view/view_reconcile.cpp create mode 100644 src/shell/view/view_reconcile.h create mode 100644 src/shell/view/view_tracks.cpp create mode 100644 src/shell/view/view_tracks.h delete mode 100644 tests/test_view_fx_park.cpp create mode 100644 tests/test_view_park.cpp create mode 100644 tests/test_view_reconcile.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index f07e6aa..4a8cb89 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -39,8 +39,11 @@ add_library(reaper_reasampler MODULE ${LICE_SRC} ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp - ${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_reconcile.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_tracks.cpp ${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp ${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp @@ -56,20 +59,19 @@ 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 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) +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 applied_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. target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) -# The park surface's decisions are pure (header-inline, no REAPER types), so they are -# CTest-covered like a core/ module: the snapshot trust test, the refusal message and -# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather -# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none -# — its TUs are compiled into this target directly. view_mode_model is linked for -# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built -# ops would not catch a change to the planner. -reasampler_test(view_fx_park LINK fx_offline view_mode_model) +# The view shell's decisions are pure (header-inline, no REAPER types), so they are +# CTest-covered like a core/ module: the Flag -> parameter mapping, the apply's +# undo-point fold, and the reconcile's scope rule and drift trigger. Declared here +# rather than in a src/shell/view/CMakeLists.txt because that directory deliberately +# has none — its TUs are compiled into this target directly. +reasampler_test(view_park LINK view_mode_model) +reasampler_test(view_reconcile LINK applied_state view_mode_model) # Bank-package import: the promptless verb plus its action skin. Kept as its own # appended block rather than merged into the lists above, so the two package diff --git a/src/app/main.cpp b/src/app/main.cpp index a851741..a2755f9 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -38,7 +38,9 @@ #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession -#include "shell/view/view.h" // reconcileManagedLanes / applyMode +#include "shell/view/view.h" // applyMode +#include "shell/view/view_lanes.h" // reconcileManagedLanes +#include "shell/view/view_reconcile.h" // reconcileAppliedFlags namespace capture = reasampler::capture; @@ -205,6 +207,11 @@ static void OnTimer() // the cross-session source of truth) BEFORE reapplying visibility. Never // re-mints, never mass-tags. reasampler::reconcileManagedLanes(g_session.view(), nullptr); + // An undo/redo reload also arrives here, and REAPER's undo does not restore + // the visibility flags a park drove (UNDO_STATE_TRACKCFG does not carry them). + // Repair the tracks the reloaded model no longer records BEFORE the reapply, + // which only ever drives tracks the model still records. + reasampler::reconcileAppliedFlags(g_session.view(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); } diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 7bf8ab9..8f7bdba 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -25,7 +25,8 @@ #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint -#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) +#include "shell/view/view.h" // applyMode (D2 shell) +#include "shell/view/view_lanes.h" // mintManagedLanes #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp index d6e343e..dfaf22c 100644 --- a/src/shell/capture/render_in_place.cpp +++ b/src/shell/capture/render_in_place.cpp @@ -21,7 +21,8 @@ #include "shell/capture/track_guid.h" // guidString #include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/persist/session.h" -#include "shell/view/view.h" // applyMode / mintManagedLanes +#include "shell/view/view.h" // applyMode +#include "shell/view/view_lanes.h" // mintManagedLanes #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountTrackMediaItems diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 9cdf01c..d2876e5 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -20,7 +20,8 @@ #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam #include "shell/capture/track_guid.h" // guidString — canonical track GUID key -#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch +#include "shell/view/view.h" // transportBlocksModeSwitch +#include "shell/view/view_lanes.h" // mintManagedLanes // New-content detection: enumerate live tracks + items and read fixed-lane state to // classify an item's lane as managed vs manual. diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 324520a..4bc63ce 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -1,98 +1,62 @@ // See view.h. Compiled into the reaper_reasampler module; includes // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). -// Tree arithmetic lives in view_tree (pure) and one track's park/restore in -// view_fx_park; this file owns the plan iteration, lanes, parents and the one -// undo block they all ride. +// Tree arithmetic lives in view_tree (pure), one track's park/restore in view_park +// and the lane writes in view_lanes; this file owns the plan iteration, parent +// visibility, and the one undo block they all ride. #include "shell/view/view.h" -#include #include -#include #include #include -#include -#include #include -#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" -#include "shell/view/view_fx_park.h" -#include "shell/view/view_solo.h" #include "core/view/view_tree.h" +#include "shell/view/view_lanes.h" +#include "shell/view/view_park.h" +#include "shell/view/view_reconcile.h" +#include "shell/view/view_solo.h" +#include "shell/view/view_tracks.h" #define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetPlayStateEx -#define REAPERAPI_WANT_GetTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_SetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetSetMediaTrackInfo_String -#define REAPERAPI_WANT_PreventUIRefresh #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_ValidatePtr2 -// Lane minting: item-side lane reads/writes assigning each item to its mode's lane. -#define REAPERAPI_WANT_CountTrackMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -#define REAPERAPI_WANT_GetMediaItemInfo_Value -#define REAPERAPI_WANT_SetMediaItemInfo_Value #include "reaper_plugin_functions.h" namespace reasampler { using view::buildFolderTree; -using view::isOnManualLane; -using view::managedLaneKey; -using view::modeIdFromLaneName; using view::TrackFolderEntry; namespace { -// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. -constexpr int kFreeModeFixedLanes = 2; - // GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is // deliberately excluded, nothing is moving there. constexpr int kTransportMoving = 1 | 4; // The domains one apply actually writes: track config/routing (visibility, -// B_MAINSEND, I_SOLO), item lane assignment, and the ext-state block -// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which -// includes UNDO_STATE_FX and so makes every undo record carry every track's FX -// state — a cost that scales with the project's plugin count rather than with -// what the switch changed. FX is OR'd back in per apply, only when I_FXEN or a -// per-FX offline state really moved (TrackApplyResult::fxMoved). -// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads -// "track/master vol/pan/routing" (reaper_plugin.h:1541) and names neither -// B_SHOWINTCP/B_SHOWINMIXER nor the fixed-lane properties (assumed to ride -// UNDO_STATE_ITEMS, :1543). Wrong ⇒ a Ctrl-Z restores less than the switch did. +// B_MAINSEND, I_FXEN, I_SOLO), item lane assignment, and the ext-state block +// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, and no +// UNDO_STATE_FX at all — the apply moves no FX state, so making every undo record +// carry the project's whole FX chunk would cost with the plugin count for nothing. +// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads "track/master +// vol/pan/routing" (reaper_plugin.h:1541) and names neither the fixed-lane properties +// (assumed to ride UNDO_STATE_ITEMS, :1543) nor B_SHOWINTCP/B_SHOWINMIXER — and for +// the visibility pair that inference is now DISPROVEN in the DAW: REAPER's undo does +// not restore them, which is what view_reconcile exists to repair. constexpr int kApplyUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; -// Holds REAPER's UI refresh off for the write phase; the deliberate rebuild -// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because -// an unbalanced pair leaves the user's UI frozen with no way back — the SDK's -// own warning at reaper_plugin_functions.h:5581. -struct UiRefreshHold { - UiRefreshHold() { PreventUIRefresh(1); } - ~UiRefreshHold() { PreventUIRefresh(-1); } - UiRefreshHold(const UiRefreshHold&) = delete; - UiRefreshHold& operator=(const UiRefreshHold&) = delete; -}; - -// applyMode is reachable from the load tick, the actions and render_in_place, and its -// TrackFX_SetOffline calls are assumed to pump the message loop — so any of them can -// re-enter it mid-apply, planning against the same model the outer loop is iterating. -// An inner restoreTrack consuming a snapshot the outer sits mid-park over leaves that -// track flags-restored, chain-offline and snapshotless: refused forever. Fail closed. +// applyMode is reachable from the load tick, the actions and render_in_place. Nothing +// it calls is known to pump the message loop now that the FX writes are gone, but a +// re-entrant apply would plan against the same model the outer loop is iterating and +// consume records out from under it — one dropped call is cheaper than that. Fail closed. bool g_applying = false; struct ApplyLatch { ApplyLatch() { g_applying = true; } @@ -101,275 +65,6 @@ struct ApplyLatch { ApplyLatch& operator=(const ApplyLatch&) = delete; }; -// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane -// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist -// but displays as non-fixed-lane). -constexpr int kLanesDisplayAsNormal = 2; - -// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the -// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint -// decision never mints an empty lane, so &1 buys nothing and risks REAPER -// silently removing a managed lane out from under the ownership index. -constexpr int kLaneSettingsHideButtons = 32; - -// Makes a tool-split track's display read as an ordinary track. Gated by every -// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before -// the flip) — a track already in fixed-lane mode (the user's own) never -// reaches this, so a user's comp-lane display prefs are never stomped. -void applyTransparentLaneDisplay(MediaTrack* tr) { - SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", - static_cast(kLanesDisplayAsNormal)); - const int settings = static_cast(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS")); - SetMediaTrackInfo_Value(tr, "C_LANESETTINGS", - static_cast(settings | kLaneSettingsHideButtons)); -} - -// The master track is not enumerated by GetTrack (index space excludes it), -// so it can never enter the tree — the master-untouched invariant holds by -// construction. -std::vector readFolderEntries( - ReaProject* proj, - TrackHandles& handleByGuid) { - std::vector entries; - int count = CountTracks(proj); - entries.reserve(static_cast(count)); - handleByGuid.reserve(static_cast(count)); - for (int i = 0; i < count; ++i) { - MediaTrack* tr = GetTrack(proj, i); - if (!tr) continue; - std::string guid = guidString(tr); - if (guid.empty()) continue; - int depth = static_cast(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); - entries.push_back(TrackFolderEntry{guid, depth}); - handleByGuid.emplace_back(guid, tr); - } - return entries; -} - -// Every park, restore, parent and lane op resolves its GUID; a linear scan made -// that O(T²) on the one path whose cost the user waits on. The vector form -// survives beside it because view_solo's writers take one pass over it. -using TrackByGuid = std::unordered_map; - -TrackByGuid indexHandles(const TrackHandles& handleByGuid) { - TrackByGuid byGuid; - byGuid.reserve(handleByGuid.size()); - for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second); - return byGuid; -} - -MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) { - auto it = byGuid.find(guid); - return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped -} - -// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / -// unavailable (non-fixed-lane track). -std::string laneName(MediaTrack* tr, int laneIdx) { - char parm[32]; - std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); - char buf[512] = {0}; - if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; - return std::string(buf); -} - -// Managed lane durable key -> current ordinal on `tr`. Manual lanes are -// omitted, so a key absent from the map must not be driven. -std::map managedLaneOrdinals(MediaTrack* tr) { - std::map byKey; - const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - for (int lane = 0; lane < numLanes; ++lane) { - std::optional key = managedLaneKey(laneName(tr, lane)); - if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide - } - return byKey; -} - -// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: -// item-side C_LANEPLAYS is read-only, so no per-item write exists or is -// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from -// C_LANEPLAYS=0/1, never written directly. -bool applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { - char parm[32]; - std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); - return writeIfChanged(tr, parm, static_cast(lanePlays)); -} - -// `freeMode` alone decides whether UpdateTimeline is owed; `wrote` is any project -// write at all, which is what the apply's undo-point decision reads. -struct LaneApplyResult { - bool freeMode = false; - bool wrote = false; -}; - -// Groups ops by track, reconciles each op's durable laneKey to the track's -// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven), -// enables fixed-lane mode on any track carrying a managed lane, and drives -// C_LANEPLAYS. UpdateTimeline() is the caller's job when `freeMode` comes back -// true (SDK: required after an I_FREEMODE change). -LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, - const std::vector& lanes) { - LaneApplyResult out; - if (lanes.empty()) return out; - - std::map> byTrack; - for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); - - for (const auto& [guid, ops] : byTrack) { - MediaTrack* tr = resolve(handleByGuid, guid); - if (!tr) continue; // stale GUID — prune - - // Every track reaching here already owns a managed lane (planToggle - // only emits ops for managed lanes), so re-asserting fixed-lane mode - // is always a tool-driven (re)split — never a user's untouched - // manual-fixed-lane track — and gets the same transparent display. - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) { - SetMediaTrackInfo_Value(tr, "I_FREEMODE", - static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); - out.freeMode = true; - out.wrote = true; - } - - const std::map ordinals = managedLaneOrdinals(tr); - for (const LanePlayOp* op : ops) { - auto it = ordinals.find(op->laneKey); - if (it == ordinals.end()) continue; // key not live on this track — prune - if (applyLanePlays(tr, it->second, op->lanePlays)) out.wrote = true; - } - } - return out; -} - -// One pass, so the assign loop needs no per-item re-scan. -std::map itemHandlesByGuid(MediaTrack* tr) { - std::map byGuid; - const int itemCount = CountTrackMediaItems(tr); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - std::string ig = itemGuid(it); - if (!ig.empty()) byGuid.emplace(std::move(ig), it); - } - return byGuid; -} - -// An untagged item is Arrange by default (mirrors leafBelongsToMode). A -// show-both/multi-mode item resolves to its first mode id — unusual for lane -// content, and any one mode is sufficient for the decision. -std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { - const std::set modes = model.membership().modesOf(itemGuid); - if (modes.empty()) return kArrangeModeId; - return *modes.begin(); -} - -// The per-track LaneItem picture the pure decision consumes. Manual-lane reads -// are skipped on a non-fixed-lane track (isOnManualLane is false there anyway). -std::vector readLaneTracks( - const ViewModeModel& model, - const TrackHandles& handleByGuid) { - std::vector tracks; - tracks.reserve(handleByGuid.size()); - for (const auto& [guid, tr] : handleByGuid) { - if (!tr) continue; // TrackHandles legally carries nulls post-validation (view.cpp) - LaneTrack lt; - lt.trackGuid = guid; - - const bool fixedLane = - static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; - - const int itemCount = CountTrackMediaItems(tr); - lt.items.reserve(static_cast(itemCount)); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - const std::string ig = itemGuid(it); - if (ig.empty()) continue; - LaneItem li; - li.guid = ig; - li.modeId = itemModeFromMembership(model, ig); - const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; - li.onManualLane = isOnManualLane(fixedLane, ln); - lt.items.push_back(std::move(li)); - } - tracks.push_back(std::move(lt)); - } - return tracks; -} - -// Idempotent, and non-destructive: only this reversible flag is written, never -// a move in time or across tracks. -bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { - const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); - if (current == laneOrdinal) return false; // already there — no-op - SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast(laneOrdinal)); - return true; -} - -// Applies the pure LaneMintPlan. Returns true if any project write actually -// changed state (⇒ caller keeps the Undo block and refreshes the timeline). -// -// The plan only ever names managed-prefixed lanes and only ever assigns -// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, -// so a user's existing manual lanes are never renamed or reassigned. -bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, - const TrackByGuid& handleByGuid) { - bool changed = false; - - std::map> mintsByTrack; - for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); - std::map> assignsByTrack; - for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a); - - for (const LaneMintPlan::TrackSplit& split : plan.splits) { - MediaTrack* tr = resolve(handleByGuid, split.trackGuid); - if (!tr) continue; // stale GUID — prune - - // A track not already in fixed-lane mode is one the tool is splitting - // now, so it owns the display; a track already at I_FREEMODE==2 (the - // user's own, or a prior tool run) skips this and keeps its display prefs. - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) { - SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); - changed = true; - } - - // Grow-only: a track may already carry the user's manual lanes, so the - // lane count only ever increases; managed lanes occupy the tail ordinals. - int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - - std::map present = managedLaneOrdinals(tr); - - for (const LaneMint* m : mintsByTrack[split.trackGuid]) { - model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership - if (present.count(m->laneKey)) continue; // already minted — idempotent - - const int laneIdx = laneCount++; - SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); - char parm[32]; - std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); - std::vector name(m->laneKey.begin(), m->laneKey.end()); - name.push_back('\0'); - GetSetMediaTrackInfo_String(tr, parm, name.data(), true); - present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass - changed = true; - } - - const std::map ordinals = managedLaneOrdinals(tr); - const std::map itemsByGuid = itemHandlesByGuid(tr); - for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { - auto ord = ordinals.find(a->laneKey); - if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign - auto handle = itemsByGuid.find(a->itemGuid); - if (handle == itemsByGuid.end()) continue; // stale item GUID — prune - if (assignItemToLane(tr, handle->second, ord->second)) changed = true; - } - } - return changed; -} - } // namespace bool transportBlocksModeSwitch(ReaProject* proj) { @@ -386,9 +81,9 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject return false; // reject before touching the project — no partial apply } - // Pinned ONCE: begin and end sit a whole apply apart with the pump premise between - // them, so resolving "current project" independently at each would let a tab switch - // mid-apply open the block on one project and close it on another. + // Pinned ONCE: resolving "current project" independently at the begin and the end + // would let a tab switch mid-apply open the block on one project and close it on + // another. ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false; @@ -405,13 +100,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject TrackHandles handleByGuid; std::vector entries = readFolderEntries(project, handleByGuid); - TrackByGuid trackByGuid = indexHandles(handleByGuid); + const TrackByGuid trackByGuid = indexHandles(handleByGuid); FolderTree tree = buildFolderTree(entries); - // Prune snapshots for tracks no longer in the live enumeration before - // planning (membership is intentionally left alone — see model.reconcile). - // Because reapply-on-load routes through applyMode, this also reconciles - // on project open. + // Prune records for tracks no longer in the live enumeration before planning + // (membership is intentionally left alone — see model.reconcile). Because + // reapply-on-load routes through applyMode, this also reconciles on project open. std::set liveGuids; for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); model.reconcile(liveGuids); @@ -430,12 +124,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject "ReaSampler: activate " + (targetMode ? targetMode->displayName : targetModeId) + " view"; - int undoMask = kApplyUndoMask; bool laneModeChanged = false; bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint - std::vector refusedParkNames; - FxRestoreDrops fxDrops; - int fxDropTracks = 0; Undo_BeginBlock2(project); { @@ -448,55 +138,32 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject clearTrackSolos(handleByGuid, outgoingSolo); } - // PARK / RESTORE: view_fx_park owns one track's whole move, flags and - // per-FX offline together. A refusal is named HERE, while the handle is - // fresh — never held past Undo_EndBlock2/TrackList_AdjustWindows. + // PARK / RESTORE: view_park owns one track's whole move. Flags only — the + // parked chain stays online until the reclaimer reaches it. for (const TrackPlan& tp : plan.park) { if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); - // Re-validated per track, not once at resolve: offlining a plugin is - // ASSUMED to pump the message loop, so a track deleted during an - // earlier iteration's FX writes would leave this handle dangling. - if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune - - const TrackApplyResult r = parkTrack(model, guid, tr, tp.flags); - if (r.refused) { refusedParkNames.push_back(trackDisplayName(tr)); continue; } - if (r.wrote) wrote = true; - if (r.fxMoved) undoMask |= UNDO_STATE_FX; + if (!tr) continue; // stale/deleted GUID — prune + if (parkTrack(model, guid, tr, tp.flags)) wrote = true; } for (const TrackPlan& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); - if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune - - 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; } + if (!tr) continue; // stale/deleted GUID — prune + if (restoreTrack(model, guid, tr, tp.flags)) wrote = true; } - // The FX writes above are the only thing here assumed to pump the message - // loop, so this is the one point a handle taken before them can have died. - // Re-validated for the whole enumeration rather than per consumer: lane ops, - // parent visibility and the solo replay all write through pre-FX handles. - for (auto& kv : handleByGuid) { - if (trackStillLive(project, kv.second)) continue; - trackByGuid.erase(kv.first); - kv.second = nullptr; // view_solo's writers already skip a null handle - } - - // MANAGED LANES: the active mode's lane plays+shows, every other managed - // lane is silenced+hidden. Empty on a D1-only project — byte-identical there. + // MANAGED LANES: the active mode's lane plays+shows, every other managed lane + // is silenced+hidden. Empty on a D1-only project — byte-identical there. const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes); laneModeChanged = lanes.freeMode; if (lanes.wrote) wrote = true; - // PARENT VISIBILITY (never parked): recomputed every toggle, never - // snapshotted. Only the two visibility flags — never mainSend/FX on a parent. + // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded. + // Only the two visibility flags — never mainSend/FX on a parent. std::set visible = model.visibleTracks(tree, targetModeId); for (const FolderNode& node : tree.nodes) { if (!node.isParent) continue; @@ -511,9 +178,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject // Replay + consume, before the single relayout below picks the change up. // Dropped against `visible` (computed above for parent visibility), not the - // park plan: a folder parent can be hidden without being parked, and a - // hidden track must not receive a replayed solo it carries no visible - // control to undo. + // park plan: a folder parent can be hidden without being parked, and a hidden + // track must not receive a replayed solo it carries no visible control to undo. if (realSwitch) { if (const std::map* cached = model.soloCache().query(targetModeId)) { restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); @@ -523,105 +189,22 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject } // Force REAPER to rebuild the TCP/MCP now rather than on the next user - // interaction — AdjustWindows(false) is the full relayout tracks appearing - // and disappearing owes. + // interaction — AdjustWindows(false) is the full relayout tracks appearing and + // disappearing owes. TrackList_AdjustWindows(false); UpdateArrange(); - // UpdateTimeline() is owed only when a track was actually toggled into - // fixed lanes this apply (SDK requirement for I_FREEMODE changes). + // UpdateTimeline() is owed only when a track was actually toggled into fixed lanes + // this apply (SDK requirement for I_FREEMODE changes). if (laneModeChanged) UpdateTimeline(); const bool mint = applyMintsUndoPoint(realSwitch, wrote); - Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? undoMask : 0); + Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kApplyUndoMask : 0); - // After the undo block and the UI hold: a report is not a project write, and - // it must not join what a Ctrl-Z rolls back. - reportRefusedParks(project, refusedParkNames); - reportFxRestoreDrops(fxDrops, fxDropTracks); + // The tool's own account of what it just left driven — the only one that survives + // an undo rolling the model back underneath it. + noteAppliedState(project, model); return true; } -bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { - TrackHandles handleByGuid; - std::vector entries = readFolderEntries(proj, handleByGuid); - // The tree is needed to detect a content-bearing folder derived-visible in - // >1 mode, exactly as applyMode builds it. - const FolderTree tree = buildFolderTree(entries); - - const std::vector tracks = readLaneTracks(model, handleByGuid); - const LaneMintPlan plan = planLaneMinting(model, tree, tracks); - if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick - - const TrackByGuid trackByGuid = indexHandles(handleByGuid); - - // `proj` deliberately NOT pinned the way applyMode pins it: nothing between this - // begin and its end pumps the message loop, so no tab switch can land between them. - Undo_BeginBlock2(proj); - const bool changed = applyMintPlan(model, plan, trackByGuid); - - if (!changed) { - // Plan was non-empty but every write was already satisfied — discard - // the empty undo point rather than flooding history every detect tick. - Undo_EndBlock2(proj, "", 0); - - // The arrange still needs a redraw: this no-op path is reached when a - // freshly-inserted item already landed on the active mode's playing - // lane (REAPER places new items on the playing lane), so - // assignItemToLane wrote nothing even though the item needs to appear - // there now. UpdateArrange() alone (no I_FREEMODE change happened, so - // UpdateTimeline isn't owed) is a repaint, not a mutation — stays - // outside the undo block. - UpdateArrange(); - return false; - } - - // Reapply the active mode's lane visibility so freshly-minted lanes take - // their play/show state immediately. applyMode is deliberately NOT reused - // here — it would re-park/restore whole tracks and recompute parent - // visibility, which a lane-only mint must not touch. - const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId()); - applyLaneOps(trackByGuid, togglePlan.lanes); - - UpdateTimeline(); // a split happened this call — refresh is owed - UpdateArrange(); - - // Lane minting writes track lane config and item lane assignment and nothing - // else — never an FX state — so it takes the same honest mask applyMode does - // (kApplyUndoMask, whose [verify — DAW] on lane-property domain binds here too). - Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kApplyUndoMask); - return true; -} - -void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { - TrackHandles handleByGuid; - readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) - - // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ - // I_FIXEDLANE written) plus an ownership-index write, recovering managed - // classification from the durable name. An unprefixed lane is left alone. - for (const auto& [guid, tr] : handleByGuid) { - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed - - const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - for (int lane = 0; lane < numLanes; ++lane) { - const std::string name = laneName(tr, lane); - std::optional key = managedLaneKey(name); - if (!key) continue; // manual/unnamed lane — leave off the index - std::optional mode = modeIdFromLaneName(name); - if (!mode) continue; // prefix-only/illegal name — skip defensively - - // A mode id encoded in the name may no longer be registered (e.g. - // removed since the project was saved). Recording it managed would - // make the toggle planner drive a lane keyed to a mode that can - // never be active — permanently silenced, orphaning its items. So - // skip: the lane stays off the index (manual-by-default) but keeps - // its name, so a later re-registration of the mode heals cleanly. - if (!model.modes().contains(*mode)) continue; - model.lanes().setManaged(guid, *key, *mode); - } - } -} - } // namespace reasampler diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 46d3a8c..0efb4b5 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,10 +1,9 @@ #pragma once -// REAPER-facing shell of Design View (D2): reads the live folder tree, runs -// ViewModeModel's pure planner, and applies the resulting flag / per-FX / -// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: -// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for -// the enforced invariants (never touch master/mute, solo cached per mode and -// never lost, snapshot-based restore). +// REAPER-facing shell of Design View: reads the live folder tree, runs +// ViewModeModel's pure planner, and applies the resulting flag / lane / parent +// writes under ONE undo block. See src/shell/view/CLAUDE.md for the enforced +// invariants (never touch master/mute, solo cached per mode and never lost, +// record-based restore). #include @@ -25,51 +24,41 @@ bool transportBlocksModeSwitch(ReaProject* proj); // Does one applyMode leave an undo point behind? // -// A REAPPLY that wrote nothing must not: the load-tick reapply over a project -// saved fully parked re-plans a park for every inactive leaf and finds every -// flag and every FX already where it wants them, so a point there would mean -// opening a project costs an undo step. `Undo_EndBlock2(proj, "", 0)` is the -// discard form (mintManagedLanes' no-op path uses the same idiom). +// A REAPPLY that wrote nothing must not: the load-tick reapply over a project saved +// fully parked re-plans a park for every inactive leaf and finds every flag already +// where it wants them, so a point there would mean opening a project costs an undo +// step. `Undo_EndBlock2(proj, "", 0)` is the discard form (mintManagedLanes' no-op +// path and the reconcile pass use the same idiom). // -// A real switch mints regardless, fail-safe: it is an explicitly fired action -// and has to stay undoable even in the degenerate case where the plan found -// nothing to write. Note what applyMode CANNOT see when it decides — the -// `view_state` ext-state write lands in persistViewState, after this block has -// closed (src/shell/view/CLAUDE.md's Gotchas). +// A real switch mints regardless, fail-safe: it is an explicitly fired action and has +// to stay undoable even in the degenerate case where the plan found nothing to write. +// Note what applyMode CANNOT see when it decides — the `view_state` ext-state write +// lands in persistViewState, after this block has closed (src/shell/view/CLAUDE.md's +// Gotchas). inline bool applyMintsUndoPoint(bool realSwitch, bool wroteAnything) { return realSwitch || wroteAnything; } -// True while an applyMode call is on the stack. applyMode fails closed when re-entered -// (its FX writes are assumed to pump the message loop, so a timer tick or an action can -// land inside one), and this lets a caller with no fallback test BEFORE it spends a +// True while an applyMode call is on the stack. applyMode fails closed when +// re-entered, and this lets a caller with no fallback test BEFORE it spends a // one-shot signal on a call that would be refused — see main.cpp's load glue. bool modeApplyInProgress(); -// Snapshots each about-to-park track's flags into `model`, caches/clears the -// outgoing mode's solo state and replays the incoming mode's, runs planToggle, -// applies park/restore writes — flags AND per-FX offline, synchronously — plus -// parent visibility flags, then sets the active mode. ONE switch is ONE undo -// point; everything it moved rolls back in a single Ctrl-Z. Returns false (no -// mutation) if `targetModeId` isn't registered, if this is a real switch -// (target != active) while the transport is running, if it is re-entered +// Captures each about-to-park track's prior flag values into `model`, caches/clears +// the outgoing mode's solo state and replays the incoming mode's, runs planToggle, +// applies the park/restore flag writes plus managed-lane and parent-visibility +// writes, then sets the active mode. ONE switch is ONE undo point; everything it +// moved rolls back in a single Ctrl-Z. +// +// FX are NOT written here. Offlining a parked chain is the reclaimer's job, off this +// path: TrackFX_SetOffline was 1897ms of a measured 1942ms switch, against 0.02ms for +// all the flag work below. Do not reintroduce a synchronous FX write. +// +// Returns false (no mutation) if `targetModeId` isn't registered, if this is a real +// switch (target != active) while the transport is running, if it is re-entered // (modeApplyInProgress), or if the project does not validate. A reapply (target == -// active) is never gated and never touches solo. `proj` == nullptr means the -// current project, resolved ONCE and used for every call this apply makes. +// active) is never gated and never touches solo. `proj` == nullptr means the current +// project, resolved ONCE and used for every call this apply makes. bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); -// Splits any track visible in more than one mode while carrying its own media -// into fixed lanes (one managed lane per involved mode), assigns items, and -// records ownership in `model`. Never touches manual lanes. Runs planLaneMinting -// (model + tree aware); wraps the mutation in one Undo block when non-empty. -// Returns true if any lane was minted (repaint hint). `proj` == nullptr means -// the current project. -bool mintManagedLanes(ViewModeModel& model, ReaProject* proj); - -// Recovers managed-lane ownership from durable P_LANENAME on project open — -// self-healing, without minting/reassigning anything and without touching -// membership. A lane without the managed prefix is left untouched (manual). -// `proj` == nullptr means the current project. -void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); - } // namespace reasampler diff --git a/src/shell/view/view_fx_park.cpp b/src/shell/view/view_fx_park.cpp deleted file mode 100644 index 6f01da7..0000000 --- a/src/shell/view/view_fx_park.cpp +++ /dev/null @@ -1,229 +0,0 @@ -// See view_fx_park.h. Compiled into the reaper_reasampler module; includes -// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). -// The decisions are pure (header, test_view_fx_park.cpp); this file owns the -// REAPER reads/writes and their ordering against the live enumeration. - -#include "shell/view/view_fx_park.h" - -#include // strnlen — bounded read of GetTrackName's buffer -#include -#include - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetTrackName -#define REAPERAPI_WANT_SetMediaTrackInfo_Value -#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_ValidatePtr2 -#define REAPERAPI_WANT_guidToString -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -// The last refusal report, so an unchanged one is not reprinted on every reapply. -ReaProject* g_reportedOwner = nullptr; -std::vector g_reportedRefusals; - -ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); } - -// Returns whether it actually wrote — the apply's undo point and its -// UNDO_STATE_FX bit both hang off that verdict. -// -// [verify — DAW] TrackFX_SetOffline unloads and re-instantiates the plugin, so -// writing a state that already holds is assumed to cost the same unload/reload -// as a real change — the header documents no internal short-circuit either way. -// The compare is cheap and correct regardless; only the size of what it saves is -// unconfirmed. -bool setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) { - if (TrackFX_GetOffline(tr, fx) == offline) return false; - TrackFX_SetOffline(tr, fx, offline); - return true; -} - -// 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 liveFxGuids(MediaTrack* tr) { - const int fxCount = TrackFX_GetCount(tr); - std::vector guids; - guids.reserve(static_cast(fxCount)); - for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx)); - return guids; -} - -// ANY offline FX, not every — see the snapshot-source invariant (view_fx_park.h, -// src/shell/view/CLAUDE.md) for why. -bool anyFxOffline(MediaTrack* tr) { - const int fxCount = TrackFX_GetCount(tr); - for (int fx = 0; fx < fxCount; ++fx) - if (TrackFX_GetOffline(tr, fx)) return true; - return false; -} - -// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO; -// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract). -// Must run before EITHER half of the park writes — both halves are read here. -// `fxKeying` stays Identity: a live read always knows the chain. -TrackSnapshot snapshotTrack(MediaTrack* tr) { - TrackSnapshot snap; - auto read = [tr](Flag f) { - return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); - }; - snap.showInTcp = read(Flag::ShowInTcp); - snap.showInMixer = read(Flag::ShowInMixer); - snap.mainSend = read(Flag::MainSend); - snap.fxEnable = read(Flag::FxEnable); - - const std::vector 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(fx)) ? 1 : 0}); - return snap; -} - -// Returns whether it wrote; raises `fxMoved` when a flag in the FX domain moved. -bool applyFlags(MediaTrack* tr, const std::vector& flags, bool& fxMoved) { - bool wrote = false; - for (const TrackFlagOp& op : flags) { - if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) continue; - wrote = true; - // I_FXEN is the only FX-domain flag driven here, so it is the only one - // whose undo record has to carry UNDO_STATE_FX. - if (op.flag == Flag::FxEnable) fxMoved = true; - } - return wrote; -} - -} // namespace - -// GetTrackName, not P_NAME, matching capture::trackName's reason for the same -// choice. The buffer is read bounded — the SDK documents no NUL within -// bufOut_sz. A track the report cannot name still has to appear in it, so the -// read failing yields a placeholder rather than dropping the entry. -std::string trackDisplayName(MediaTrack* tr) { - std::vector buf(1024, '\0'); - if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast(buf.size()))) - return "(name unavailable)"; - return std::string(buf.data(), strnlen(buf.data(), buf.size())); -} - -ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot, - const std::vector& parkFlags) { - if (haveSnapshot) return decidePark(true, false); // second term unread — skip the reads - const bool flagsRead = parkFlagsAlreadyApplied(parkFlags, [tr](Flag f) { - return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); - }); - return decidePark(false, chainReadsParked(flagsRead, anyFxOffline(tr))); -} - -void reportRefusedParks(ReaProject* proj, const std::vector& refusedNames) { - ReaProject* const owner = proj ? proj : currentProject(); - - if (!shouldReport(owner == g_reportedOwner, g_reportedRefusals, refusedNames)) return; - g_reportedOwner = owner; - g_reportedRefusals = refusedNames; - - const std::string msg = describeRefusedParks(refusedNames); - // "!SHOW:" so an unattended project-load reapply cannot force the console open - // (reaper_plugin_functions.h:6536). - if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); -} - -bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { - if (GetMediaTrackInfo_Value(tr, parm) == value) return false; - SetMediaTrackInfo_Value(tr, parm, value); - return true; -} - -bool trackStillLive(ReaProject* proj, MediaTrack* tr) { - return ValidatePtr2(proj ? proj : currentProject(), tr, "MediaTrack*"); -} - -TrackApplyResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags) { - TrackApplyResult out; - - const ParkAction action = decideParkForTrack(tr, model.snapshot(guid) != nullptr, flags); - if (action == ParkAction::Refuse) { - out.refused = true; - return out; - } - if (action == ParkAction::SnapshotThenPark) { - model.storeSnapshot(guid, snapshotTrack(tr)); - out.wrote = true; - } - - if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true; - - const int fxCount = TrackFX_GetCount(tr); - for (int fx = 0; fx < fxCount; ++fx) - if (setOfflineIfChanged(tr, fx, true)) out.fxMoved = out.wrote = true; - return out; -} - -TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& fxOps, - const std::vector& flags) { - TrackApplyResult out; - - // Which live FX each captured state belongs to is resolveFxRestore's call — - // the plan is written VERBATIM, never a blanket "online". - const FxRestoreResolution res = resolveFxRestore(fxOps, liveFxGuids(tr)); - 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 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 record either way, that being - // model state persisted after Undo_EndBlock2 (src/shell/view/CLAUDE.md). - out.wrote = true; - return out; -} - -void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) { - const std::string msg = describeFxRestoreDrops(drops, trackCount); - // "!SHOW:" (reaper_plugin_functions.h:6536) keeps it from force-opening the - // console: this also runs behind an unattended project-load reapply, and - // cannot tell that case from an interactive toggle. - if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); -} - -} // namespace reasampler diff --git a/src/shell/view/view_fx_park.h b/src/shell/view/view_fx_park.h deleted file mode 100644 index a4e7129..0000000 --- a/src/shell/view/view_fx_park.h +++ /dev/null @@ -1,185 +0,0 @@ -#pragma once -// Design View's park surface: one track parked or restored — the trust test, -// the snapshot lifecycle, and the flag + per-FX writes both halves address -// through this one file. Every write declared here runs synchronously inside -// applyMode's own undo block, which is what makes one switch one undo point — -// see src/shell/view/CLAUDE.md's FX-parking caveat. - -#include -#include - -#include "core/view/fx_offline.h" -#include "core/view/view_mode_model.h" // Flag / TrackFlagOp — what a park writes - -// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. -class MediaTrack; -class ReaProject; - -namespace reasampler { - -// -- may this chain be snapshotted? ------------------------------------------- -// -// A pre-park snapshot may only ever be taken from a chain NO park has touched. -// Its values are restore's sole source of truth, so a snapshot lifted from an -// already-parked chain records park state as the user's state, and every later -// restore then faithfully writes hidden/out-of-mix/FX-disabled back — permanent -// and silent. A model carrying no snapshot is NOT evidence the chain is clean; -// src/shell/view/CLAUDE.md lists the routes that still reach that pair. -// -// A snapshot has TWO halves and the test covers both, disjunctively: the flags -// can read clean while the FX are still offline (a hand recovery that stopped at -// the flags, one hand-offlined plugin) and vice versa. Either half reading -// parked poisons the whole snapshot, so either half alone refuses. - -// The REAPER track parameter each driven flag addresses. One home for the -// mapping — park's live read, restore's write and the trust test below must -// never disagree about which parameter a Flag names. -inline const char* trackFlagParm(Flag f) { - switch (f) { - case Flag::ShowInTcp: return "B_SHOWINTCP"; - case Flag::ShowInMixer: return "B_SHOWINMIXER"; - case Flag::MainSend: return "B_MAINSEND"; - case Flag::FxEnable: return "I_FXEN"; - } - return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet -} - -// Does the track already sit at every value this park would write? Then a park -// reached it. `readFlag` answers one Flag's live value; templating on it lets the -// live path compare as it reads (no per-track vector) while the tests drive the -// same fold from a table, keyed by Flag rather than by plan order. An empty plan -// is evidence of nothing and answers false. -template -bool parkFlagsAlreadyApplied(const std::vector& parkFlags, ReadFlag readFlag) { - if (parkFlags.empty()) return false; - for (const TrackFlagOp& op : parkFlags) - if (readFlag(op.flag) != op.value) return false; - return true; -} - -// DISJUNCTIVE fold — rationale is the snapshot-source invariant above (also -// src/shell/view/CLAUDE.md). -inline bool chainReadsParked(bool parkFlagsRead, bool anyFxOffline) { - return parkFlagsRead || anyFxOffline; -} - -enum class ParkAction { - SnapshotThenPark, // clean chain — capture the pre-park truth, then park - ParkOnly, // still parked; the held snapshot IS the pre-park truth - Refuse, // no snapshot AND a parked chain — the truth is gone -}; - -// Refusing leaves the track exactly as found: no flag write, no FX write, no -// snapshot. Nothing false is committed, so the moment the user's own edit puts -// the chain back somewhere trustworthy the next park captures it correctly — -// where a snapshot of the parked state would have closed that door for good. -inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) { - if (haveSnapshot) return ParkAction::ParkOnly; - return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark; -} - -// `decidePark` over the live track: the four driven flags AND the FX chain, which -// a snapshot taken here would record both halves of. Skips the reads on the -// ParkOnly path, where decidePark ignores the second term anyway. -ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot, - const std::vector& parkFlags); - -// "Track N" for an unnamed track (GetTrackName, matching capture::trackName's -// choice of API for the same reason). Exported so a caller holding the handle -// only briefly (view.cpp's park loop, before Undo_EndBlock2/TrackList_AdjustWindows -// invalidate it) can capture the name at resolve time rather than the handle. -std::string trackDisplayName(MediaTrack* tr); - -// The console report for the tracks one apply refused, empty when none. NAMES -// them: a refused track is hidden from both the TCP and the mixer, so a count -// alone points at nothing the user can go and find. It asserts no cause — the -// pair it detects has several routes (src/shell/view/CLAUDE.md), and only some of -// them are anyone's mistake. -inline std::string describeRefusedParks(const std::vector& trackNames) { - if (trackNames.empty()) return {}; - std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" + - (trackNames.size() == 1 ? "" : "s") + - " left unparked -- no stored pre-park state, and a live state that " - "already reads as parked; parking now would record that as your own " - "state.\n"; - for (const std::string& name : trackNames) msg += " " + name + "\n"; - msg += "To bring a track back under mode control: restore its TCP and mixer visibility, " - "its main send and its FX enable, AND set every FX in its chain online -- FX enable " - "is the chain bypass and does not bring an individually offlined FX back. The next " - "mode switch will then park it correctly.\n"; - return msg; -} - -// Pure gate behind reportRefusedParks' one-per-changed-set memo: prints (i.e. -// updates the memo) on any change of owner or of the named set, stays silent on -// an exact repeat. An empty `now` still counts as a change from a non-empty -// `last` — that update itself prints nothing (describeRefusedParks({}) is -// empty) but resets the memo so a later refusal is never mistaken for a repeat -// of one that already healed. -inline bool shouldReport(bool sameOwnerAsLast, const std::vector& last, - const std::vector& now) { - return !(sameOwnerAsLast && last == now); -} - -// Prints describeRefusedParks for `refusedNames` (captured by the caller while its -// track handles are still fresh — see view.cpp's park loop), ONCE per changed set -// per project: the same refusal recurs on every reapply — every project open, -// every tag/untag — and repeating it verbatim is noise the user cannot act on any -// better the second time. Gated by shouldReport. -void reportRefusedParks(ReaProject* proj, const std::vector& refusedNames); - -// -- parking and restoring one track ------------------------------------------ -// -// Both run INSIDE applyMode's undo block, on the switch's own synchronous path, -// so one switch is one undo point. - -// A track parameter written only when it is not already there. A reapply -// re-plans every flag it already applied, so most of what it writes is a value -// the track already holds. The read is ASSUMED cheaper than the write it elides -// (unmeasured); what it certainly does is keep those no-op writes out of the -// undo record's mask. Returns whether it wrote. -bool writeIfChanged(MediaTrack* tr, const char* parm, double value); - -// Is `tr` still a live track of `proj` (nullptr = current project)? The apply's -// loops re-ask per track: a plugin unloading is assumed to pump the message -// loop, so a handle resolved before the first FX write can be dead by the last. -// `proj` is resolved to a concrete pointer here because ValidatePtr2 documents -// no nullptr-means-current-project contract (reaper_plugin_functions.h:7899), -// and a false there would silently skip every track. -bool trackStillLive(ReaProject* proj, MediaTrack* tr); - -// What one track's park or restore did, folded into the apply's block state. -struct TrackApplyResult { - bool wrote = false; // a project write or a snapshot moved — the undo-point verdict - bool fxMoved = false; // ⇒ the block's mask owes UNDO_STATE_FX - bool refused = false; // park only: the chain was left exactly as found - - // Restore only: captured FX state resolveFxRestore could not place. Reported - // rather than swallowed — silence would read as "restore worked" while an FX - // sat at whatever state the park left it in. - FxRestoreDrops drops; -}; - -// Parks one inactive leaf: the trust decision (decidePark), the pre-park -// snapshot when it is owed, then flags before FX so the track is out of the mix -// and bypassed before its plugins start unloading. On `refused` nothing at all -// was written — the caller names the track (trackDisplayName) while its handle -// is still fresh. -TrackApplyResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags); - -// Restores one returning leaf: FX before flags, park's order mirrored, so the -// chain comes back online while still bypassed and out of the mix and the track -// never sounds through a half-restored chain. Consumes the snapshot — from that -// point the flags are back at their captured values, and a later reapply must -// not replan a restore over whatever the user has changed since. -TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& fxOps, - const std::vector& flags); - -// One console line for a whole apply's drops. Called after the undo block -// closes: a report is not a project write and must not join what a Ctrl-Z rolls -// back. -void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount); - -} // namespace reasampler diff --git a/src/shell/view/view_lanes.cpp b/src/shell/view/view_lanes.cpp new file mode 100644 index 0000000..5dba34d --- /dev/null +++ b/src/shell/view/view_lanes.cpp @@ -0,0 +1,346 @@ +// See view_lanes.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The lane decisions are pure (planToggle / planLaneMinting, core/view); this file +// owns the live lane reads and writes they resolve against. + +#include "shell/view/view_lanes.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/view/lane_keys.h" +#include "core/view/view_tree.h" +#include "shell/capture/item_read.h" +#include "shell/view/view_park.h" // writeIfChanged — the read-before-write every driven parm takes + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_UpdateTimeline +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_SetMediaItemInfo_Value +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using view::buildFolderTree; +using view::isOnManualLane; +using view::managedLaneKey; +using view::modeIdFromLaneName; +using view::TrackFolderEntry; + +namespace { + +// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. +constexpr int kFreeModeFixedLanes = 2; + +// The same domains the apply's own mask names (view.cpp's kApplyUndoMask, which owns +// the rationale): lane minting writes track lane config and item lane assignment. +constexpr int kLaneUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; + +// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane track +// showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist but displays +// as non-fixed-lane). +constexpr int kLanesDisplayAsNormal = 2; + +// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the mask. +// Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint decision never +// mints an empty lane, so &1 buys nothing and risks REAPER silently removing a +// managed lane out from under the ownership index. +constexpr int kLaneSettingsHideButtons = 32; + +// Makes a tool-split track's display read as an ordinary track. Gated by every caller +// on the tool-driven transition INTO fixed lanes (freeMode != 2 before the flip) — a +// track already in fixed-lane mode (the user's own) never reaches this, so a user's +// comp-lane display prefs are never stomped. +void applyTransparentLaneDisplay(MediaTrack* tr) { + SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", + static_cast(kLanesDisplayAsNormal)); + const int settings = static_cast(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS")); + SetMediaTrackInfo_Value(tr, "C_LANESETTINGS", + static_cast(settings | kLaneSettingsHideButtons)); +} + +// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / +// unavailable (non-fixed-lane track). +std::string laneName(MediaTrack* tr, int laneIdx) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + char buf[512] = {0}; + if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; + return std::string(buf); +} + +// Managed lane durable key -> current ordinal on `tr`. Manual lanes are omitted, so a +// key absent from the map must not be driven. +std::map managedLaneOrdinals(MediaTrack* tr) { + std::map byKey; + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + std::optional key = managedLaneKey(laneName(tr, lane)); + if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide + } + return byKey; +} + +// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: item-side +// C_LANEPLAYS is read-only, so no per-item write exists or is needed). +// B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from C_LANEPLAYS=0/1, +// never written directly. +bool applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); + return writeIfChanged(tr, parm, static_cast(lanePlays)); +} + +// One pass, so the assign loop needs no per-item re-scan. +std::map itemHandlesByGuid(MediaTrack* tr) { + std::map byGuid; + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + std::string ig = itemGuid(it); + if (!ig.empty()) byGuid.emplace(std::move(ig), it); + } + return byGuid; +} + +// An untagged item is Arrange by default (mirrors leafBelongsToMode). A +// show-both/multi-mode item resolves to its first mode id — unusual for lane content, +// and any one mode is sufficient for the decision. +std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { + const std::set modes = model.membership().modesOf(itemGuid); + if (modes.empty()) return kArrangeModeId; + return *modes.begin(); +} + +// The per-track LaneItem picture the pure decision consumes. Manual-lane reads are +// skipped on a non-fixed-lane track (isOnManualLane is false there anyway). +std::vector readLaneTracks(const ViewModeModel& model, + const TrackHandles& handleByGuid) { + std::vector tracks; + tracks.reserve(handleByGuid.size()); + for (const auto& [guid, tr] : handleByGuid) { + LaneTrack lt; + lt.trackGuid = guid; + + const bool fixedLane = + static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + + const int itemCount = CountTrackMediaItems(tr); + lt.items.reserve(static_cast(itemCount)); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + const std::string ig = itemGuid(it); + if (ig.empty()) continue; + LaneItem li; + li.guid = ig; + li.modeId = itemModeFromMembership(model, ig); + const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; + li.onManualLane = isOnManualLane(fixedLane, ln); + lt.items.push_back(std::move(li)); + } + tracks.push_back(std::move(lt)); + } + return tracks; +} + +// Idempotent, and non-destructive: only this reversible flag is written, never a move +// in time or across tracks. +bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { + const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); + if (current == laneOrdinal) return false; // already there — no-op + SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast(laneOrdinal)); + return true; +} + +// Applies the pure LaneMintPlan. Returns true if any project write actually changed +// state (⇒ caller keeps the Undo block and refreshes the timeline). +// +// The plan only ever names managed-prefixed lanes and only ever assigns +// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, so a +// user's existing manual lanes are never renamed or reassigned. +bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, + const TrackByGuid& handleByGuid) { + bool changed = false; + + std::map> mintsByTrack; + for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); + std::map> assignsByTrack; + for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a); + + for (const LaneMintPlan::TrackSplit& split : plan.splits) { + MediaTrack* tr = resolve(handleByGuid, split.trackGuid); + if (!tr) continue; // stale GUID — prune + + // A track not already in fixed-lane mode is one the tool is splitting now, so + // it owns the display; a track already at I_FREEMODE==2 (the user's own, or a + // prior tool run) skips this and keeps its display prefs. + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); + changed = true; + } + + // Grow-only: a track may already carry the user's manual lanes, so the lane + // count only ever increases; managed lanes occupy the tail ordinals. + int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + + std::map present = managedLaneOrdinals(tr); + + for (const LaneMint* m : mintsByTrack[split.trackGuid]) { + model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership + if (present.count(m->laneKey)) continue; // already minted — idempotent + + const int laneIdx = laneCount++; + SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + std::vector name(m->laneKey.begin(), m->laneKey.end()); + name.push_back('\0'); + GetSetMediaTrackInfo_String(tr, parm, name.data(), true); + present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass + changed = true; + } + + const std::map ordinals = managedLaneOrdinals(tr); + const std::map itemsByGuid = itemHandlesByGuid(tr); + for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { + auto ord = ordinals.find(a->laneKey); + if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign + auto handle = itemsByGuid.find(a->itemGuid); + if (handle == itemsByGuid.end()) continue; // stale item GUID — prune + if (assignItemToLane(tr, handle->second, ord->second)) changed = true; + } + } + return changed; +} + +} // namespace + +LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, + const std::vector& lanes) { + LaneApplyResult out; + if (lanes.empty()) return out; + + std::map> byTrack; + for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); + + for (const auto& [guid, ops] : byTrack) { + MediaTrack* tr = resolve(handleByGuid, guid); + if (!tr) continue; // stale GUID — prune + + // Every track reaching here already owns a managed lane (planToggle only emits + // ops for managed lanes), so re-asserting fixed-lane mode is always a + // tool-driven (re)split — never a user's untouched manual-fixed-lane track — + // and gets the same transparent display. + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", + static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); + out.freeMode = true; + out.wrote = true; + } + + const std::map ordinals = managedLaneOrdinals(tr); + for (const LanePlayOp* op : ops) { + auto it = ordinals.find(op->laneKey); + if (it == ordinals.end()) continue; // key not live on this track — prune + if (applyLanePlays(tr, it->second, op->lanePlays)) out.wrote = true; + } + } + return out; +} + +bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { + TrackHandles handleByGuid; + std::vector entries = readFolderEntries(proj, handleByGuid); + // The tree is needed to detect a content-bearing folder derived-visible in + // >1 mode, exactly as applyMode builds it. + const FolderTree tree = buildFolderTree(entries); + + const std::vector tracks = readLaneTracks(model, handleByGuid); + const LaneMintPlan plan = planLaneMinting(model, tree, tracks); + if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick + + const TrackByGuid trackByGuid = indexHandles(handleByGuid); + + Undo_BeginBlock2(proj); + const bool changed = applyMintPlan(model, plan, trackByGuid); + + if (!changed) { + // Plan was non-empty but every write was already satisfied — discard the empty + // undo point rather than flooding history every detect tick. + Undo_EndBlock2(proj, "", 0); + + // The arrange still needs a redraw: this no-op path is reached when a + // freshly-inserted item already landed on the active mode's playing lane + // (REAPER places new items on the playing lane), so assignItemToLane wrote + // nothing even though the item needs to appear there now. UpdateArrange() + // alone (no I_FREEMODE change happened, so UpdateTimeline isn't owed) is a + // repaint, not a mutation — stays outside the undo block. + UpdateArrange(); + return false; + } + + // Reapply the active mode's lane visibility so freshly-minted lanes take their + // play/show state immediately. applyMode is deliberately NOT reused here — it + // would re-park/restore whole tracks and recompute parent visibility, which a + // lane-only mint must not touch. + const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId()); + applyLaneOps(trackByGuid, togglePlan.lanes); + + UpdateTimeline(); // a split happened this call — refresh is owed + UpdateArrange(); + + Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kLaneUndoMask); + return true; +} + +void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { + TrackHandles handleByGuid; + readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) + + // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ + // I_FIXEDLANE written) plus an ownership-index write, recovering managed + // classification from the durable name. An unprefixed lane is left alone. + for (const auto& [guid, tr] : handleByGuid) { + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed + + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + const std::string name = laneName(tr, lane); + std::optional key = managedLaneKey(name); + if (!key) continue; // manual/unnamed lane — leave off the index + std::optional mode = modeIdFromLaneName(name); + if (!mode) continue; // prefix-only/illegal name — skip defensively + + // A mode id encoded in the name may no longer be registered (e.g. removed + // since the project was saved). Recording it managed would make the toggle + // planner drive a lane keyed to a mode that can never be active — + // permanently silenced, orphaning its items. So skip: the lane stays off + // the index (manual-by-default) but keeps its name, so a later + // re-registration of the mode heals cleanly. + if (!model.modes().contains(*mode)) continue; + model.lanes().setManaged(guid, *key, *mode); + } + } +} + +} // namespace reasampler diff --git a/src/shell/view/view_lanes.h b/src/shell/view/view_lanes.h new file mode 100644 index 0000000..6fd1311 --- /dev/null +++ b/src/shell/view/view_lanes.h @@ -0,0 +1,43 @@ +#pragma once +// Managed fixed lanes, REAPER side: minting one lane per mode on a track whose own +// content spans modes, driving the active mode's lane audible and every other +// managed lane silent+hidden, and recovering ownership from the durable P_LANENAME +// on project open. Manual lanes are never minted, driven, renamed or reassigned. + +#include + +#include "core/view/view_mode_model.h" +#include "shell/view/view_tracks.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +// `freeMode` alone decides whether UpdateTimeline is owed (SDK: required after an +// I_FREEMODE change); `wrote` is any project write at all, which is what the apply's +// undo-point decision reads. +struct LaneApplyResult { + bool freeMode = false; + bool wrote = false; +}; + +// Reconciles each op's durable laneKey to the track's current ordinal (a +// stale/renamed/deleted key is pruned, never mis-driven) and drives C_LANEPLAYS. +LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, + const std::vector& lanes); + +// Splits any track visible in more than one mode while carrying its own media into +// fixed lanes (one managed lane per involved mode), assigns items, and records +// ownership in `model`. Wraps the mutation in one Undo block when non-empty. Returns +// true if any lane was minted (repaint hint). `proj` == nullptr means the current +// project. +bool mintManagedLanes(ViewModeModel& model, ReaProject* proj); + +// Recovers managed-lane ownership from durable P_LANENAME on project open — +// self-healing, without minting/reassigning anything and without touching +// membership. A lane without the managed prefix is left untouched (manual). +// `proj` == nullptr means the current project. +void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); + +} // namespace reasampler diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp new file mode 100644 index 0000000..fb60ebf --- /dev/null +++ b/src/shell/view/view_park.cpp @@ -0,0 +1,114 @@ +// See view_park.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). + +#include "shell/view/view_park.h" + +#include +#include + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_TrackFX_GetCount +#define REAPERAPI_WANT_TrackFX_GetFXGUID +#define REAPERAPI_WANT_TrackFX_GetOffline +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// 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); +} + +// Captures prior driven-flag state, and the chain's per-FX offline state, before +// parking. Never reads B_MUTE/I_SOLO; ints preserve whatever REAPER reported +// (TrackSnapshot's defensive contract). The plain 0..TrackFX_GetCount-1 enumeration +// — never the 0x1000000/0x2000000 input-FX or container forms — is the same one the +// reclaimer resolves against. `fxKeying` stays Identity: a live read always knows +// the chain. +TrackSnapshot snapshotTrack(MediaTrack* tr) { + TrackSnapshot snap; + auto read = [tr](Flag f) { + return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); + }; + snap.showInTcp = read(Flag::ShowInTcp); + snap.showInMixer = read(Flag::ShowInMixer); + snap.mainSend = read(Flag::MainSend); + snap.fxEnable = read(Flag::FxEnable); + + const int fxCount = TrackFX_GetCount(tr); + snap.fxOffline.reserve(static_cast(fxCount)); + for (int fx = 0; fx < fxCount; ++fx) + snap.fxOffline.push_back( + FxOfflineState{fxGuidString(tr, fx), TrackFX_GetOffline(tr, fx) ? 1 : 0}); + return snap; +} + +bool applyFlags(MediaTrack* tr, const std::vector& flags) { + bool wrote = false; + for (const TrackFlagOp& op : flags) + if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) + wrote = true; + return wrote; +} + +} // namespace + +bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { + if (GetMediaTrackInfo_Value(tr, parm) == value) return false; + SetMediaTrackInfo_Value(tr, parm, value); + return true; +} + +bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags) { + bool wrote = false; + + if (!model.holdsSnapshotFlags(guid)) { + TrackSnapshot snap = snapshotTrack(tr); + // A restore hands the flags back immediately but leaves the chain offline + // until the reclaimer gets to it, so a re-park lands here with the FX half + // still held over a still-parked chain. Keep it: the live read above would + // record the park's own offline state as the user's, permanently. + const TrackSnapshot* prior = model.snapshot(guid); + if (prior && prior->fxHeld) { + snap.fxOffline = prior->fxOffline; + snap.fxKeying = prior->fxKeying; + } + model.storeSnapshot(guid, snap); + wrote = true; + } + + if (applyFlags(tr, flags)) wrote = true; + return wrote; +} + +bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags) { + applyFlags(tr, flags); + model.releaseSnapshotFlags(guid); + return true; +} + +} // namespace reasampler diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h new file mode 100644 index 0000000..c4ef8c8 --- /dev/null +++ b/src/shell/view/view_park.h @@ -0,0 +1,59 @@ +#pragma once +// Design View's park surface: one track's flag park or restore, plus the park +// record that makes the restore non-destructive. FLAG DOMAIN ONLY — a park record's +// FX half is captured here (a read) but never written here; the reclaimer owns +// every TrackFX_SetOffline, off the switch's synchronous path. + +#include +#include + +#include "core/view/view_mode_model.h" // Flag / TrackFlagOp — what a park writes + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class MediaTrack; + +namespace reasampler { + +// The REAPER track parameter each driven flag addresses. One home for the mapping — +// the park's pre-park read, the restore's write and the reconcile's repair must +// never disagree about which parameter a Flag names. +inline const char* trackFlagParm(Flag f) { + switch (f) { + case Flag::ShowInTcp: return "B_SHOWINTCP"; + case Flag::ShowInMixer: return "B_SHOWINMIXER"; + case Flag::MainSend: return "B_MAINSEND"; + case Flag::FxEnable: return "I_FXEN"; + } + return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet +} + +// A track parameter written only when it is not already there. A reapply re-plans +// every flag it already applied, so most of what it writes is a value the track +// already holds. The read is ASSUMED cheaper than the write it elides (unmeasured); +// what it certainly does is keep those no-op writes out of the undo record's mask. +// Returns whether it wrote. +bool writeIfChanged(MediaTrack* tr, const char* parm, double value); + +// Parks one inactive leaf: capture the pre-park truth when the model does not +// already hold it, then drive the flags. Returns whether anything moved — a project +// write or the record. +// +// The record is the tool's own account of an act it performed, and it is the ONLY +// account — nothing infers a park from a live value any more. That is what retired +// the old refusal: a track that merely LOOKS parked while no record names it is a +// track the tool did not park, so its values are the user's own and capturing them +// is right. So the capture is owed exactly when the flag half is not held. +// +// A still-held FX half is the one thing not re-read: it outlives the flag half the +// restore spends, the chain stays offline until the reclaimer onlines it, and +// re-reading it there would file the park's own state as the user's. +bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags); + +// Restores one returning leaf from its record and spends the record's flag half. +// Always true: the record is consumed here, so this apply changed state and is not +// repeatable, which is exactly what its one reader (the undo-point decision) asks. +bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags); + +} // namespace reasampler diff --git a/src/shell/view/view_reconcile.cpp b/src/shell/view/view_reconcile.cpp new file mode 100644 index 0000000..3accb85 --- /dev/null +++ b/src/shell/view/view_reconcile.cpp @@ -0,0 +1,80 @@ +// See view_reconcile.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The decision is pure (header, test_view_reconcile.cpp); this file owns the memo's +// lifetime and the writes. + +#include "shell/view/view_reconcile.h" + +#include + +#include "shell/view/view_park.h" // trackFlagParm / writeIfChanged +#include "shell/view/view_tracks.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_TrackList_AdjustWindows +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_ValidatePtr2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// What the last apply left driven, and in which project. The owner is COMPARE-ONLY +// and never dereferenced (the same discipline the panel's own project memos take): a +// closed project's pointer must never be followed, only found unequal. A project we +// hold no memo for gets no repair — the tool has no account of driving anything +// there, so it has no opinion. +ReaProject* g_appliedOwner = nullptr; +view::AppliedState g_applied; + +} // namespace + +void noteAppliedState(ReaProject* proj, const ViewModeModel& model) { + g_appliedOwner = proj ? proj : EnumProjects(-1, nullptr, 0); + g_applied = view::appliedState(model); +} + +void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { + ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); + if (!ValidatePtr2(nullptr, project, "ReaProject*")) return; + + const view::AppliedState reloaded = view::appliedState(model); + const std::vector ops = + project == g_appliedOwner ? planReconcile(g_applied, reloaded) + : std::vector{}; + + // Adopted whether or not anything is written: from here the memo describes the + // project as it now stands, so a second reload cannot replay a repair that has + // already happened over values the user has since changed. + g_appliedOwner = project; + g_applied = reloaded; + if (ops.empty()) return; + + TrackHandles handleByGuid; + readFolderEntries(project, handleByGuid); + const TrackByGuid trackByGuid = indexHandles(handleByGuid); + + bool wrote = false; + Undo_BeginBlock2(project); + { + UiRefreshHold uiHold; + for (const TrackFlagOp& op : ops) { + MediaTrack* tr = resolve(trackByGuid, op.guid); + if (!tr) continue; // stale/deleted GUID — prune + if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) + wrote = true; + } + } + Undo_EndBlock2(project, "", 0); // discard form — see the header + + if (wrote) { + TrackList_AdjustWindows(false); // tracks reappearing owe the full relayout + UpdateArrange(); + } +} + +} // namespace reasampler diff --git a/src/shell/view/view_reconcile.h b/src/shell/view/view_reconcile.h new file mode 100644 index 0000000..62fec33 --- /dev/null +++ b/src/shell/view/view_reconcile.h @@ -0,0 +1,58 @@ +#pragma once +// The repair pass for a project state REAPER reloaded under us. An undo/redo rolls +// the view_state ext block back but NOT B_SHOWINTCP/B_SHOWINMIXER — UNDO_STATE_TRACKCFG +// does not carry them (DAW-confirmed) — so a park the model no longer records can be +// left driven on the track with nobody holding its captured values but us. + +#include +#include +#include + +#include "core/view/applied_state.h" +#include "core/view/view_mode_model.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +// The flags to hand back, and to whom. +// +// SCOPE — the rule this pass exists to keep: a track is driven only where a park +// record names it, and the value written is one that record captured. `applied` is +// the tool's account of an act it PERFORMED, so a track absent from it (and from the +// reloaded model) is one the tool never drove, holds no captured value for, and has +// no opinion about. Keying the repair on "the model calls this track active" instead +// is the rejected shape: it asserts B_MAINSEND=1 on a track the user disabled by hand. +// +// A track the RELOADED model still parks is left out too — it still has an owner, and +// the reapply that follows this pass drives it from the live model. +inline std::vector planReconcile(const view::AppliedState& applied, + const view::AppliedState& reloaded) { + std::vector ops; + if (applied == reloaded) return ops; // no drift in what we applied — nothing to repair + + std::set stillRecorded; + for (const view::AppliedTrack& t : reloaded.parked) stillRecorded.insert(t.guid); + + for (const view::AppliedTrack& t : applied.parked) { + if (stillRecorded.count(t.guid)) continue; + ops.push_back(TrackFlagOp{t.guid, Flag::ShowInTcp, t.showInTcp}); + ops.push_back(TrackFlagOp{t.guid, Flag::ShowInMixer, t.showInMixer}); + ops.push_back(TrackFlagOp{t.guid, Flag::MainSend, t.mainSend}); + ops.push_back(TrackFlagOp{t.guid, Flag::FxEnable, t.fxEnable}); + } + return ops; +} + +// Records what `model` left driven in `proj` (nullptr = current project). Called at +// the end of every apply: the fingerprint is the ONLY account of what the tool drove +// once an undo has rolled the model back underneath it. +void noteAppliedState(ReaProject* proj, const ViewModeModel& model); + +// Runs planReconcile over the reloaded `model` and writes its ops. The undo block it +// opens is deliberately DISCARDED — this runs in the wake of a Ctrl-Z, and a repair +// that minted a point would become the thing the user's next Ctrl-Z undoes. +void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj); + +} // namespace reasampler diff --git a/src/shell/view/view_solo.h b/src/shell/view/view_solo.h index 7e4d5bb..cdb4241 100644 --- a/src/shell/view/view_solo.h +++ b/src/shell/view/view_solo.h @@ -6,21 +6,13 @@ #include #include #include -#include #include #include "core/view/solo_cache.h" - -// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. -class MediaTrack; +#include "shell/view/view_tracks.h" // TrackHandles — the enumeration these writers pass over namespace reasampler { -// The (GUID, handle) enumeration applyMode builds from GetTrack. GetTrack's index -// space excludes the master track, so no solo read or write reachable through this -// type can ever land on master. -using TrackHandles = std::vector>; - std::vector readTrackSolos(const TrackHandles& handles); // Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID diff --git a/src/shell/view/view_tracks.cpp b/src/shell/view/view_tracks.cpp new file mode 100644 index 0000000..89f221e --- /dev/null +++ b/src/shell/view/view_tracks.cpp @@ -0,0 +1,47 @@ +// See view_tracks.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). + +#include "shell/view/view_tracks.h" + +#include + +#include "shell/capture/track_guid.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_PreventUIRefresh +#include "reaper_plugin_functions.h" + +namespace reasampler { + +std::vector readFolderEntries(ReaProject* proj, + TrackHandles& handleByGuid) { + std::vector entries; + const int count = CountTracks(proj); + entries.reserve(static_cast(count)); + handleByGuid.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) { + MediaTrack* tr = GetTrack(proj, i); + if (!tr) continue; + std::string guid = guidString(tr); + if (guid.empty()) continue; + const int depth = static_cast(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); + entries.push_back(view::TrackFolderEntry{guid, depth}); + handleByGuid.emplace_back(std::move(guid), tr); + } + return entries; +} + +TrackByGuid indexHandles(const TrackHandles& handleByGuid) { + TrackByGuid byGuid; + byGuid.reserve(handleByGuid.size()); + for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second); + return byGuid; +} + +UiRefreshHold::UiRefreshHold() { PreventUIRefresh(1); } +UiRefreshHold::~UiRefreshHold() { PreventUIRefresh(-1); } + +} // namespace reasampler diff --git a/src/shell/view/view_tracks.h b/src/shell/view/view_tracks.h new file mode 100644 index 0000000..f7109f2 --- /dev/null +++ b/src/shell/view/view_tracks.h @@ -0,0 +1,51 @@ +#pragma once +// The scaffolding every Design View write pass sets up: the live GUID-keyed track +// enumeration it plans against, and the UI-refresh hold its writes land under. +// The master track is outside GetTrack's index space, so nothing reached through +// these types can land on it. + +#include +#include +#include +#include + +#include "core/view/view_tree.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class MediaTrack; +class ReaProject; + +namespace reasampler { + +// (GUID, handle) in arrange order — the shape view_solo's one-pass writers take. +using TrackHandles = std::vector>; + +// The same handles indexed for lookup. Every park, restore, parent and lane op +// resolves a GUID; a linear scan made that O(T²) on the one path the user waits on. +using TrackByGuid = std::unordered_map; + +// Enumerates `proj` (nullptr = current project) into `handleByGuid` and returns each +// track's I_FOLDERDEPTH entry for buildFolderTree. A track REAPER reports no GUID +// for is skipped by both. +std::vector readFolderEntries(ReaProject* proj, + TrackHandles& handleByGuid); + +TrackByGuid indexHandles(const TrackHandles& handleByGuid); + +inline MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) { + auto it = byGuid.find(guid); + return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped +} + +// Holds REAPER's UI refresh off for a write phase; the deliberate rebuild +// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because an +// unbalanced pair leaves the user's UI frozen with no way back — the SDK's own +// warning at reaper_plugin_functions.h:5581. +struct UiRefreshHold { + UiRefreshHold(); + ~UiRefreshHold(); + UiRefreshHold(const UiRefreshHold&) = delete; + UiRefreshHold& operator=(const UiRefreshHold&) = delete; +}; + +} // namespace reasampler diff --git a/tests/test_view_fx_park.cpp b/tests/test_view_fx_park.cpp deleted file mode 100644 index 00a168b..0000000 --- a/tests/test_view_fx_park.cpp +++ /dev/null @@ -1,197 +0,0 @@ -// Standalone tests for the Design View park surface's pure decisions — no -// REAPER, no test framework. -// -// The properties under test: a pre-park snapshot is never taken from a chain a -// park has already touched, the refusal that follows names its tracks and is -// reported once per changed set, and one mode apply leaves at most ONE undo -// point — none at all when a reapply found everything already where it wanted it. - -#include "../src/shell/view/view_fx_park.h" -#include "../src/shell/view/view.h" // applyMintsUndoPoint — the apply's undo-point fold - -#include "core/view/view_mode_model.h" // makeParkPlan — the park flags' only producer - -#include -#include -#include - -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) - -// -- may this chain be snapshotted? ------------------------------------------- -// -// Rationale: the snapshot-source invariant (view_fx_park.h, this directory's -// CLAUDE.md). - -// The live values the fold reads, keyed by Flag — so an assertion names the flag -// it varies rather than a position in makeParkPlan's op order. -struct FlagValues { - int showInTcp = 0, showInMixer = 0, mainSend = 0, fxEnable = 0; - - int operator()(Flag f) const { - switch (f) { - case Flag::ShowInTcp: return showInTcp; - case Flag::ShowInMixer: return showInMixer; - case Flag::MainSend: return mainSend; - case Flag::FxEnable: return fxEnable; - } - return -1; - } -}; - -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}"); - - CHECK(park.flags.size() == 4); - CHECK(parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 0})); -} - -static void testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain() { - 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. - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{1, 0, 0, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 1, 0, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 1, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 1})); -} - -static void testAnEmptyPlanProvesNothing() { - CHECK(!parkFlagsAlreadyApplied(std::vector{}, FlagValues{0, 0, 0, 0})); -} - -static void testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse() { - // Disjunction pinned; rationale at chainReadsParked (view_fx_park.h). - CHECK(!chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/false)); - CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/false)); - CHECK(chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/true)); - CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/true)); -} - -static void testACleanChainIsSnapshottedThenParked() { - CHECK(decidePark(/*haveSnapshot=*/false, /*chainReadsParked=*/false) == - ParkAction::SnapshotThenPark); -} - -static void testAHeldSnapshotIsNeverOverwrittenWhateverTheChainReads() { - // The held snapshot IS the pre-park truth, so the chain is not consulted — - // which is what lets the shell skip the live flag reads on this path. - CHECK(decidePark(/*haveSnapshot=*/true, /*chainReadsParked=*/false) == ParkAction::ParkOnly); - CHECK(decidePark(/*haveSnapshot=*/true, /*chainReadsParked=*/true) == ParkAction::ParkOnly); -} - -static void testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted() { - // The defect this whole section exists for: the truth is gone, so the only - // non-destructive act is to leave the track alone. Snapshotting here commits - // park state as the user's state and no later restore can undo it. - CHECK(decidePark(/*haveSnapshot=*/false, /*chainReadsParked=*/true) == ParkAction::Refuse); -} - -static void testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere() { - CHECK(describeRefusedParks({}).empty()); - - const std::string one = describeRefusedParks({"Bass"}); - CHECK(one.find("1 track ") != std::string::npos); - CHECK(one.find("Bass") != std::string::npos); - - const std::string two = describeRefusedParks({"Bass", "Drum bus"}); - CHECK(two.find("2 tracks ") != std::string::npos); - CHECK(two.find("Bass") != std::string::npos); - CHECK(two.find("Drum bus") != std::string::npos); -} - -static void testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause() { - const std::string msg = describeRefusedParks({"Bass"}); - - // I_FXEN is the chain bypass: a user who restores only the four flags leaves - // every individually offlined FX offline and walks straight back into a - // refusal, so the recovery has to spell the per-FX step out. - CHECK(msg.find("every FX in its chain online") != std::string::npos); - // And it must not name a cause: the pair has several routes, and on a track - // the user themselves keeps hidden/bypassed there was no lost state at all. - CHECK(msg.find("undo") == std::string::npos); - CHECK(msg.find("lost") == std::string::npos); -} - -// -- the refusal memo's print-suppression gate -------------------------------- -// -// reportRefusedParks' own memo (owner + last-reported names, both REAPER-side -// statics) can't be driven from here, but the pure gate behind it can: prints -// (i.e. updates the memo) on any change, stays silent on an exact repeat, and -// treats a fresh empty set as a change too, so a later real refusal is never -// mistaken for a repeat of one that already healed. - -static void testUnchangedOwnerAndSetStaysSilent() { - CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {"Bass"})); -} - -static void testADifferentNamedSetReports() { - CHECK(shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {"Bass", "Drum bus"})); -} - -static void testADifferentOwnerReportsEvenWithTheSameNames() { - // Two alternating project tabs must not suppress each other's first refusal. - CHECK(shouldReport(/*sameOwnerAsLast=*/false, {"Bass"}, {"Bass"})); -} - -static void testAFreshEmptySetReportsAndResetsTheMemo() { - CHECK(shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {})); - // Already empty, same owner: nothing changed, stays silent. - CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {}, {})); -} - -// -- the apply's one undo point ---------------------------------------------- -// -// These pin applyMintsUndoPoint's fold only. The `wroteAnything` verdict it -// consumes — writeIfChanged / parkTrack / restoreTrack, all running against live -// REAPER calls — cannot be pinned here; docs/VERIFICATION.md §"Mode switching" -// is the sole cover for that plumbing being wired correctly. - -static void testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite() { - // An explicitly fired action stays undoable even when the plan found nothing - // to write — the discard form is for reapplies only. - CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/false)); - CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/true)); -} - -static void testAReapplyThatWroteSomethingLeavesAPoint() { - // A tag/untag reapply that actually parked a track: real project state moved. - CHECK(applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/true)); -} - -static void testAReapplyThatWroteNothingLeavesNoPointAtAll() { - // The project-load reapply over a project saved fully parked: every flag and - // every FX already sit where the plan wants them. Opening a project must not - // cost the user an undo step. - CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false)); -} - -int main() { - testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked(); - testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain(); - testAnEmptyPlanProvesNothing(); - testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse(); - testACleanChainIsSnapshottedThenParked(); - testAHeldSnapshotIsNeverOverwrittenWhateverTheChainReads(); - testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted(); - testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere(); - testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause(); - - testUnchangedOwnerAndSetStaysSilent(); - testADifferentNamedSetReports(); - testADifferentOwnerReportsEvenWithTheSameNames(); - testAFreshEmptySetReportsAndResetsTheMemo(); - - testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite(); - testAReapplyThatWroteSomethingLeavesAPoint(); - testAReapplyThatWroteNothingLeavesNoPointAtAll(); - - if (g_fail == 0) std::printf("All tests passed.\n"); - return g_fail ? 1 : 0; -} diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 41494a0..78dfd5e 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -816,11 +816,11 @@ using LiveFlags = std::map; // Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place, // exactly mirroring view.cpp's park then restore then setActiveMode ordering. // -// Guarded, the park mirrors decidePark's three ways: a held snapshot parks -// without recapturing, a chain that already reads parked with no snapshot is -// REFUSED (left exactly as found), and only a clean chain is snapshotted. The one -// `live` flag stands in for the whole park-flag-plus-FX fold the shell reads — -// the model half of the defect is identical either way. +// Guarded, the park recaptures only when the model holds no record — the property +// the shell's parkTrack keeps. Its second guarded branch (a parked-looking chain +// with no record is left alone) outlived the shell rule it mirrored and is kept +// here only as a second unguarded-vs-guarded contrast; the model half of the defect +// is identical either way. void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree, const std::string& target, bool guard) { TogglePlan plan = vm.planToggle(tree, target); diff --git a/tests/test_view_park.cpp b/tests/test_view_park.cpp new file mode 100644 index 0000000..8f875c8 --- /dev/null +++ b/tests/test_view_park.cpp @@ -0,0 +1,105 @@ +// Standalone tests for the Design View park surface's pure decisions — no REAPER, no +// test framework. +// +// The properties under test: the four flags a park drives address exactly the four +// REAPER parameters the tool owns (never mute, never solo), and one mode apply leaves +// at most ONE undo point — none at all when a reapply found everything already where +// it wanted it. + +#include "../src/shell/view/view_park.h" +#include "../src/shell/view/view.h" // applyMintsUndoPoint — the apply's undo-point fold + +#include "core/view/view_mode_model.h" // makeParkPlan — the park flags' only producer + +#include +#include +#include +#include + +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) + +// -- what a park is allowed to address ---------------------------------------- +// +// trackFlagParm is the ONE mapping the park's pre-park read, the restore's write and +// the reconcile's repair all share; a mistyped parameter there is silent at compile +// time and drives the wrong track property forever. + +static void testEachDrivenFlagAddressesItsOwnReaperParameter() { + const std::set parms{trackFlagParm(Flag::ShowInTcp), + trackFlagParm(Flag::ShowInMixer), + trackFlagParm(Flag::MainSend), + trackFlagParm(Flag::FxEnable)}; + CHECK(parms.size() == 4); // no two flags collapse onto one parameter + CHECK(parms.count("B_SHOWINTCP") == 1); + CHECK(parms.count("B_SHOWINMIXER") == 1); + CHECK(parms.count("B_MAINSEND") == 1); + CHECK(parms.count("I_FXEN") == 1); +} + +static void testNoDrivenFlagCanEverAddressMuteOrSolo() { + // The never-touch-mute invariant, held at the one place a Flag becomes a REAPER + // parameter name. + for (const Flag f : {Flag::ShowInTcp, Flag::ShowInMixer, Flag::MainSend, Flag::FxEnable}) { + const std::string parm = trackFlagParm(f); + CHECK(parm != "B_MUTE"); + CHECK(parm != "I_SOLO"); + } +} + +static void testAParkPlanDrivesEveryDrivenFlagToZero() { + // 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}"); + + CHECK(park.flags.size() == 4); + std::set parms; + for (const TrackFlagOp& op : park.flags) { + CHECK(op.guid == "{A}"); + CHECK(op.value == 0); + parms.insert(trackFlagParm(op.flag)); + } + CHECK(parms.size() == 4); +} + +// -- the apply's one undo point ---------------------------------------------- +// +// These pin applyMintsUndoPoint's fold only. The `wroteAnything` verdict it consumes +// — writeIfChanged / parkTrack / restoreTrack, all running against live REAPER calls +// — cannot be pinned here; docs/VERIFICATION.md §"Mode switching" is the sole cover +// for that plumbing being wired correctly. + +static void testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite() { + // An explicitly fired action stays undoable even when the plan found nothing to + // write — the discard form is for reapplies only. + CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/false)); + CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/true)); +} + +static void testAReapplyThatWroteSomethingLeavesAPoint() { + // A tag/untag reapply that actually parked a track: real project state moved. + CHECK(applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/true)); +} + +static void testAReapplyThatWroteNothingLeavesNoPointAtAll() { + // The project-load reapply over a project saved fully parked: every flag already + // sits where the plan wants it. Opening a project must not cost the user an undo + // step. + CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false)); +} + +int main() { + testEachDrivenFlagAddressesItsOwnReaperParameter(); + testNoDrivenFlagCanEverAddressMuteOrSolo(); + testAParkPlanDrivesEveryDrivenFlagToZero(); + + testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite(); + testAReapplyThatWroteSomethingLeavesAPoint(); + testAReapplyThatWroteNothingLeavesNoPointAtAll(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_view_reconcile.cpp b/tests/test_view_reconcile.cpp new file mode 100644 index 0000000..0d8db11 --- /dev/null +++ b/tests/test_view_reconcile.cpp @@ -0,0 +1,158 @@ +// Standalone tests for the Design View repair pass's pure decision — no REAPER, no +// test framework. +// +// The two properties the pass exists for: it drives a flag ONLY where a park record +// names the track (so a track the tool never parked can never have its main send +// re-enabled under it), and it fires on applied-output drift rather than on "an undo +// happened". Both AppliedStates are produced by the real fingerprint +// (view::appliedState) over real models, so a change to what the fingerprint carries +// shows up here rather than being papered over by hand-built fixtures. + +#include "../src/shell/view/view_reconcile.h" + +#include +#include +#include + +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) + +namespace { + +TrackSnapshot captured(int showInTcp, int showInMixer, int mainSend, int fxEnable) { + TrackSnapshot snap; + snap.showInTcp = showInTcp; + snap.showInMixer = showInMixer; + snap.mainSend = mainSend; + snap.fxEnable = fxEnable; + return snap; +} + +bool hasOp(const std::vector& ops, const std::string& guid, Flag flag, int value) { + for (const TrackFlagOp& op : ops) + if (op.guid == guid && op.flag == flag && op.value == value) return true; + return false; +} + +bool namesTrack(const std::vector& ops, const std::string& guid) { + for (const TrackFlagOp& op : ops) + if (op.guid == guid) return true; + return false; +} + +} // namespace + +// -- the trigger: applied-output drift, not "an undo happened" ----------------- + +static void testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing() { + // The rejected trigger is "did an undo occur" — an undo that touched nothing we + // drive still answers yes, and repairing on it rewrites state the user owns. + ViewModeModel model; + model.setActiveMode(kDesignModeId); + model.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const view::AppliedState state = view::appliedState(model); + CHECK(planReconcile(state, state).empty()); +} + +static void testADriftedActiveModeAloneIsNotAReasonToDriveATrack() { + // Drift is the gate, not the instruction: the record still has an owner, so the + // reapply that follows this pass is what drives it. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + ViewModeModel reloaded; // active mode Arrange — the fingerprint differs + reloaded.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + CHECK(view::appliedState(applied) != view::appliedState(reloaded)); + CHECK(planReconcile(view::appliedState(applied), view::appliedState(reloaded)).empty()); +} + +// -- the scope rule: a record names the track, or nothing is written ----------- + +static void testARecordTheReloadedModelLostIsHandedBackItsCapturedValues() { + // The undo case: REAPER rolled view_state back to before the switch, so the model + // no longer records the park — but the flags are still driven on the track, and + // this memo is the only surviving account of what they were. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const ViewModeModel reloaded; // Arrange, no records at all + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(ops.size() == 4); + CHECK(hasOp(ops, "{A}", Flag::ShowInTcp, 1)); + CHECK(hasOp(ops, "{A}", Flag::ShowInMixer, 1)); + CHECK(hasOp(ops, "{A}", Flag::MainSend, 1)); + CHECK(hasOp(ops, "{A}", Flag::FxEnable, 1)); +} + +static void testARepairedFlagReturnsToItsCapturedValueNeverToOn() { + // A track the user had OUT of the master mix and FX-bypassed before we parked it: + // the repair must hand back exactly that, not a hardcoded "on". + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 0, 0, 0)); + + const ViewModeModel reloaded; + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(hasOp(ops, "{A}", Flag::MainSend, 0)); + CHECK(hasOp(ops, "{A}", Flag::ShowInMixer, 0)); + CHECK(hasOp(ops, "{A}", Flag::FxEnable, 0)); + CHECK(!hasOp(ops, "{A}", Flag::MainSend, 1)); +} + +static void testATrackNeitherStateRecordsIsNeverDrivenAtAll() { + // The regression this rule is built against: a track the model merely calls + // active is NOT a track the tool parked. It holds no captured value, so asserting + // B_MAINSEND=1 on it would silently re-enable a send the user disabled by hand. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + ViewModeModel reloaded; + reloaded.membership().tag("{B}", kArrangeModeId); // active, never parked, no record + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(namesTrack(ops, "{A}")); + CHECK(!namesTrack(ops, "{B}")); +} + +static void testASpentFlagHalfIsNotARecordToRepairFrom() { + // A restore already handed the flags back and zeroed them on the record; the FX + // half outlives that. Repairing from the spent half would drive those zeros — + // i.e. re-park the track — so the fingerprint must not carry it. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + applied.releaseSnapshotFlags("{A}"); + + const ViewModeModel reloaded; + + CHECK(planReconcile(view::appliedState(applied), view::appliedState(reloaded)).empty()); +} + +int main() { + testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing(); + testADriftedActiveModeAloneIsNotAReasonToDriveATrack(); + + testARecordTheReloadedModelLostIsHandedBackItsCapturedValues(); + testARepairedFlagReturnsToItsCapturedValueNeverToOn(); + testATrackNeitherStateRecordsIsNeverDrivenAtAll(); + testASpentFlagHalfIsNotARecordToRepairFrom(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} From 024c6198849ee6d9ba73693ac19410ea1a6f8a79 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 17:50:49 -0400 Subject: [PATCH 2/3] Fix Wave-2 review findings: FX union on re-park, per-project reconcile memo, shared undo mask, decayed test oracle --- src/shell/view/view.cpp | 14 +--- src/shell/view/view_lanes.cpp | 8 +-- src/shell/view/view_park.cpp | 21 +++++- src/shell/view/view_park.h | 37 ++++++++++ src/shell/view/view_reconcile.cpp | 30 ++++---- src/shell/view/view_reconcile.h | 35 +++++++++ tests/test_view_mode_model.cpp | 114 ++++++++++++------------------ tests/test_view_park.cpp | 44 ++++++++++++ tests/test_view_reconcile.cpp | 46 ++++++++++++ 9 files changed, 244 insertions(+), 105 deletions(-) diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 4bc63ce..651476d 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -41,18 +41,6 @@ namespace { // deliberately excluded, nothing is moving there. constexpr int kTransportMoving = 1 | 4; -// The domains one apply actually writes: track config/routing (visibility, -// B_MAINSEND, I_FXEN, I_SOLO), item lane assignment, and the ext-state block -// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, and no -// UNDO_STATE_FX at all — the apply moves no FX state, so making every undo record -// carry the project's whole FX chunk would cost with the plugin count for nothing. -// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads "track/master -// vol/pan/routing" (reaper_plugin.h:1541) and names neither the fixed-lane properties -// (assumed to ride UNDO_STATE_ITEMS, :1543) nor B_SHOWINTCP/B_SHOWINMIXER — and for -// the visibility pair that inference is now DISPROVEN in the DAW: REAPER's undo does -// not restore them, which is what view_reconcile exists to repair. -constexpr int kApplyUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; - // applyMode is reachable from the load tick, the actions and render_in_place. Nothing // it calls is known to pump the message loop now that the FX writes are gone, but a // re-entrant apply would plan against the same model the outer loop is iterating and @@ -199,7 +187,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject if (laneModeChanged) UpdateTimeline(); const bool mint = applyMintsUndoPoint(realSwitch, wrote); - Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kApplyUndoMask : 0); + Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0); // The tool's own account of what it just left driven — the only one that survives // an undo rolling the model back underneath it. diff --git a/src/shell/view/view_lanes.cpp b/src/shell/view/view_lanes.cpp index 5dba34d..087589c 100644 --- a/src/shell/view/view_lanes.cpp +++ b/src/shell/view/view_lanes.cpp @@ -45,10 +45,6 @@ namespace { // I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. constexpr int kFreeModeFixedLanes = 2; -// The same domains the apply's own mask names (view.cpp's kApplyUndoMask, which owns -// the rationale): lane minting writes track lane config and item lane assignment. -constexpr int kLaneUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; - // C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane track // showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist but displays // as non-fixed-lane). @@ -280,6 +276,8 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { const TrackByGuid trackByGuid = indexHandles(handleByGuid); + // `proj` deliberately NOT pinned the way applyMode pins it: nothing between this + // begin and its end pumps the message loop, so no tab switch can land between them. Undo_BeginBlock2(proj); const bool changed = applyMintPlan(model, plan, trackByGuid); @@ -308,7 +306,7 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { UpdateTimeline(); // a split happened this call — refresh is owed UpdateArrange(); - Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kLaneUndoMask); + Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kViewUndoMask); return true; } diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp index fb60ebf..4b0c95d 100644 --- a/src/shell/view/view_park.cpp +++ b/src/shell/view/view_park.cpp @@ -75,6 +75,8 @@ bool applyFlags(MediaTrack* tr, const std::vector& flags) { } // namespace +const int kViewUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; + bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { if (GetMediaTrackInfo_Value(tr, parm) == value) return false; SetMediaTrackInfo_Value(tr, parm, value); @@ -87,12 +89,25 @@ bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, if (!model.holdsSnapshotFlags(guid)) { TrackSnapshot snap = snapshotTrack(tr); + // A restore hands the flags back immediately but leaves the chain offline // until the reclaimer gets to it, so a re-park lands here with the FX half - // still held over a still-parked chain. Keep it: the live read above would - // record the park's own offline state as the user's, permanently. + // still held over a still-parked chain. Union the live read against the + // record rather than overwriting either way — see unionFxOfflineOnRepark. + // + // `prior->fxHeld` is provably redundant here today — eraseIfSpent + // (view_mode_model.cpp) guarantees a surviving record has fxHeld == true + // — so the false arm is unreached. Kept anyway: it is the direct + // statement of what this branch actually needs, and cheaper to keep true + // than to re-derive if that guarantee ever loosens. const TrackSnapshot* prior = model.snapshot(guid); - if (prior && prior->fxHeld) { + if (prior && prior->fxHeld && prior->fxKeying == FxKeying::Identity) { + snap.fxOffline = unionFxOfflineOnRepark(prior->fxOffline, snap.fxOffline); + snap.fxKeying = FxKeying::Identity; + } else if (prior && prior->fxHeld) { + // Slot-keyed legacy record (lifted from a pre-identity view_state): no + // identities to union against, so fall back to the old behavior and + // trust the record verbatim over live slots that may have moved since. snap.fxOffline = prior->fxOffline; snap.fxKeying = prior->fxKeying; } diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h index c4ef8c8..650f0fc 100644 --- a/src/shell/view/view_park.h +++ b/src/shell/view/view_park.h @@ -4,6 +4,7 @@ // FX half is captured here (a read) but never written here; the reclaimer owns // every TrackFX_SetOffline, off the switch's synchronous path. +#include #include #include @@ -27,6 +28,42 @@ inline const char* trackFlagParm(Flag f) { return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet } +// A re-park's FX half, unioned rather than overwritten: keeps every FX identity +// `prior` already names (the user's real pre-park state, still offline while the +// reclaimer hasn't run) and appends whatever identity `live` sees that `prior` +// does not — a plugin added during the restored interval, which a verbatim keep +// of `prior` would otherwise never capture and leave stuck offline for good once +// the reclaimer lands. Only meaningful when `prior` is Identity-keyed; a +// Slot-keyed legacy record has no identities to union against, so parkTrack falls +// back to trusting it verbatim instead of calling this. An empty fxGuid (REAPER +// reported no identity) is never deduped against another empty one — it is +// unrestorable either way, so appending it costs nothing and dedup would be a +// coin flip. +inline std::vector unionFxOfflineOnRepark( + const std::vector& prior, const std::vector& live) { + std::vector merged = prior; + std::set named; + for (const FxOfflineState& fx : merged) + if (!fx.fxGuid.empty()) named.insert(fx.fxGuid); + for (const FxOfflineState& fx : live) + if (fx.fxGuid.empty() || named.insert(fx.fxGuid).second) merged.push_back(fx); + return merged; +} + +// The undo-state domains both the mode-switch apply (view.cpp) and lane minting +// (view_lanes.cpp) write: track config/routing, item lane assignment, and the +// ext-state block (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, +// and no UNDO_STATE_FX at all — neither writer moves FX state, so carrying the +// project's whole FX chunk on every point would cost with the plugin count for +// nothing. One symbol, not two identically-valued constants free to diverge. +// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads "track/master +// vol/pan/routing" (reaper_plugin.h:1541) and names neither the fixed-lane +// properties (assumed to ride UNDO_STATE_ITEMS, :1543) nor +// B_SHOWINTCP/B_SHOWINMIXER — and for the visibility pair that inference is now +// DISPROVEN in the DAW: REAPER's undo does not restore them, which is what +// view_reconcile exists to repair. +extern const int kViewUndoMask; + // A track parameter written only when it is not already there. A reapply re-plans // every flag it already applied, so most of what it writes is a value the track // already holds. The read is ASSUMED cheaper than the write it elides (unmeasured); diff --git a/src/shell/view/view_reconcile.cpp b/src/shell/view/view_reconcile.cpp index 3accb85..6d605e8 100644 --- a/src/shell/view/view_reconcile.cpp +++ b/src/shell/view/view_reconcile.cpp @@ -5,6 +5,7 @@ #include "shell/view/view_reconcile.h" +#include #include #include "shell/view/view_park.h" // trackFlagParm / writeIfChanged @@ -23,19 +24,21 @@ namespace reasampler { namespace { -// What the last apply left driven, and in which project. The owner is COMPARE-ONLY +// What the last apply left driven, per project. Keyed by ReaProject*, COMPARE-ONLY // and never dereferenced (the same discipline the panel's own project memos take): a -// closed project's pointer must never be followed, only found unequal. A project we -// hold no memo for gets no repair — the tool has no account of driving anything -// there, so it has no opinion. -ReaProject* g_appliedOwner = nullptr; -view::AppliedState g_applied; +// closed project's pointer must never be followed, only found unequal or used as a +// map key. A project absent here gets no repair — the tool has no account of driving +// anything there, so it has no opinion. Never pruned on project close: churn is a +// handful of entries per session, cheap enough that a close hook would cost more than +// it saves. One slot used to serve every project; a tab-switch mid-session silently +// discarded whichever project's memo wasn't "current" — see planReconcilePerProject. +std::map g_applied; } // namespace void noteAppliedState(ReaProject* proj, const ViewModeModel& model) { - g_appliedOwner = proj ? proj : EnumProjects(-1, nullptr, 0); - g_applied = view::appliedState(model); + ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); + g_applied[project] = view::appliedState(model); } void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { @@ -43,16 +46,13 @@ void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { if (!ValidatePtr2(nullptr, project, "ReaProject*")) return; const view::AppliedState reloaded = view::appliedState(model); - const std::vector ops = - project == g_appliedOwner ? planReconcile(g_applied, reloaded) - : std::vector{}; + const ProjectReconcileResult result = planReconcilePerProject(g_applied, project, reloaded); // Adopted whether or not anything is written: from here the memo describes the // project as it now stands, so a second reload cannot replay a repair that has // already happened over values the user has since changed. - g_appliedOwner = project; - g_applied = reloaded; - if (ops.empty()) return; + g_applied[project] = result.newEntry; + if (result.ops.empty()) return; TrackHandles handleByGuid; readFolderEntries(project, handleByGuid); @@ -62,7 +62,7 @@ void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { Undo_BeginBlock2(project); { UiRefreshHold uiHold; - for (const TrackFlagOp& op : ops) { + for (const TrackFlagOp& op : result.ops) { MediaTrack* tr = resolve(trackByGuid, op.guid); if (!tr) continue; // stale/deleted GUID — prune if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) diff --git a/src/shell/view/view_reconcile.h b/src/shell/view/view_reconcile.h index 62fec33..6c33328 100644 --- a/src/shell/view/view_reconcile.h +++ b/src/shell/view/view_reconcile.h @@ -4,6 +4,7 @@ // does not carry them (DAW-confirmed) — so a park the model no longer records can be // left driven on the track with nobody holding its captured values but us. +#include #include #include #include @@ -27,6 +28,14 @@ namespace reasampler { // // A track the RELOADED model still parks is left out too — it still has an owner, and // the reapply that follows this pass drives it from the live model. +// +// THE LOAD-BEARING RULE is narrower than the `applied == reloaded` early-out below +// looks: only a GUID in `applied.parked` that is ABSENT from `reloaded.parked` can +// ever produce an op. A drifted `activeModeId` alone, or a drifted captured VALUE +// under a GUID both sides still name, fails the equality check (so the early-out is +// skipped) but the loop below finds that GUID still recorded and writes nothing — +// the check is a cheap skip, not a "did anything change" decision. See +// tests/test_view_reconcile.cpp's drift-alone case for the documented inertness. inline std::vector planReconcile(const view::AppliedState& applied, const view::AppliedState& reloaded) { std::vector ops; @@ -45,6 +54,32 @@ inline std::vector planReconcile(const view::AppliedState& applied, return ops; } +// The per-project memo's pure decision: given every project the tool has a memo for +// so far, which project just reloaded, and its fresh fingerprint, produce the ops to +// write back plus the entry the memo should hold for that project from here on. +// `ReaProject*` is used only as an opaque map key here — never dereferenced, the same +// compare-only discipline the .cpp's memo itself keeps. +// +// A project absent from `applied` gets no repair (see SCOPE above: no memo, no +// opinion), but its `reloaded` fingerprint is still the entry handed back — a project +// this pass has never seen before starts its own history here rather than being +// re-skipped on every future compare too. +struct ProjectReconcileResult { + std::vector ops; + view::AppliedState newEntry; +}; + +inline ProjectReconcileResult planReconcilePerProject( + const std::map& applied, ReaProject* project, + const view::AppliedState& reloaded) { + auto it = applied.find(project); + ProjectReconcileResult result; + result.ops = it != applied.end() ? planReconcile(it->second, reloaded) + : std::vector{}; + result.newEntry = reloaded; + return result; +} + // Records what `model` left driven in `proj` (nullptr = current project). Called at // the end of every apply: the fingerprint is the ONLY account of what the tool drove // once an undo has rolled the model back underneath it. diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 78dfd5e..93345d9 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -814,13 +814,13 @@ using LiveFlags = std::map; // Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the // fixed behavior vs. the original buggy (snapshot-every-park) one. // Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place, -// exactly mirroring view.cpp's park then restore then setActiveMode ordering. -// -// Guarded, the park recaptures only when the model holds no record — the property -// the shell's parkTrack keeps. Its second guarded branch (a parked-looking chain -// with no record is left alone) outlived the shell rule it mirrored and is kept -// here only as a second unguarded-vs-guarded contrast; the model half of the defect -// is identical either way. +// mirroring view.cpp's park then restore then setActiveMode ordering: guarded park +// gates on holdsSnapshotFlags (parkTrack's own gate, current shell — there is no +// refusal branch any more, see view_park.h) and guarded restore spends the flag half +// through releaseSnapshotFlags (restoreTrack's own call), not a full clearSnapshot. +// The FX half of the two-half record is never exercised here (nothing sets fxHeld +// false), so it stays held throughout and never blocks a re-capture the way +// flagsHeld does — the one place this harness is narrower than the real record. void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree, const std::string& target, bool guard) { TogglePlan plan = vm.planToggle(tree, target); @@ -830,8 +830,7 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; if (guard) { - if (vm.snapshot(guid) == nullptr) { - if (live[guid] == 0) continue; // reads parked, no snapshot — refuse + if (!vm.holdsSnapshotFlags(guid)) { TrackSnapshot snap; snap.showInTcp = live[guid]; vm.storeSnapshot(guid, snap); @@ -844,13 +843,13 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre live[guid] = 0; // park hides it } - // RESTORE: write snapshot flag back, then drop the snapshot. + // RESTORE: write snapshot flag back, then spend the flag half. for (const auto& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; if (const TrackSnapshot* snap = vm.snapshot(guid)) live[guid] = snap->showInTcp; // restore the captured visible flag - vm.clearSnapshot(guid); + vm.releaseSnapshotFlags(guid); } vm.setActiveMode(target); @@ -939,70 +938,47 @@ static void testNestedToggleSnapshotSurvivesRepark() { // -- 9b. Reload strand: snapshots gone, live flags still parked --------------- // -// The pair the park refusal exists for, driven through the same harness. A model -// replaced without the project rolling back with it — an unreadable view_state, a -// snapshot reconciled away while its track was deleted, a hand or script edit — -// leaves tracks sitting at parked values with nothing recording what they were -// before. Snapshotting there commits the parked state as the user's, and the very -// next toggle writes it back over whatever they have since fixed by hand. +// A model replaced without the project rolling back with it — an unreadable +// view_state, a snapshot reconciled away while its track was deleted, a hand or +// script edit — leaves tracks sitting at parked values with nothing recording what +// they were before. The park refusal that once existed for this pair (view_fx_park's +// decidePark) was retired by the Wave-2 shell restructure: view_park.h now states +// plainly that nothing infers a park from a live value any more, so a parked-looking +// chain with no record is treated as the user's own state and captured, not refused. +// This test documents the CURRENT consequence — it is not asserting the consequence +// is desirable: a hand recovery made before the next reapply survives, but one made +// after it (the reapply has already re-captured the still-hidden value as "the +// user's") is overwritten by the next restore, converging with the always-recapture +// defect test 9's unguarded block pins. Guarded and unguarded no longer differ here. -static void testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting() { +static void testReloadedModelWithParkedTracksIsCapturedNotRefused() { const FolderTree tree = nestedTree(); - // GUARDED (fixed shell). - { - ViewModeModel vm; - vm.membership().tag("{L1}", kDesignModeId); - LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; + ViewModeModel vm; + vm.membership().tag("{L1}", kDesignModeId); + LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); - CHECK(vm.snapshot("{L2}") != nullptr); + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + CHECK(vm.snapshot("{L2}") != nullptr); - // The reload: the model comes back with no snapshots while the project's - // tracks are still parked. - vm.clearSnapshot("{L2}"); - vm.clearSnapshot("{L3}"); + // The reload: the model comes back with no snapshots while the project's + // tracks are still parked. + vm.clearSnapshot("{L2}"); + vm.clearSnapshot("{L3}"); - // The load tick reapplies the saved active mode. Both leaves are refused — - // nothing written, and nothing false captured. - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr); - CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + // The load tick reapplies the saved active mode. Both leaves are captured + // fresh from their still-hidden live value — not refused. + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(vm.snapshot("{L2}") != nullptr && vm.snapshot("{L3}") != nullptr); + CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); - // The documented hand recovery, on {L2} only, while still in Design. - // Toggling back must leave it where the user put it: with no snapshot - // there is nothing to restore FROM, so the restore writes nothing. - live["{L2}"] = 1; - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); - CHECK(live["{L2}"] == 1); // the hand fix survives - CHECK(live["{L3}"] == 0); // never recovered — stuck, but never falsely committed - - // And {L2} is back under mode control from there: the next park sees a - // clean chain, captures the user's value, and restores it. - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(live["{L2}"] == 0 && vm.snapshot("{L2}") != nullptr); - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); - CHECK(live["{L2}"] == 1); - } - - // UNGUARDED (the defect): the reapply after the reload recaptures the parked - // zero, so the very next toggle writes it back over the hand recovery. - { - ViewModeModel vm; - vm.membership().tag("{L1}", kDesignModeId); - LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; - - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false); - vm.clearSnapshot("{L2}"); - vm.clearSnapshot("{L3}"); - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false); - - live["{L2}"] = 1; // the same hand recovery - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/false); - - CHECK(live["{L2}"] == 0); // wiped — the false snapshot won - } + // A hand recovery made NOW, after that recapture, is not what the next restore + // reads back — the fresh (hidden) capture just above is, so it is lost. + live["{L2}"] = 1; + simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); + CHECK(live["{L2}"] == 0); // wiped by the restore + CHECK(live["{L3}"] == 0); // never recovered } // =========================================================================== @@ -2190,7 +2166,7 @@ int main() { testUntaggedLeavesManagedByModeSystem(); testTaggedLeafBehaviorUnchangedWithUntagged(); testNestedToggleSnapshotSurvivesRepark(); - testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting(); + testReloadedModelWithParkedTracksIsCapturedNotRefused(); testReconcilePrunesOrphanedSnapshots(); testReconcileFullLiveSetIsNoOp(); testReconcileThenReparkLifecycleIntact(); diff --git a/tests/test_view_park.cpp b/tests/test_view_park.cpp index 8f875c8..538231e 100644 --- a/tests/test_view_park.cpp +++ b/tests/test_view_park.cpp @@ -91,6 +91,47 @@ static void testAReapplyThatWroteNothingLeavesNoPointAtAll() { CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false)); } +// -- the re-park union: a plugin added between a restore and the next park -------- +// +// Sequence under test: park -> restore -> user adds plugin P -> re-park. Without the +// union, the record's prior verbatim-keep would drop P silently -- the reclaimer +// offlines it with everything else, and no snapshot ever names it to bring it back. + +static void testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped() { + const std::vector prior{ + FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-B}", 0}}; + const std::vector live{ + FxOfflineState{"{FX-A}", 0}, FxOfflineState{"{FX-B}", 0}, FxOfflineState{"{FX-P}", 0}}; + + const std::vector merged = unionFxOfflineOnRepark(prior, live); + + CHECK(merged.size() == 3); + bool foundA = false, foundB = false, foundP = false; + for (const FxOfflineState& fx : merged) { + if (fx.fxGuid == "{FX-A}") { foundA = true; CHECK(fx.offline == 1); } // record wins + if (fx.fxGuid == "{FX-B}") { foundB = true; CHECK(fx.offline == 0); } // record wins + if (fx.fxGuid == "{FX-P}") { foundP = true; CHECK(fx.offline == 0); } // added from live + } + CHECK(foundA && foundB && foundP); +} + +static void testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop() { + // The union is not where a removed plugin's entry disappears -- resolveFxRestore + // owns that at restore time (missingIdentity, reported), so the merge must keep + // naming it rather than quietly forgetting it here. + const std::vector prior{ + FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-REMOVED}", 1}}; + const std::vector live{FxOfflineState{"{FX-A}", 0}}; + + const std::vector merged = unionFxOfflineOnRepark(prior, live); + + CHECK(merged.size() == 2); + bool foundRemoved = false; + for (const FxOfflineState& fx : merged) + if (fx.fxGuid == "{FX-REMOVED}") foundRemoved = true; + CHECK(foundRemoved); +} + int main() { testEachDrivenFlagAddressesItsOwnReaperParameter(); testNoDrivenFlagCanEverAddressMuteOrSolo(); @@ -100,6 +141,9 @@ int main() { testAReapplyThatWroteSomethingLeavesAPoint(); testAReapplyThatWroteNothingLeavesNoPointAtAll(); + testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped(); + testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } diff --git a/tests/test_view_reconcile.cpp b/tests/test_view_reconcile.cpp index 0d8db11..dda1122 100644 --- a/tests/test_view_reconcile.cpp +++ b/tests/test_view_reconcile.cpp @@ -11,6 +11,7 @@ #include "../src/shell/view/view_reconcile.h" #include +#include #include #include @@ -144,6 +145,48 @@ static void testASpentFlagHalfIsNotARecordToRepairFrom() { CHECK(planReconcile(view::appliedState(applied), view::appliedState(reloaded)).empty()); } +// -- the per-project memo: a tab switch must not erase another project's account -- + +static void testASecondProjectNotedInBetweenDoesNotEraseTheFirstProjectsMemo() { + // The bug this replaced: a single global slot meant noting project B's applied + // state overwrote project A's entirely, so a repair for A run afterward found no + // memo to compare against and silently skipped -- exactly what a tab-switch then + // an undo on the ORIGINAL project produces within one timer period. + ReaProject* const projA = reinterpret_cast(1); + ReaProject* const projB = reinterpret_cast(2); + + ViewModeModel appliedA; + appliedA.setActiveMode(kDesignModeId); + appliedA.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const ViewModeModel appliedB; // untouched — B has nothing driven + + std::map memo; + memo[projA] = view::appliedState(appliedA); + memo[projB] = view::appliedState(appliedB); // noted in between; must not clobber A + + const ViewModeModel reloadedA; // A's park record dropped, as an undo would leave it + + const ProjectReconcileResult result = + planReconcilePerProject(memo, projA, view::appliedState(reloadedA)); + + CHECK(result.ops.size() == 4); + CHECK(hasOp(result.ops, "{A}", Flag::MainSend, 1)); +} + +static void testAProjectWithNoPriorMemoGetsNoRepair() { + // "A project we hold no memo for gets no repair" -- the tool has no account of + // driving anything there, so it has no opinion (see the header's SCOPE note). + ReaProject* const projC = reinterpret_cast(3); + const std::map memo; // never noted + + const ViewModeModel reloaded; + const ProjectReconcileResult result = + planReconcilePerProject(memo, projC, view::appliedState(reloaded)); + + CHECK(result.ops.empty()); +} + int main() { testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing(); testADriftedActiveModeAloneIsNotAReasonToDriveATrack(); @@ -153,6 +196,9 @@ int main() { testATrackNeitherStateRecordsIsNeverDrivenAtAll(); testASpentFlagHalfIsNotARecordToRepairFrom(); + testASecondProjectNotedInBetweenDoesNotEraseTheFirstProjectsMemo(); + testAProjectWithNoPriorMemoGetsNoRepair(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } From 0f6df5fd6dc0dac78984357d8f3e86ee28675e2d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 6 Aug 2026 18:35:24 -0400 Subject: [PATCH 3/3] Design View: expose a park-shaped no-record track visible instead of adopting or refusing it Adopting silently wipes hand repairs or hides untouched tracks forever; refusing was rejected too. EXPOSE hands the track back and self-heals on the next apply. --- src/shell/view/view.cpp | 11 +++- src/shell/view/view_park.cpp | 67 +++++++++++++++++++--- src/shell/view/view_park.h | 100 +++++++++++++++++++++++++++++---- tests/test_view_mode_model.cpp | 60 +++++++++++++------- tests/test_view_park.cpp | 88 +++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 41 deletions(-) diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 651476d..132bc3e 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -114,6 +114,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject bool laneModeChanged = false; bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint + std::vector exposedNames; // named while handles are fresh, reported after the block Undo_BeginBlock2(project); { @@ -133,7 +134,11 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune - if (parkTrack(model, guid, tr, tp.flags)) wrote = true; + const TrackParkResult r = parkTrack(model, guid, tr, tp.flags); + if (r.wrote) wrote = true; + // Named here, not after the block: TrackList_AdjustWindows below can + // invalidate the handle. + if (r.exposed) exposedNames.push_back(trackDisplayName(tr)); } for (const TrackPlan& tp : plan.restore) { @@ -189,6 +194,10 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject const bool mint = applyMintsUndoPoint(realSwitch, wrote); Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0); + // After the undo block: an EXPOSE report is a console message, not a project + // write, and must not join what a Ctrl-Z rolls back. + reportExposedTracks(project, exposedNames); + // The tool's own account of what it just left driven — the only one that survives // an undo rolling the model back underneath it. noteAppliedState(project, model); diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp index 4b0c95d..ef74936 100644 --- a/src/shell/view/view_park.cpp +++ b/src/shell/view/view_park.cpp @@ -3,12 +3,15 @@ #include "shell/view/view_park.h" +#include // strnlen — bounded read of GetTrackName's buffer #include #include #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetTrackName #define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_TrackFX_GetCount #define REAPERAPI_WANT_TrackFX_GetFXGUID #define REAPERAPI_WANT_TrackFX_GetOffline @@ -73,6 +76,22 @@ bool applyFlags(MediaTrack* tr, const std::vector& flags) { return wrote; } +// The visible/active mirror of a park write: same four ops, value 1 instead of +// whatever the plan says (always 0 for a park plan — see makeParkPlan). Reusing +// the incoming park ops rather than minting a separate "expose plan" keeps the +// two writes provably addressing the same four parameters. +bool exposeFlags(MediaTrack* tr, const std::vector& parkFlags) { + bool wrote = false; + for (const TrackFlagOp& op : parkFlags) + if (writeIfChanged(tr, trackFlagParm(op.flag), 1.0)) wrote = true; + return wrote; +} + +// The last EXPOSE report, so an unchanged set is not reprinted on every reapply +// the track hasn't yet self-healed from. +ReaProject* g_reportedOwner = nullptr; +std::vector g_reportedExposed; + } // namespace const int kViewUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; @@ -83,11 +102,28 @@ bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { return true; } -bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags) { - bool wrote = false; +TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags) { + TrackParkResult result; if (!model.holdsSnapshotFlags(guid)) { + const TrackSnapshot* prior = model.snapshot(guid); + + // No record of ANY kind (see parkTrack's header doc for why prior == nullptr + // is the right gate): the ambiguous case. A live chain already reading + // park-shaped is EXPOSEd — handed back visible, no snapshot stored — rather + // than adopted as the user's intent. + if (!prior) { + auto read = [tr](Flag f) { + return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); + }; + if (chainReadsParked(flags, read)) { + if (exposeFlags(tr, flags)) result.wrote = true; + result.exposed = true; + return result; // this cycle hands the track back; it does not park it + } + } + TrackSnapshot snap = snapshotTrack(tr); // A restore hands the flags back immediately but leaves the chain offline @@ -100,7 +136,6 @@ bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, // — so the false arm is unreached. Kept anyway: it is the direct // statement of what this branch actually needs, and cheaper to keep true // than to re-derive if that guarantee ever loosens. - const TrackSnapshot* prior = model.snapshot(guid); if (prior && prior->fxHeld && prior->fxKeying == FxKeying::Identity) { snap.fxOffline = unionFxOfflineOnRepark(prior->fxOffline, snap.fxOffline); snap.fxKeying = FxKeying::Identity; @@ -112,11 +147,11 @@ bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, snap.fxKeying = prior->fxKeying; } model.storeSnapshot(guid, snap); - wrote = true; + result.wrote = true; } - if (applyFlags(tr, flags)) wrote = true; - return wrote; + if (applyFlags(tr, flags)) result.wrote = true; + return result; } bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, @@ -126,4 +161,22 @@ bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, return true; } +std::string trackDisplayName(MediaTrack* tr) { + std::vector buf(1024, '\0'); + if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast(buf.size()))) + return "(name unavailable)"; + return std::string(buf.data(), strnlen(buf.data(), buf.size())); +} + +void reportExposedTracks(ReaProject* proj, const std::vector& trackNames) { + if (!shouldReport(proj == g_reportedOwner, g_reportedExposed, trackNames)) return; + g_reportedOwner = proj; + g_reportedExposed = trackNames; + + const std::string msg = describeExposedTracks(trackNames); + // "!SHOW:" so an unattended project-load reapply cannot force the console open + // (reaper_plugin_functions.h:6536). + if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); +} + } // namespace reasampler diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h index 650f0fc..4b99706 100644 --- a/src/shell/view/view_park.h +++ b/src/shell/view/view_park.h @@ -12,6 +12,7 @@ // Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. class MediaTrack; +class ReaProject; namespace reasampler { @@ -50,6 +51,21 @@ inline std::vector unionFxOfflineOnRepark( return merged; } +// Does the track already sit at every value `parkFlags` would write? Templated on +// the read so live code compares as it reads (one call per Flag, no live-value +// vector) while a test drives the same fold from a table. FX-offline is +// deliberately NOT folded in: Wave 3 moved every per-FX write off this path onto +// the reclaimer, so a chain with these four flags fine but an FX hand-offlined is +// not evidence THIS path ever touched the track — only the flags it would itself +// have driven are. An empty plan is evidence of nothing and answers false. +template +bool chainReadsParked(const std::vector& parkFlags, ReadFlag readFlag) { + if (parkFlags.empty()) return false; + for (const TrackFlagOp& op : parkFlags) + if (readFlag(op.flag) != op.value) return false; + return true; +} + // The undo-state domains both the mode-switch apply (view.cpp) and lane minting // (view_lanes.cpp) write: track config/routing, item lane assignment, and the // ext-state block (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, @@ -71,21 +87,81 @@ extern const int kViewUndoMask; // Returns whether it wrote. bool writeIfChanged(MediaTrack* tr, const char* parm, double value); +// What one parkTrack call did. `wrote` is any project write at all — a flag write +// or a record write — and feeds the apply's undo-point fold. `exposed` is true iff +// this call handed the track back visible instead of parking or adopting it; the +// caller (view.cpp) collects exposed tracks across the whole apply for the one +// batched console report. +struct TrackParkResult { + bool wrote = false; + bool exposed = false; +}; + // Parks one inactive leaf: capture the pre-park truth when the model does not -// already hold it, then drive the flags. Returns whether anything moved — a project -// write or the record. +// already hold ANY record for it (holdsSnapshotFlags false AND model.snapshot == +// nullptr), then drive the flags. // -// The record is the tool's own account of an act it performed, and it is the ONLY -// account — nothing infers a park from a live value any more. That is what retired -// the old refusal: a track that merely LOOKS parked while no record names it is a -// track the tool did not park, so its values are the user's own and capturing them -// is right. So the capture is owed exactly when the flag half is not held. +// A record with a held FX half but no held flag half (a re-park landing right +// after a restore, before the reclaimer catches up) is NOT the no-record case — +// model.snapshot(guid) is non-null there, so it takes the existing union path +// below, never the branch this comment is about. That is how EXPOSE is known not +// to fire on a track the tool legitimately still holds a record for: EXPOSE is +// gated on snapshot(guid) == nullptr, and a legitimately-held record is by +// definition non-null (eraseIfSpent only drops a record once BOTH halves are +// spent). // -// A still-held FX half is the one thing not re-read: it outlives the flag half the -// restore spends, the chain stays offline until the reclaimer onlines it, and -// re-reading it there would file the park's own state as the user's. -bool parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags); +// The true no-record case splits on whether the live chain already reads +// park-shaped (chainReadsParked, flags only — see its own comment for why FX is +// excluded). Not park-shaped: the chain is the track's own honest state, captured +// and parked exactly as ever. Park-shaped: parking now would adopt a park with no +// account behind it — an undo/redo or reload that dropped the snapshot, a hand or +// script edit, or simply a track the user keeps hidden — as the user's intent, +// which either wipes a hand repair on the very next restore or hides an untouched +// track for good. EXPOSE hands it back instead: no snapshot stored, the four +// flags driven to visible/active, nothing reported here (the caller batches it). +// The next apply sees an honest chain again and captures/parks it normally — +// self-healing, no user action required. +TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags); + +// GetTrackName, matching capture::trackName's reason for the same choice: "Track +// N" for an unnamed track, matching REAPER's own convention. Exported so a caller +// holding the handle only briefly (view.cpp's park loop, before +// Undo_EndBlock2/TrackList_AdjustWindows can invalidate it) captures the name at +// resolve time rather than the handle. +std::string trackDisplayName(MediaTrack* tr); + +// One console message for the tracks EXPOSEd this apply, empty when none. NAMES +// them: an exposed track was hidden from both the TCP and the mixer moments ago, +// so a bare count points at nothing the user can go and find. Says the tool lost +// its own record and handed the track back — never "refused". +inline std::string describeExposedTracks(const std::vector& trackNames) { + if (trackNames.empty()) return {}; + const bool one = trackNames.size() == 1; + std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" + + (one ? "" : "s") + " handed back visible -- the tool's record of " + + (one ? "its" : "their") + + " pre-park state is gone (an undo/redo or a project reload dropped " + "it), and parking now would have recorded the parked state as " + "yours instead.\n"; + for (const std::string& name : trackNames) msg += " " + name + "\n"; + msg += "The next mode switch will park it correctly from what you see now.\n"; + return msg; +} + +// Pure gate behind reportExposedTracks' one-per-changed-set memo: true on any +// change of owner or of the named set, false on an exact repeat. +inline bool shouldReport(bool sameOwnerAsLast, const std::vector& last, + const std::vector& now) { + return !(sameOwnerAsLast && last == now); +} + +// Prints describeExposedTracks for `trackNames` (captured by the caller while its +// track handles are still fresh — see view.cpp's park loop), ONCE per changed set +// per project: the same exposure recurs on every reapply until the track +// self-heals, and repeating it verbatim is noise the user cannot act on any +// better the second time. `proj` is used only as a memo key, never dereferenced. +void reportExposedTracks(ReaProject* proj, const std::vector& trackNames); // Restores one returning leaf from its record and spends the record's flag half. // Always true: the record is consumed here, so this apply changed state and is not diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 93345d9..3bbf76a 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -821,6 +821,13 @@ using LiveFlags = std::map; // The FX half of the two-half record is never exercised here (nothing sets fxHeld // false), so it stays held throughout and never blocks a re-capture the way // flagsHeld does — the one place this harness is narrower than the real record. +// +// `live[guid] == 0` doubles as this harness's single-flag stand-in for +// parkTrack's real chainReadsParked (all four driven flags at their parked +// value) — the harness tracks one representative flag, not four, so "already at +// its parked value" is the whole predicate here. `vm.snapshot(guid) == nullptr` +// mirrors parkTrack's own gate for "no record of any kind" — see that function's +// header doc for why an FX-held-only record must not reach EXPOSE. void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree, const std::string& target, bool guard) { TogglePlan plan = vm.planToggle(tree, target); @@ -831,6 +838,13 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre const std::string& guid = tp.flags.front().guid; if (guard) { if (!vm.holdsSnapshotFlags(guid)) { + if (!vm.snapshot(guid) && live[guid] == 0) { + // EXPOSE: hand it back visible rather than adopt the + // park-shaped read as intent. No snapshot stored; this cycle + // does not park the track. + live[guid] = 1; + continue; + } TrackSnapshot snap; snap.showInTcp = live[guid]; vm.storeSnapshot(guid, snap); @@ -941,17 +955,14 @@ static void testNestedToggleSnapshotSurvivesRepark() { // A model replaced without the project rolling back with it — an unreadable // view_state, a snapshot reconciled away while its track was deleted, a hand or // script edit — leaves tracks sitting at parked values with nothing recording what -// they were before. The park refusal that once existed for this pair (view_fx_park's -// decidePark) was retired by the Wave-2 shell restructure: view_park.h now states -// plainly that nothing infers a park from a live value any more, so a parked-looking -// chain with no record is treated as the user's own state and captured, not refused. -// This test documents the CURRENT consequence — it is not asserting the consequence -// is desirable: a hand recovery made before the next reapply survives, but one made -// after it (the reapply has already re-captured the still-hidden value as "the -// user's") is overwritten by the next restore, converging with the always-recapture -// defect test 9's unguarded block pins. Guarded and unguarded no longer differ here. +// they were before. Daniel's ruling: hand the track back rather than either +// adopting the parked read as intent (silently wipes a hand repair on the very +// next restore, or hides an untouched track forever) or refusing outright (the +// retired `decidePark`). EXPOSE does neither: no snapshot stored, the flags +// driven visible, and the very next reapply — no user action needed — finds an +// honest chain and captures/parks it normally. -static void testReloadedModelWithParkedTracksIsCapturedNotRefused() { +static void testReloadedModelWithParkedTracksIsExposedNotAdopted() { const FolderTree tree = nestedTree(); ViewModeModel vm; @@ -963,22 +974,29 @@ static void testReloadedModelWithParkedTracksIsCapturedNotRefused() { CHECK(vm.snapshot("{L2}") != nullptr); // The reload: the model comes back with no snapshots while the project's - // tracks are still parked. + // tracks are still parked -- park-shaped, no record. vm.clearSnapshot("{L2}"); vm.clearSnapshot("{L3}"); - // The load tick reapplies the saved active mode. Both leaves are captured - // fresh from their still-hidden live value — not refused. + // The load-tick reapply hits the no-record/park-shaped pair on both leaves. + // EXPOSE fires for each: handed back visible, no snapshot stored -- neither + // leaf needed a hand recovery first, and {L3} (never touched by hand in the + // old test) comes back on its own exactly like {L2}. simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(vm.snapshot("{L2}") != nullptr && vm.snapshot("{L3}") != nullptr); - CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + CHECK(live["{L2}"] == 1 && live["{L3}"] == 1); + CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr); - // A hand recovery made NOW, after that recapture, is not what the next restore - // reads back — the fresh (hidden) capture just above is, so it is lost. - live["{L2}"] = 1; + // Self-healing: the chain no longer reads park-shaped, so the very next apply + // of the same target takes the normal path -- captures the (now-visible) + // truth and parks it correctly. + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + CHECK(vm.snapshot("{L2}") != nullptr && vm.snapshot("{L3}") != nullptr); + + // A normal round trip back to Arrange restores the captured (visible) truth -- + // the wipe the old adopt-based behavior produced is gone. simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); - CHECK(live["{L2}"] == 0); // wiped by the restore - CHECK(live["{L3}"] == 0); // never recovered + CHECK(live["{L2}"] == 1 && live["{L3}"] == 1); } // =========================================================================== @@ -2166,7 +2184,7 @@ int main() { testUntaggedLeavesManagedByModeSystem(); testTaggedLeafBehaviorUnchangedWithUntagged(); testNestedToggleSnapshotSurvivesRepark(); - testReloadedModelWithParkedTracksIsCapturedNotRefused(); + testReloadedModelWithParkedTracksIsExposedNotAdopted(); testReconcilePrunesOrphanedSnapshots(); testReconcileFullLiveSetIsNoOp(); testReconcileThenReparkLifecycleIntact(); diff --git a/tests/test_view_park.cpp b/tests/test_view_park.cpp index 538231e..c8ef44a 100644 --- a/tests/test_view_park.cpp +++ b/tests/test_view_park.cpp @@ -132,6 +132,85 @@ static void testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToD CHECK(foundRemoved); } +// -- chainReadsParked: the EXPOSE trigger, flags only ------------------------- +// +// The live values the fold reads, keyed by Flag -- so an assertion names the flag +// it varies rather than a position in makeParkPlan's op order. +struct FlagValues { + int showInTcp = 0, showInMixer = 0, mainSend = 0, fxEnable = 0; + + int operator()(Flag f) const { + switch (f) { + case Flag::ShowInTcp: return showInTcp; + case Flag::ShowInMixer: return showInMixer; + case Flag::MainSend: return mainSend; + case Flag::FxEnable: return fxEnable; + } + return -1; + } +}; + +static void testAChainSittingAtEveryParkedValueReadsAsParkShaped() { + const TrackPlan park = makeParkPlan("{A}"); + CHECK(chainReadsParked(park.flags, FlagValues{0, 0, 0, 0})); +} + +static void testOneFlagStillAtTheUsersValueMeansNotParkShaped() { + 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. + CHECK(!chainReadsParked(park.flags, FlagValues{1, 0, 0, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 1, 0, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 0, 1, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 0, 0, 1})); +} + +static void testAnEmptyPlanProvesNothing() { + CHECK(!chainReadsParked(std::vector{}, FlagValues{0, 0, 0, 0})); +} + +static void testFxOfflineAloneIsNotFoldedIntoTheParkedRead() { + // chainReadsParked takes no FX argument at all: an FX-only-offline chain + // cannot make it read parked, by construction -- there is nothing here it + // could read. That is the resolved design question (see view_park.h): + // flags-fine/FX-offline stays on the ordinary capture path, never EXPOSE. + const TrackPlan park = makeParkPlan("{A}"); + CHECK(!chainReadsParked(park.flags, FlagValues{1, 1, 1, 1})); // flags all fine +} + +// -- the EXPOSE console report ------------------------------------------------- + +static void testExposedTracksAreNamedAndEmptyListSaysNothing() { + CHECK(describeExposedTracks({}).empty()); + + const std::string one = describeExposedTracks({"Bass"}); + CHECK(one.find("1 track ") != std::string::npos); + CHECK(one.find("Bass") != std::string::npos); + + const std::string two = describeExposedTracks({"Bass", "Drum bus"}); + CHECK(two.find("2 tracks ") != std::string::npos); + CHECK(two.find("Bass") != std::string::npos); + CHECK(two.find("Drum bus") != std::string::npos); +} + +static void testExposedReportNeverUsesRefusalWording() { + // Daniel's ruling was EXPOSE, not refuse -- the wording must never suggest the + // tool declined to act. + const std::string msg = describeExposedTracks({"Bass"}); + CHECK(msg.find("refus") == std::string::npos); +} + +static void testShouldReportSkipsAnExactRepeatOnly() { + const std::vector a{"Bass"}; + const std::vector b{"Bass", "Drum bus"}; + + CHECK(!shouldReport(/*sameOwnerAsLast=*/true, a, a)); // exact repeat: silent + CHECK(shouldReport(/*sameOwnerAsLast=*/true, a, b)); // set changed: reprint + CHECK(shouldReport(/*sameOwnerAsLast=*/false, a, a)); // owner (project) changed: reprint + CHECK(shouldReport(/*sameOwnerAsLast=*/true, a, {})); // healed to none: reprint (resets memo) +} + int main() { testEachDrivenFlagAddressesItsOwnReaperParameter(); testNoDrivenFlagCanEverAddressMuteOrSolo(); @@ -144,6 +223,15 @@ int main() { testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped(); testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop(); + testAChainSittingAtEveryParkedValueReadsAsParkShaped(); + testOneFlagStillAtTheUsersValueMeansNotParkShaped(); + testAnEmptyPlanProvesNothing(); + testFxOfflineAloneIsNotFoldedIntoTheParkedRead(); + + testExposedTracksAreNamedAndEmptyListSaysNothing(); + testExposedReportNeverUsesRefusalWording(); + testShouldReportSkipsAnExactRepeatOnly(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }