#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 #include #include #include #include #include #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& 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 modes_; // kept sorted by ordinal, then insertion }; // The membership record for one tagged leaf track, keyed externally by GUID. struct Membership { std::set 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 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& 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 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 modesOf(const std::string& guid) const; const std::map& 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 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 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 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 flags; std::vector 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 park; // inactive leaves -> parked (fixed zeros) std::vector 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 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& 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& 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 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 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 deserialize(const std::string& json); private: ModeRegistry modes_; MembershipIndex membership_; LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership std::string activeModeId_; // always a registered id std::map 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 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 autoTagNewContent(const std::vector& newTrackGuids, const std::vector& 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 planItemRetag(const std::vector& 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 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 splits; std::vector mints; // managed lanes to mint (name + ownership write) std::vector 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& 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