Merge Wave 2: Design View shell restructure (FX off the switch, park/lanes/reconcile/tracks split, record-scoped repair, expose-on-lost-record)

This commit is contained in:
2026-08-06 18:38:40 -04:00
22 changed files with 1619 additions and 1210 deletions
+12 -10
View File
@@ -39,8 +39,11 @@ add_library(reaper_reasampler MODULE
${LICE_SRC} ${LICE_SRC}
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
${REASAMPLER_SRC_DIR}/shell/view/view.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_solo.cpp
${REASAMPLER_SRC_DIR}/shell/view/view_tracks.cpp
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp ${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/item_read.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/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.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 # 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 # 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. # 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}) 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 # The view shell'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 # CTest-covered like a core/ module: the Flag -> parameter mapping, the apply's
# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather # undo-point fold, and the reconcile's scope rule and drift trigger. Declared here
# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none # rather than in a src/shell/view/CMakeLists.txt because that directory deliberately
# — its TUs are compiled into this target directly. view_mode_model is linked for # has none — its TUs are compiled into this target directly.
# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built reasampler_test(view_park LINK view_mode_model)
# ops would not catch a change to the planner. reasampler_test(view_reconcile LINK applied_state view_mode_model)
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# Bank-package import: the promptless verb plus its action skin. Kept as its own # 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 # appended block rather than merged into the lists above, so the two package
+8 -1
View File
@@ -38,7 +38,9 @@
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession #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; namespace capture = reasampler::capture;
@@ -205,6 +207,11 @@ static void OnTimer()
// the cross-session source of truth) BEFORE reapplying visibility. Never // the cross-session source of truth) BEFORE reapplying visibility. Never
// re-mints, never mass-tags. // re-mints, never mass-tags.
reasampler::reconcileManagedLanes(g_session.view(), nullptr); 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); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
} }
+2 -1
View File
@@ -25,7 +25,8 @@
#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #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/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #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_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_CountSelectedTracks
+2 -1
View File
@@ -21,7 +21,8 @@
#include "shell/capture/track_guid.h" // guidString #include "shell/capture/track_guid.h" // guidString
#include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/panel/panel_input.h" // bankPanelTailSetting
#include "shell/persist/session.h" #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_MINIMAL
#define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_CountTrackMediaItems
+2 -1
View File
@@ -20,7 +20,8 @@
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag #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/item_read.h" // itemGuid / itemLaneName — shared item-read seam
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key #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 // New-content detection: enumerate live tracks + items and read fixed-lane state to
// classify an item's lane as managed vs manual. // classify an item's lane as managed vs manual.
+47 -467
View File
@@ -1,98 +1,50 @@
// See view.h. Compiled into the reaper_reasampler module; includes // See view.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). // 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 // Tree arithmetic lives in view_tree (pure), one track's park/restore in view_park
// view_fx_park; this file owns the plan iteration, lanes, parents and the one // and the lane writes in view_lanes; this file owns the plan iteration, parent
// undo block they all ride. // visibility, and the one undo block they all ride.
#include "shell/view/view.h" #include "shell/view/view.h"
#include <cstdio>
#include <map> #include <map>
#include <optional>
#include <set> #include <set>
#include <string> #include <string>
#include <unordered_map>
#include <utility>
#include <vector> #include <vector>
#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 "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 "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_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetPlayStateEx #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_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_UpdateTimeline
#define REAPERAPI_WANT_ValidatePtr2 #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" #include "reaper_plugin_functions.h"
namespace reasampler { namespace reasampler {
using view::buildFolderTree; using view::buildFolderTree;
using view::isOnManualLane;
using view::managedLaneKey;
using view::modeIdFromLaneName;
using view::TrackFolderEntry; using view::TrackFolderEntry;
namespace { 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 // GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is
// deliberately excluded, nothing is moving there. // deliberately excluded, nothing is moving there.
constexpr int kTransportMoving = 1 | 4; constexpr int kTransportMoving = 1 | 4;
// The domains one apply actually writes: track config/routing (visibility, // applyMode is reachable from the load tick, the actions and render_in_place. Nothing
// B_MAINSEND, I_SOLO), item lane assignment, and the ext-state block // it calls is known to pump the message loop now that the FX writes are gone, but a
// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which // re-entrant apply would plan against the same model the outer loop is iterating and
// includes UNDO_STATE_FX and so makes every undo record carry every track's FX // consume records out from under it — one dropped call is cheaper than that. Fail closed.
// 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.
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.
bool g_applying = false; bool g_applying = false;
struct ApplyLatch { struct ApplyLatch {
ApplyLatch() { g_applying = true; } ApplyLatch() { g_applying = true; }
@@ -101,275 +53,6 @@ struct ApplyLatch {
ApplyLatch& operator=(const ApplyLatch&) = delete; 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<double>(kLanesDisplayAsNormal));
const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
static_cast<double>(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<TrackFolderEntry> readFolderEntries(
ReaProject* proj,
TrackHandles& handleByGuid) {
std::vector<TrackFolderEntry> entries;
int count = CountTracks(proj);
entries.reserve(static_cast<std::size_t>(count));
handleByGuid.reserve(static_cast<std::size_t>(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<int>(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<std::string, MediaTrack*>;
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<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
std::map<std::string, int> byKey;
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
std::optional<std::string> 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<double>(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<LanePlayOp>& lanes) {
LaneApplyResult out;
if (lanes.empty()) return out;
std::map<std::string, std::vector<const LanePlayOp*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
static_cast<double>(kFreeModeFixedLanes));
applyTransparentLaneDisplay(tr);
out.freeMode = true;
out.wrote = true;
}
const std::map<std::string, int> 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<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> 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<std::string> 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<LaneTrack> readLaneTracks(
const ViewModeModel& model,
const TrackHandles& handleByGuid) {
std::vector<LaneTrack> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
const int itemCount = CountTrackMediaItems(tr);
lt.items.reserve(static_cast<std::size_t>(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<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
if (current == laneOrdinal) return false; // already there — no-op
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(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<std::string, std::vector<const LaneMint*>> mintsByTrack;
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
std::map<std::string, std::vector<const LaneAssign*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(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<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
std::map<std::string, int> 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<double>(laneCount));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
std::vector<char> 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<std::string, int> ordinals = managedLaneOrdinals(tr);
const std::map<std::string, MediaItem*> 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 } // namespace
bool transportBlocksModeSwitch(ReaProject* proj) { bool transportBlocksModeSwitch(ReaProject* proj) {
@@ -386,9 +69,9 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
return false; // reject before touching the project — no partial apply 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 // Pinned ONCE: resolving "current project" independently at the begin and the end
// them, so resolving "current project" independently at each would let a tab switch // would let a tab switch mid-apply open the block on one project and close it on
// mid-apply open the block on one project and close it on another. // another.
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false; if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false;
@@ -405,13 +88,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
TrackHandles handleByGuid; TrackHandles handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(project, handleByGuid); std::vector<TrackFolderEntry> entries = readFolderEntries(project, handleByGuid);
TrackByGuid trackByGuid = indexHandles(handleByGuid); const TrackByGuid trackByGuid = indexHandles(handleByGuid);
FolderTree tree = buildFolderTree(entries); FolderTree tree = buildFolderTree(entries);
// Prune snapshots for tracks no longer in the live enumeration before // Prune records for tracks no longer in the live enumeration before planning
// planning (membership is intentionally left alone — see model.reconcile). // (membership is intentionally left alone — see model.reconcile). Because
// Because reapply-on-load routes through applyMode, this also reconciles // reapply-on-load routes through applyMode, this also reconciles on project open.
// on project open.
std::set<std::string> liveGuids; std::set<std::string> liveGuids;
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
model.reconcile(liveGuids); model.reconcile(liveGuids);
@@ -430,12 +112,9 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
"ReaSampler: activate " + "ReaSampler: activate " +
(targetMode ? targetMode->displayName : targetModeId) + " view"; (targetMode ? targetMode->displayName : targetModeId) + " view";
int undoMask = kApplyUndoMask;
bool laneModeChanged = false; bool laneModeChanged = false;
bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint
std::vector<std::string> refusedParkNames; std::vector<std::string> exposedNames; // named while handles are fresh, reported after the block
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
Undo_BeginBlock2(project); Undo_BeginBlock2(project);
{ {
@@ -448,55 +127,36 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
clearTrackSolos(handleByGuid, outgoingSolo); clearTrackSolos(handleByGuid, outgoingSolo);
} }
// PARK / RESTORE: view_fx_park owns one track's whole move, flags and // PARK / RESTORE: view_park owns one track's whole move. Flags only — the
// per-FX offline together. A refusal is named HERE, while the handle is // parked chain stays online until the reclaimer reaches it.
// fresh — never held past Undo_EndBlock2/TrackList_AdjustWindows.
for (const TrackPlan& tp : plan.park) { for (const TrackPlan& tp : plan.park) {
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid); MediaTrack* tr = resolve(trackByGuid, guid);
// Re-validated per track, not once at resolve: offlining a plugin is if (!tr) continue; // stale/deleted GUID — prune
// ASSUMED to pump the message loop, so a track deleted during an const TrackParkResult r = parkTrack(model, guid, tr, tp.flags);
// 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.wrote) wrote = true;
if (r.fxMoved) undoMask |= UNDO_STATE_FX; // 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) { for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue; if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid); MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune if (!tr) continue; // stale/deleted GUID — prune
if (restoreTrack(model, guid, tr, tp.flags)) wrote = true;
const std::vector<FxOfflineOp> 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; }
} }
// The FX writes above are the only thing here assumed to pump the message // MANAGED LANES: the active mode's lane plays+shows, every other managed lane
// loop, so this is the one point a handle taken before them can have died. // is silenced+hidden. Empty on a D1-only project — byte-identical there.
// 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.
const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes); const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes);
laneModeChanged = lanes.freeMode; laneModeChanged = lanes.freeMode;
if (lanes.wrote) wrote = true; if (lanes.wrote) wrote = true;
// PARENT VISIBILITY (never parked): recomputed every toggle, never // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded.
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent. // Only the two visibility flags — never mainSend/FX on a parent.
std::set<std::string> visible = model.visibleTracks(tree, targetModeId); std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
for (const FolderNode& node : tree.nodes) { for (const FolderNode& node : tree.nodes) {
if (!node.isParent) continue; if (!node.isParent) continue;
@@ -511,9 +171,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// Replay + consume, before the single relayout below picks the change up. // Replay + consume, before the single relayout below picks the change up.
// Dropped against `visible` (computed above for parent visibility), not the // Dropped against `visible` (computed above for parent visibility), not the
// park plan: a folder parent can be hidden without being parked, and a // park plan: a folder parent can be hidden without being parked, and a hidden
// hidden track must not receive a replayed solo it carries no visible // track must not receive a replayed solo it carries no visible control to undo.
// control to undo.
if (realSwitch) { if (realSwitch) {
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) { if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
@@ -523,105 +182,26 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
} }
// Force REAPER to rebuild the TCP/MCP now rather than on the next user // Force REAPER to rebuild the TCP/MCP now rather than on the next user
// interaction — AdjustWindows(false) is the full relayout tracks appearing // interaction — AdjustWindows(false) is the full relayout tracks appearing and
// and disappearing owes. // disappearing owes.
TrackList_AdjustWindows(false); TrackList_AdjustWindows(false);
UpdateArrange(); UpdateArrange();
// UpdateTimeline() is owed only when a track was actually toggled into // UpdateTimeline() is owed only when a track was actually toggled into fixed lanes
// fixed lanes this apply (SDK requirement for I_FREEMODE changes). // this apply (SDK requirement for I_FREEMODE changes).
if (laneModeChanged) UpdateTimeline(); if (laneModeChanged) UpdateTimeline();
const bool mint = applyMintsUndoPoint(realSwitch, wrote); const bool mint = applyMintsUndoPoint(realSwitch, wrote);
Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? undoMask : 0); Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0);
// After the undo block and the UI hold: a report is not a project write, and // After the undo block: an EXPOSE report is a console message, not a project
// it must not join what a Ctrl-Z rolls back. // write, and must not join what a Ctrl-Z rolls back.
reportRefusedParks(project, refusedParkNames); reportExposedTracks(project, exposedNames);
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; return true;
} }
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
TrackHandles handleByGuid;
std::vector<TrackFolderEntry> 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<LaneTrack> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
const std::string name = laneName(tr, lane);
std::optional<std::string> key = managedLaneKey(name);
if (!key) continue; // manual/unnamed lane — leave off the index
std::optional<std::string> 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 } // namespace reasampler
+31 -42
View File
@@ -1,10 +1,9 @@
#pragma once #pragma once
// REAPER-facing shell of Design View (D2): reads the live folder tree, runs // REAPER-facing shell of Design View: reads the live folder tree, runs
// ViewModeModel's pure planner, and applies the resulting flag / per-FX / // ViewModeModel's pure planner, and applies the resulting flag / lane / parent
// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: // writes under ONE undo block. See src/shell/view/CLAUDE.md for the enforced
// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for // invariants (never touch master/mute, solo cached per mode and never lost,
// the enforced invariants (never touch master/mute, solo cached per mode and // record-based restore).
// never lost, snapshot-based restore).
#include <string> #include <string>
@@ -25,51 +24,41 @@ bool transportBlocksModeSwitch(ReaProject* proj);
// Does one applyMode leave an undo point behind? // Does one applyMode leave an undo point behind?
// //
// A REAPPLY that wrote nothing must not: the load-tick reapply over a project // A REAPPLY that wrote nothing must not: the load-tick reapply over a project saved
// saved fully parked re-plans a park for every inactive leaf and finds every // fully parked re-plans a park for every inactive leaf and finds every flag already
// flag and every FX already where it wants them, so a point there would mean // where it wants them, so a point there would mean opening a project costs an undo
// opening a project costs an undo step. `Undo_EndBlock2(proj, "", 0)` is the // step. `Undo_EndBlock2(proj, "", 0)` is the discard form (mintManagedLanes' no-op
// discard form (mintManagedLanes' no-op path uses the same idiom). // path and the reconcile pass use the same idiom).
// //
// A real switch mints regardless, fail-safe: it is an explicitly fired action // A real switch mints regardless, fail-safe: it is an explicitly fired action and has
// and has to stay undoable even in the degenerate case where the plan found // to stay undoable even in the degenerate case where the plan found nothing to write.
// nothing to write. Note what applyMode CANNOT see when it decides — the // Note what applyMode CANNOT see when it decides — the `view_state` ext-state write
// `view_state` ext-state write lands in persistViewState, after this block has // lands in persistViewState, after this block has closed (src/shell/view/CLAUDE.md's
// closed (src/shell/view/CLAUDE.md's Gotchas). // Gotchas).
inline bool applyMintsUndoPoint(bool realSwitch, bool wroteAnything) { inline bool applyMintsUndoPoint(bool realSwitch, bool wroteAnything) {
return realSwitch || wroteAnything; return realSwitch || wroteAnything;
} }
// True while an applyMode call is on the stack. applyMode fails closed when re-entered // True while an applyMode call is on the stack. applyMode fails closed when
// (its FX writes are assumed to pump the message loop, so a timer tick or an action can // re-entered, and this lets a caller with no fallback test BEFORE it spends a
// land inside one), 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. // one-shot signal on a call that would be refused — see main.cpp's load glue.
bool modeApplyInProgress(); bool modeApplyInProgress();
// Snapshots each about-to-park track's flags into `model`, caches/clears the // Captures each about-to-park track's prior flag values into `model`, caches/clears
// outgoing mode's solo state and replays the incoming mode's, runs planToggle, // 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 // applies the park/restore flag writes plus managed-lane and parent-visibility
// parent visibility flags, then sets the active mode. ONE switch is ONE undo // writes, then sets the active mode. ONE switch is ONE undo point; everything it
// point; everything it moved rolls back in a single Ctrl-Z. Returns false (no // moved rolls back in a single Ctrl-Z.
// mutation) if `targetModeId` isn't registered, if this is a real switch //
// (target != active) while the transport is running, if it is re-entered // 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 == // (modeApplyInProgress), or if the project does not validate. A reapply (target ==
// active) is never gated and never touches solo. `proj` == nullptr means the // active) is never gated and never touches solo. `proj` == nullptr means the current
// current project, resolved ONCE and used for every call this apply makes. // project, resolved ONCE and used for every call this apply makes.
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); 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 } // namespace reasampler
-229
View File
@@ -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 <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <string>
#include <vector>
#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<std::string> 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<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
return guids;
}
// 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<int>(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<std::string> guids = liveFxGuids(tr);
snap.fxOffline.reserve(guids.size());
for (std::size_t fx = 0; fx < guids.size(); ++fx)
snap.fxOffline.push_back(
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
return snap;
}
// Returns whether it wrote; raises `fxMoved` when a flag in the FX domain moved.
bool applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags, bool& fxMoved) {
bool wrote = false;
for (const TrackFlagOp& op : flags) {
if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(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<char> buf(1024, '\0');
if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast<int>(buf.size())))
return "(name unavailable)";
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
}
ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot,
const std::vector<TrackFlagOp>& parkFlags) {
if (haveSnapshot) return decidePark(true, false); // second term unread — skip the reads
const bool flagsRead = parkFlagsAlreadyApplied(parkFlags, [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
});
return decidePark(false, chainReadsParked(flagsRead, anyFxOffline(tr)));
}
void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& 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<TrackFlagOp>& 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<FxOfflineOp>& fxOps,
const std::vector<TrackFlagOp>& 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
-185
View File
@@ -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 <string>
#include <vector>
#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 <class ReadFlag>
bool parkFlagsAlreadyApplied(const std::vector<TrackFlagOp>& 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<TrackFlagOp>& 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<std::string>& 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<std::string>& last,
const std::vector<std::string>& 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<std::string>& 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<TrackFlagOp>& 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<FxOfflineOp>& fxOps,
const std::vector<TrackFlagOp>& 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
+344
View File
@@ -0,0 +1,344 @@
// 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 <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#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;
// 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<double>(kLanesDisplayAsNormal));
const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
static_cast<double>(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<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
std::map<std::string, int> byKey;
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
std::optional<std::string> 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<double>(lanePlays));
}
// One pass, so the assign loop needs no per-item re-scan.
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> 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<std::string> 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<LaneTrack> readLaneTracks(const ViewModeModel& model,
const TrackHandles& handleByGuid) {
std::vector<LaneTrack> tracks;
tracks.reserve(handleByGuid.size());
for (const auto& [guid, tr] : handleByGuid) {
LaneTrack lt;
lt.trackGuid = guid;
const bool fixedLane =
static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
const int itemCount = CountTrackMediaItems(tr);
lt.items.reserve(static_cast<std::size_t>(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<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
if (current == laneOrdinal) return false; // already there — no-op
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(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<std::string, std::vector<const LaneMint*>> mintsByTrack;
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
std::map<std::string, std::vector<const LaneAssign*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(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<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
std::map<std::string, int> 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<double>(laneCount));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
std::vector<char> 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<std::string, int> ordinals = managedLaneOrdinals(tr);
const std::map<std::string, MediaItem*> 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<LanePlayOp>& lanes) {
LaneApplyResult out;
if (lanes.empty()) return out;
std::map<std::string, std::vector<const LanePlayOp*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
static_cast<double>(kFreeModeFixedLanes));
applyTransparentLaneDisplay(tr);
out.freeMode = true;
out.wrote = true;
}
const std::map<std::string, int> 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<TrackFolderEntry> 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<LaneTrack> 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();
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kViewUndoMask);
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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
const std::string name = laneName(tr, lane);
std::optional<std::string> key = managedLaneKey(name);
if (!key) continue; // manual/unnamed lane — leave off the index
std::optional<std::string> 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
+43
View File
@@ -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 <vector>
#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<LanePlayOp>& 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
+182
View File
@@ -0,0 +1,182 @@
// 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 <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <string>
#include <vector>
#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
#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<int>(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<std::size_t>(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<TrackFlagOp>& flags) {
bool wrote = false;
for (const TrackFlagOp& op : flags)
if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(op.value)))
wrote = true;
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<TrackFlagOp>& 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<std::string> g_reportedExposed;
} // 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);
return true;
}
TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr,
const std::vector<TrackFlagOp>& 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<int>(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
// until the reclaimer gets to it, so a re-park lands here with the FX half
// 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.
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;
}
model.storeSnapshot(guid, snap);
result.wrote = true;
}
if (applyFlags(tr, flags)) result.wrote = true;
return result;
}
bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr,
const std::vector<TrackFlagOp>& flags) {
applyFlags(tr, flags);
model.releaseSnapshotFlags(guid);
return true;
}
std::string trackDisplayName(MediaTrack* tr) {
std::vector<char> buf(1024, '\0');
if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast<int>(buf.size())))
return "(name unavailable)";
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
}
void reportExposedTracks(ReaProject* proj, const std::vector<std::string>& 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
+172
View File
@@ -0,0 +1,172 @@
#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 <set>
#include <string>
#include <vector>
#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 {
// 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 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<FxOfflineState> unionFxOfflineOnRepark(
const std::vector<FxOfflineState>& prior, const std::vector<FxOfflineState>& live) {
std::vector<FxOfflineState> merged = prior;
std::set<std::string> 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;
}
// 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 <class ReadFlag>
bool chainReadsParked(const std::vector<TrackFlagOp>& 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,
// 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);
// 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);
// 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 ANY record for it (holdsSnapshotFlags false AND model.snapshot ==
// nullptr), then drive the flags.
//
// 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).
//
// 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<TrackFlagOp>& 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<std::string>& 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<std::string>& last,
const std::vector<std::string>& 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<std::string>& 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
// 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<TrackFlagOp>& flags);
} // namespace reasampler
+80
View File
@@ -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 <map>
#include <vector>
#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, 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 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<ReaProject*, view::AppliedState> g_applied;
} // namespace
void noteAppliedState(ReaProject* proj, const ViewModeModel& model) {
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
g_applied[project] = 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 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_applied[project] = result.newEntry;
if (result.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 : result.ops) {
MediaTrack* tr = resolve(trackByGuid, op.guid);
if (!tr) continue; // stale/deleted GUID — prune
if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(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
+93
View File
@@ -0,0 +1,93 @@
#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 <map>
#include <set>
#include <string>
#include <vector>
#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.
//
// 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<TrackFlagOp> planReconcile(const view::AppliedState& applied,
const view::AppliedState& reloaded) {
std::vector<TrackFlagOp> ops;
if (applied == reloaded) return ops; // no drift in what we applied — nothing to repair
std::set<std::string> 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;
}
// 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<TrackFlagOp> ops;
view::AppliedState newEntry;
};
inline ProjectReconcileResult planReconcilePerProject(
const std::map<ReaProject*, view::AppliedState>& 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<TrackFlagOp>{};
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.
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
+1 -9
View File
@@ -6,21 +6,13 @@
#include <map> #include <map>
#include <set> #include <set>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "core/view/solo_cache.h" #include "core/view/solo_cache.h"
#include "shell/view/view_tracks.h" // TrackHandles — the enumeration these writers pass over
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
class MediaTrack;
namespace reasampler { 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::pair<std::string, MediaTrack*>>;
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles); std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles);
// Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID // Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID
+47
View File
@@ -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 <string>
#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<view::TrackFolderEntry> readFolderEntries(ReaProject* proj,
TrackHandles& handleByGuid) {
std::vector<view::TrackFolderEntry> entries;
const int count = CountTracks(proj);
entries.reserve(static_cast<std::size_t>(count));
handleByGuid.reserve(static_cast<std::size_t>(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<int>(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
+51
View File
@@ -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 <string>
#include <unordered_map>
#include <utility>
#include <vector>
#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<std::pair<std::string, MediaTrack*>>;
// 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<std::string, MediaTrack*>;
// 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<view::TrackFolderEntry> 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
-197
View File
@@ -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 <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- 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<TrackFlagOp>{}, 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;
}
+49 -55
View File
@@ -814,13 +814,20 @@ using LiveFlags = std::map<std::string, int>;
// Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the // Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the
// fixed behavior vs. the original buggy (snapshot-every-park) one. // fixed behavior vs. the original buggy (snapshot-every-park) one.
// Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place, // Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place,
// exactly mirroring view.cpp's park then restore then setActiveMode ordering. // 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.
// //
// Guarded, the park mirrors decidePark's three ways: a held snapshot parks // `live[guid] == 0` doubles as this harness's single-flag stand-in for
// without recapturing, a chain that already reads parked with no snapshot is // parkTrack's real chainReadsParked (all four driven flags at their parked
// REFUSED (left exactly as found), and only a clean chain is snapshotted. The one // value) — the harness tracks one representative flag, not four, so "already at
// `live` flag stands in for the whole park-flag-plus-FX fold the shell reads — // its parked value" is the whole predicate here. `vm.snapshot(guid) == nullptr`
// the model half of the defect is identical either way. // 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, void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree,
const std::string& target, bool guard) { const std::string& target, bool guard) {
TogglePlan plan = vm.planToggle(tree, target); TogglePlan plan = vm.planToggle(tree, target);
@@ -830,8 +837,14 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre
if (tp.flags.empty()) continue; if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
if (guard) { if (guard) {
if (vm.snapshot(guid) == nullptr) { if (!vm.holdsSnapshotFlags(guid)) {
if (live[guid] == 0) continue; // reads parked, no snapshot — refuse 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; TrackSnapshot snap;
snap.showInTcp = live[guid]; snap.showInTcp = live[guid];
vm.storeSnapshot(guid, snap); vm.storeSnapshot(guid, snap);
@@ -844,13 +857,13 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre
live[guid] = 0; // park hides it 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) { for (const auto& tp : plan.restore) {
if (tp.flags.empty()) continue; if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
if (const TrackSnapshot* snap = vm.snapshot(guid)) if (const TrackSnapshot* snap = vm.snapshot(guid))
live[guid] = snap->showInTcp; // restore the captured visible flag live[guid] = snap->showInTcp; // restore the captured visible flag
vm.clearSnapshot(guid); vm.releaseSnapshotFlags(guid);
} }
vm.setActiveMode(target); vm.setActiveMode(target);
@@ -939,18 +952,19 @@ static void testNestedToggleSnapshotSurvivesRepark() {
// -- 9b. Reload strand: snapshots gone, live flags still parked --------------- // -- 9b. Reload strand: snapshots gone, live flags still parked ---------------
// //
// The pair the park refusal exists for, driven through the same harness. A model // A model replaced without the project rolling back with it — an unreadable
// replaced without the project rolling back with it — an unreadable view_state, a // view_state, a snapshot reconciled away while its track was deleted, a hand or
// snapshot reconciled away while its track was deleted, a hand or script edit — // script edit — leaves tracks sitting at parked values with nothing recording what
// leaves tracks sitting at parked values with nothing recording what they were // they were before. Daniel's ruling: hand the track back rather than either
// before. Snapshotting there commits the parked state as the user's, and the very // adopting the parked read as intent (silently wipes a hand repair on the very
// next toggle writes it back over whatever they have since fixed by hand. // 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 testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting() { static void testReloadedModelWithParkedTracksIsExposedNotAdopted() {
const FolderTree tree = nestedTree(); const FolderTree tree = nestedTree();
// GUARDED (fixed shell).
{
ViewModeModel vm; ViewModeModel vm;
vm.membership().tag("{L1}", kDesignModeId); vm.membership().tag("{L1}", kDesignModeId);
LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}};
@@ -960,49 +974,29 @@ static void testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting() {
CHECK(vm.snapshot("{L2}") != nullptr); CHECK(vm.snapshot("{L2}") != nullptr);
// The reload: the model comes back with no snapshots while the project's // 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("{L2}");
vm.clearSnapshot("{L3}"); vm.clearSnapshot("{L3}");
// The load tick reapplies the saved active mode. Both leaves are refused — // The load-tick reapply hits the no-record/park-shaped pair on both leaves.
// nothing written, and nothing false captured. // 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); simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
CHECK(live["{L2}"] == 1 && live["{L3}"] == 1);
CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr); 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. // Self-healing: the chain no longer reads park-shaped, so the very next apply
// Toggling back must leave it where the user put it: with no snapshot // of the same target takes the normal path -- captures the (now-visible)
// there is nothing to restore FROM, so the restore writes nothing. // truth and parks it correctly.
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); simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
CHECK(live["{L2}"] == 0 && vm.snapshot("{L2}") != nullptr); 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); simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true);
CHECK(live["{L2}"] == 1); CHECK(live["{L2}"] == 1 && live["{L3}"] == 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
}
} }
// =========================================================================== // ===========================================================================
@@ -2190,7 +2184,7 @@ int main() {
testUntaggedLeavesManagedByModeSystem(); testUntaggedLeavesManagedByModeSystem();
testTaggedLeafBehaviorUnchangedWithUntagged(); testTaggedLeafBehaviorUnchangedWithUntagged();
testNestedToggleSnapshotSurvivesRepark(); testNestedToggleSnapshotSurvivesRepark();
testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting(); testReloadedModelWithParkedTracksIsExposedNotAdopted();
testReconcilePrunesOrphanedSnapshots(); testReconcilePrunesOrphanedSnapshots();
testReconcileFullLiveSetIsNoOp(); testReconcileFullLiveSetIsNoOp();
testReconcileThenReparkLifecycleIntact(); testReconcileThenReparkLifecycleIntact();
+237
View File
@@ -0,0 +1,237 @@
// 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 <cstdio>
#include <set>
#include <string>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- 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<std::string> 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<std::string> 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));
}
// -- 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<FxOfflineState> prior{
FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-B}", 0}};
const std::vector<FxOfflineState> live{
FxOfflineState{"{FX-A}", 0}, FxOfflineState{"{FX-B}", 0}, FxOfflineState{"{FX-P}", 0}};
const std::vector<FxOfflineState> 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<FxOfflineState> prior{
FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-REMOVED}", 1}};
const std::vector<FxOfflineState> live{FxOfflineState{"{FX-A}", 0}};
const std::vector<FxOfflineState> 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);
}
// -- 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<TrackFlagOp>{}, 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<std::string> a{"Bass"};
const std::vector<std::string> 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();
testAParkPlanDrivesEveryDrivenFlagToZero();
testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite();
testAReapplyThatWroteSomethingLeavesAPoint();
testAReapplyThatWroteNothingLeavesNoPointAtAll();
testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped();
testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop();
testAChainSittingAtEveryParkedValueReadsAsParkShaped();
testOneFlagStillAtTheUsersValueMeansNotParkShaped();
testAnEmptyPlanProvesNothing();
testFxOfflineAloneIsNotFoldedIntoTheParkedRead();
testExposedTracksAreNamedAndEmptyListSaysNothing();
testExposedReportNeverUsesRefusalWording();
testShouldReportSkipsAnExactRepeatOnly();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+204
View File
@@ -0,0 +1,204 @@
// 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 <cstdio>
#include <map>
#include <string>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
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<TrackFlagOp>& 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<TrackFlagOp>& 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<TrackFlagOp> 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<TrackFlagOp> 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<TrackFlagOp> 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());
}
// -- 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<ReaProject*>(1);
ReaProject* const projB = reinterpret_cast<ReaProject*>(2);
ViewModeModel appliedA;
appliedA.setActiveMode(kDesignModeId);
appliedA.storeSnapshot("{A}", captured(1, 1, 1, 1));
const ViewModeModel appliedB; // untouched — B has nothing driven
std::map<ReaProject*, view::AppliedState> 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<ReaProject*>(3);
const std::map<ReaProject*, view::AppliedState> memo; // never noted
const ViewModeModel reloaded;
const ProjectReconcileResult result =
planReconcilePerProject(memo, projC, view::appliedState(reloaded));
CHECK(result.ops.empty());
}
int main() {
testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing();
testADriftedActiveModeAloneIsNotAReasonToDriveATrack();
testARecordTheReloadedModelLostIsHandedBackItsCapturedValues();
testARepairedFlagReturnsToItsCapturedValueNeverToOn();
testATrackNeitherStateRecordsIsNeverDrivenAtAll();
testASpentFlagHalfIsNotARecordToRepairFrom();
testASecondProjectNotedInBetweenDoesNotEraseTheFirstProjectsMemo();
testAProjectWithNoPriorMemoGetsNoRepair();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}