Files
reasampler/src/core/view/view_mode_model.h
T
daniel 5f6efb7cc3 Key Design View's parked FX-offline state to the FX's own GUID, not its slot
view_state v2 writes identities beside the v1 slot array, so a downgrade keeps
what it had. An FX gone at restore time is dropped and reported, never restored
onto whatever took its place.
2026-08-02 18:31:08 -04:00

527 lines
23 KiB
C++

#pragma once
// Pure core of the Design View feature — mirror of bank_model: mode registry,
// GUID-keyed membership, folder-tree-aware visibility, park/restore planner,
// and JSON round-trip. Folder structure is an INPUT (the D2 shell reads
// REAPER's I_FOLDERDEPTH); this model never fetches or stores REAPER's live
// tree. See src/core/view/CLAUDE.md for the settled invariants.
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/solo_cache.h"
namespace reasampler {
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
inline constexpr const char* kArrangeModeId = "arrange";
inline constexpr const char* kDesignModeId = "design";
// A display "stance" the user adopts. Modes are ordered by `ordinal` for tab order.
struct Mode {
std::string id; // stable, persisted; never reused for a different mode
std::string displayName;
int ordinal = 0; // tab order
bool operator==(const Mode& o) const;
};
// Ordered registry of modes. Arrange + Design are seeded on construction; ids
// are unique, adding a duplicate id is rejected.
class ModeRegistry {
public:
ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1)
bool add(const Mode& mode);
const Mode* query(const std::string& id) const;
bool contains(const std::string& id) const { return query(id) != nullptr; }
const std::vector<Mode>& all() const { return modes_; }
std::size_t size() const { return modes_.size(); }
bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; }
// Empty registry (no seed modes) for deserialization, so the parsed
// Arrange/Design don't collide with the default ctor's seeded ones.
static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); }
private:
struct EmptyTag {};
explicit ModeRegistry(EmptyTag) {} // no seed
std::vector<Mode> modes_; // kept sorted by ordinal, then insertion
};
// The membership record for one tagged leaf track, keyed externally by GUID.
struct Membership {
std::set<std::string> modeIds; // the mode(s) this leaf opted into
bool showBoth = false; // pinned visible + running in every mode
bool operator==(const Membership& o) const {
return modeIds == o.modeIds && showBoth == o.showBoth;
}
};
// Item-level (fixed-lane) lane ownership. Mode operations touch only managed
// lanes; manual lanes are the user's own comping lanes and stay untouched —
// the fixed-lane analog of never-touch-mute/solo. Lane identity is an opaque
// key the shell supplies; this model bakes in no I_FIXEDLANE ordinal assumption.
// One lane's ownership: managed by a specific mode, or manual (user-minted).
struct LaneOwnership {
std::optional<std::string> managedMode; // set ⇒ managed by this mode; unset ⇒ manual
bool isManaged() const { return managedMode.has_value(); }
bool isManual() const { return !managedMode.has_value(); }
bool operator==(const LaneOwnership& o) const { return managedMode == o.managedMode; }
};
// A lane's composite key: (track GUID, opaque lane key).
struct LaneRef {
std::string trackGuid;
std::string laneKey; // opaque, shell-supplied; not assumed to be a stable ordinal
bool operator<(const LaneRef& o) const {
if (trackGuid != o.trackGuid) return trackGuid < o.trackGuid;
return laneKey < o.laneKey;
}
bool operator==(const LaneRef& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// (track GUID, lane key) -> ownership, GUID-keyed and portable. A lane ABSENT
// from the index is treated as manual by default (never minted by the tool),
// so the managed-only guarantee holds even before the index is populated.
class LaneOwnershipIndex {
public:
bool setManaged(const std::string& trackGuid, const std::string& laneKey,
const std::string& modeId);
bool setManual(const std::string& trackGuid, const std::string& laneKey);
// Removes the lane entirely (⇒ manual-by-default again). Returns true if present.
bool remove(const std::string& trackGuid, const std::string& laneKey);
const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const;
// Load-bearing predicate the toggle planner gates on: absent ⇒ not managed.
bool isManaged(const std::string& trackGuid, const std::string& laneKey) const {
const LaneOwnership* o = query(trackGuid, laneKey);
return o && o->isManaged();
}
const std::map<LaneRef, LaneOwnership>& all() const { return entries_; }
std::size_t size() const { return entries_.size(); }
bool empty() const { return entries_.empty(); }
bool operator==(const LaneOwnershipIndex& o) const { return entries_ == o.entries_; }
private:
std::map<LaneRef, LaneOwnership> entries_; // (guid, laneKey) -> ownership
};
// C_LANEPLAYS value for a managed lane under the given active mode: the lane
// plays exclusively iff its owning mode is active, else silent+hidden. Callers
// must only pass MANAGED lanes; manual lanes never reach this decision.
inline constexpr int kLanePlaysExclusive = 1; // C_LANEPLAYS: plays exclusively
inline constexpr int kLaneSilent = 0; // C_LANEPLAYS: does not play (hidden+silent)
int laneModeState(const std::string& managedMode, const std::string& activeMode);
// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
class MembershipIndex {
public:
// Tags `guid` into `modeId`, replacing any prior mode set. Returns false if
// guid or modeId is empty.
bool tag(const std::string& guid, const std::string& modeId);
// Removes `guid` entirely (returns it to the Arrange default).
bool untag(const std::string& guid);
// Sets the show-both flag; creates an untagged (Arrange-default) entry if
// `guid` had none, so show-both alone is representable.
bool setShowBoth(const std::string& guid, bool showBoth);
// Installs a complete membership record verbatim, replacing any existing
// entry. Used by deserialization to rebuild a trusted entry without tag()'s
// single-mode clobbering.
bool restore(const std::string& guid, const Membership& membership);
const Membership* query(const std::string& guid) const;
bool isShowBoth(const std::string& guid) const {
const Membership* m = query(guid);
return m && m->showBoth;
}
// The mode ids `guid` belongs to. Empty for an untagged guid (⇒ Arrange).
std::set<std::string> modesOf(const std::string& guid) const;
const std::map<std::string, Membership>& all() const { return entries_; }
std::size_t size() const { return entries_.size(); }
bool empty() const { return entries_.empty(); }
bool operator==(const MembershipIndex& o) const { return entries_ == o.entries_; }
private:
std::map<std::string, Membership> entries_; // guid -> membership
};
// Folder tree: an INPUT the shell rebuilds from I_FOLDERDEPTH each call, never
// stored here. A parent is visible in a mode if it belongs by its own
// membership or any descendant leaf does, and is never parked. The master
// track is implicit (always visible, untouched) and is not a node here.
struct FolderNode {
std::string guid;
std::string parentGuid; // empty ⇒ top-level (child of master / project root)
bool isParent = false; // true if this node has descendant tracks (a folder)
};
// Arrange-view order; parentGuid links each node to its immediate parent folder.
struct FolderTree {
std::vector<FolderNode> nodes;
};
// The prior value of every tool-driven flag on one track, captured BEFORE
// parking — restore's source of truth. Ints, not bools, so a snapshot
// faithfully round-trips whatever REAPER reported (defensive against
// non-0/1 values).
struct TrackSnapshot {
int showInTcp = 0; // B_SHOWINTCP prior value
int showInMixer = 0; // B_SHOWINMIXER prior value
int mainSend = 0; // B_MAINSEND prior value
int fxEnable = 0; // I_FXEN prior value
// Prior per-FX offline state in capture-time slot order, keyed per fxKeying:
// by the FX's own identity (live capture), or by position (a snapshot lifted
// from a project saved before identity was recorded).
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
}
};
// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it
// is per-slot, variable length) — see TrackParkPlan::fxOffline.
enum class Flag {
ShowInTcp, // B_SHOWINTCP
ShowInMixer, // B_SHOWINMIXER
MainSend, // B_MAINSEND
FxEnable, // I_FXEN
};
// One scalar-flag write the shell must apply: SetMediaTrackInfo_Value(guid, flag, value).
struct TrackFlagOp {
std::string guid;
Flag flag = Flag::ShowInTcp;
int value = 0;
bool operator==(const TrackFlagOp& o) const {
return guid == o.guid && flag == o.flag && value == o.value;
}
};
// One managed-lane play/show write the shell must apply (translated into
// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes
// only — never a manual lane; enforced in planToggle and mirrored by
// lanesTouchedByToggle.
struct LanePlayOp {
std::string trackGuid;
std::string laneKey; // opaque, shell-supplied
int lanePlays = kLaneSilent;
bool operator==(const LanePlayOp& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && lanePlays == o.lanePlays;
}
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
};
// The plan for a toggle to a target mode. Parents and show-both leaves never
// appear (derived-visible, never parked — see visibleTracks). Untagged
// leaves DO appear: an untagged leaf is an Arrange member, so it parks in
// every non-Arrange mode and restores in Arrange.
struct TogglePlan {
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values
// Per managed lane, the C_LANEPLAYS state for the target mode. Managed
// lanes only. Empty when no fixed lanes exist, so a lane-free project
// produces an identical plan to before fixed-lane support.
std::vector<LanePlayOp> lanes;
};
// Owns the mode registry, membership index, active mode, and durable
// per-track snapshots (kept while parked so a save-while-parked project
// restores correctly). Visibility and the toggle plan are computed against a
// supplied FolderTree — the tree is never stored.
class ViewModeModel {
public:
ViewModeModel(); // Arrange + Design seeded; active mode = Arrange
ModeRegistry& modes() { return modes_; }
const ModeRegistry& modes() const { return modes_; }
MembershipIndex& membership() { return membership_; }
const MembershipIndex& membership() const { return membership_; }
LaneOwnershipIndex& lanes() { return lanes_; }
const LaneOwnershipIndex& lanes() const { return lanes_; }
view::SoloCache& soloCache() { return soloCache_; }
const view::SoloCache& soloCache() const { return soloCache_; }
const std::string& activeModeId() const { return activeModeId_; }
// Returns false (no change) if the id is not registered.
bool setActiveMode(const std::string& modeId);
// The shell calls store before it parks a track, so restore survives a save.
void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
void clearSnapshot(const std::string& guid);
const TrackSnapshot* snapshot(const std::string& guid) const;
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
// cache the same way (see SoloCache::reconcile). Returns the count of
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
//
// Snapshots are pruned, membership is not: a parked track's snapshot is
// dead weight once the track is deleted (can never restore; a reused GUID
// would drive an incorrect restore). Membership survives because REAPER's
// undo of a track delete restores the SAME GUID — dropping the tag on
// delete would lose it on undo. A never-restored track leaves only a
// dormant membership entry, which is a fine trade against losing tags on
// undo. Folder restructure is self-healing (tree rebuilt every toggle) and
// is not what this handles.
std::size_t reconcile(const std::set<std::string>& liveGuids);
// A leaf belongs if tagged into modeId, show-both, or untagged with modeId
// == Arrange. No parent derivation here — see visibleTracks for that.
bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const;
// Tree-aware visible set: active leaves, show-both leaves, and every
// parent that belongs to the mode itself or has a visible descendant.
// Untagged nodes count as Arrange. Stale tree GUIDs are tolerated; the
// master is not represented (always visible, untouched).
std::set<std::string> visibleTracks(const FolderTree& tree,
const std::string& modeId) const;
// Enumerates every leaf in `tree`; a leaf inactive in `targetMode` is
// parked (fixed zeros), one becoming active with a stored snapshot is
// restored from it. Parents and show-both leaves are never parked.
// Untagged leaves are Arrange members and park/restore accordingly. Tree
// membership is the enumeration source, so stale membership GUIDs absent
// from the tree are ignored.
//
// Park plans here carry an empty fxOffline vector — the D2 shell expands
// per-FX offline writes via TrackFX_GetCount (not available to the pure model).
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
// Managed lanes only, from the ownership index — the set a toggle may
// drive. Independent of the folder tree (lane ownership isn't a tree
// property); the target mode decides each lane's play VALUE, not the set.
std::set<LaneRef> lanesTouchedByToggle() const;
bool operator==(const ViewModeModel& o) const;
std::string serialize() const;
// std::nullopt on malformed input. deserialize(serialize(x)) == x on success.
static std::optional<ViewModeModel> deserialize(const std::string& json);
private:
ModeRegistry modes_;
MembershipIndex membership_;
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
std::string activeModeId_; // always a registered id
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
};
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// Restore plan for one leaf from its snapshot — every flag to its captured
// value, never a default.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// Auto-tag decision: new content takes the active mode at creation; the
// Wave-2 shell diffs GUIDs on the panel timer and asks this what to tag.
// Pre-existing content never reaches here.
//
// Manual-lane exemption: an item landing on a MANUAL lane is off-limits.
//
// Adoption (strand fix): a new item on a track that already carries
// pre-existing content adopts that content's single mode rather than blindly
// taking the active mode — otherwise the track would go multi-mode, get
// lane-split, and strand the pre-existing (previously visible) items on a
// silenced lane with no user intent. Falls back to the active mode only when
// the track has no pre-existing managed-eligible content, or that content
// already spans multiple modes (an existing deliberate split).
// One new item the shell detected this poll.
struct NewItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT from auto-tag
// Distinct modes the pre-existing (not-new-this-tick) content on this
// item's track resolves to. Empty ⇒ take active mode. Exactly one ⇒
// adopt it. More than one ⇒ already a deliberate split, take active mode.
std::set<std::string> trackModes;
};
// One membership write: tag `guid` into `modeId`.
struct AutoTag {
std::string guid;
std::string modeId;
bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; }
};
// Every new track is tagged to `activeMode`. Every new item is tagged unless
// exempt (manual lane); its target is the adopted single mode of its track's
// pre-existing content, else `activeMode`. Empty `activeMode` yields no tags.
// Empty GUIDs are skipped. Mutates nothing.
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// Item-level mode-move decision (bindable "Move selected items -> mode"
// actions): which selected items to retag, and to what. Manual-lane items
// (shell-reported `onManualLane`) are exempt — never retagged, never re-laned,
// upholding the managed-lanes-only invariant under an explicit user action too.
// One selected item the shell reports for the retag decision.
struct RetagItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT
};
// One membership op: `untag` removes the item (Arrange default); otherwise
// tags it into `modeId`.
struct ItemRetagOp {
std::string guid;
bool untag = false; // true ⇒ untag; false ⇒ tag into modeId
std::string modeId; // the target mode when !untag (empty when untag)
bool operator==(const ItemRetagOp& o) const {
return guid == o.guid && untag == o.untag && modeId == o.modeId;
}
};
// Empty `targetMode` means untag (Move -> Arrange and Untag collapse to the
// same act, mirroring the track-level doUntag). Manual-lane and empty-GUID
// items are skipped. Mutates nothing.
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode);
// Lane-minting decision (D2 Wave 3): once a track is visible in more than one
// mode while carrying its own media, whole-track parking can no longer keep
// stances separate, so it drops to fixed lanes — one managed lane per
// involved mode, each item assigned to its mode's lane.
//
// "Visible in more than one mode" has two independent triggers, either
// splits the track: (a) the track's own items span >= 2 modes, or (b) the
// track is a content-bearing folder derived-visible in >= 2 modes
// (visibleTracks) even though its own item is single-mode — the folder case
// a naive own-item-span check would miss.
//
// Show-both tracks are skipped outright (never force-split — the point of
// show-both is staying audible everywhere). Manual-lane items are exempt.
// Lanes are minted LAZILY — only for modes the track's own items actually
// occupy, never an empty reserved lane for a merely-derived-visible mode;
// confinement still holds because an absent lane never plays.
//
// Idempotent: re-reporting an already-split track yields the same mints and
// assignments, so re-running detection does not thrash the project or undo
// history.
// One item the shell reports for the minting decision.
struct LaneItem {
std::string guid;
std::string modeId; // the mode this item's content belongs to
bool onManualLane = false; // true ⇒ EXEMPT (user's hand-managed lane)
};
// One track the shell reports: its GUID plus the items on it.
struct LaneTrack {
std::string trackGuid;
std::vector<LaneItem> items;
};
// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the
// durable key `laneKey` currently occupies). Only managed-eligible items appear.
struct LaneAssign {
std::string itemGuid;
std::string trackGuid;
std::string laneKey; // durable managed-lane key (laneNameForMode(modeId))
bool operator==(const LaneAssign& o) const {
return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// One managed lane the shell must mint: its durable key (== the P_LANENAME to
// stamp) and the mode that owns it (an ownership-index write).
struct LaneMint {
std::string trackGuid;
std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp
std::string modeId; // the owning mode (ownership-index managed-for-mode write)
bool operator==(const LaneMint& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
}
};
// The complete plan; empty when no track needs splitting (D1 behavior
// unchanged). The shell wraps application in one Undo block (visible
// structural mutation).
struct LaneMintPlan {
// Tracks to switch into fixed-lane mode (I_FREEMODE=2, I_NUMFIXEDLANES >=
// laneCount). Idempotent — an already-split track still appears, but the
// shell's ensure is then a no-op.
struct TrackSplit {
std::string trackGuid;
int laneCount = 0; // number of managed lanes this track needs
};
std::vector<TrackSplit> splits;
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
std::vector<LaneAssign> assigns; // item→managed-lane assignments
bool empty() const {
return splits.empty() && mints.empty() && assigns.empty();
}
};
// `model` supplies membership + show-both state; `tree` supplies folder
// structure for the derived-visibility trigger. Items with an empty GUID or
// modeId are skipped (defensive). Mutates nothing.
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
const std::vector<LaneTrack>& tracks);
// Next mode id in ordinal order, cycling past `currentModeId` and wrapping
// after the last. Empty registry -> "". currentModeId not present -> the
// first mode's id. Free function (not a model member) so it is testable
// against a bare ModeRegistry.
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId);
} // namespace reasampler