feat(view_mode_model): add D2 pure lane model (ownership index, lane ops, auto-tag)

Extends the REAPER-free view_mode_model for per-item mode membership via fixed
lanes: lane->mode C_LANEPLAYS mapping, managed-vs-manual lane-ownership index,
managed-only item-lane ops in the toggle planner, "which lanes may this toggle
touch" query, auto-tag decision (manual-lane items exempt), and JSON round-trip
of the lane index. D1 behavior and tests unchanged.
This commit is contained in:
2026-07-23 16:56:36 -04:00
parent 2ae4c420c5
commit 6e901c198c
3 changed files with 594 additions and 1 deletions
+178
View File
@@ -100,6 +100,109 @@ struct Membership {
}
};
// -- Lane ownership (Phase D2 / two-canvas item-level projection) -------------
//
// D2 extends the track-level projection to the ITEM level via REAPER fixed lanes
// (I_FREEMODE=2). On a track shared by two stances, each mode owns a fixed lane; a
// toggle shows/plays only the active mode's lane. This is the item-visibility analog
// of D1's track parking, and it carries the same load-bearing guarantee:
//
// THE TOOL DRIVES ONLY WHAT IT MINTED. A fixed-lane track is also REAPER's native
// comping surface — a user may keep their OWN manual lanes (comp takes, alternate
// reads). Mode operations touch ONLY managed lanes; manual lanes are never shown,
// hidden, silenced, or re-laned, and their C_LANEPLAYS stays exactly as set. This
// is the fixed-lane analog of "never touch B_MUTE/I_SOLO" and "never touch master".
//
// LANE IDENTITY IS AN OPAQUE, STABLE KEY SUPPLIED BY THE SHELL (boundary). The pure
// index keys a lane by (track GUID + a lane key string). The lane key is an OPAQUE
// identifier the shell provides; this model does NOT assume lane ordinals are stable
// and bakes in NO I_FIXEDLANE renumber/reorder assumptions. Whether the shell derives
// the key from a raw I_FIXEDLANE ordinal or a more durable identity — and how it keeps
// the index from going stale across lane reorder/renumber/deletion — is a Wave-2 SHELL
// design point (CONTEXT.md §Lane-identity fragility). The pure model's only contract:
// the same lane key denotes the same lane across calls.
// One lane's ownership: managed by a specific mode, or manual (user-minted, outside
// the mode system). `managedMode` present ⇒ managed by that mode id; absent ⇒ manual.
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). Ordered so it can key a map.
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. Managed lanes name their owning mode; manual
// lanes are user-minted and off-limits to every mode operation. GUID-keyed and
// portable, it rides in the "reasampler" view_state alongside the membership index.
// A lane ABSENT from the index has no recorded ownership — the model treats an absent
// lane as manual by default (the tool never minted it), so the managed-only guarantee
// holds even before the index is populated.
class LaneOwnershipIndex {
public:
// Records lane (trackGuid, laneKey) as MANAGED by `modeId`, replacing any prior
// ownership. Returns false if any argument is empty.
bool setManaged(const std::string& trackGuid, const std::string& laneKey,
const std::string& modeId);
// Records lane (trackGuid, laneKey) as MANUAL (user-minted), replacing any prior
// ownership. Returns false if trackGuid or laneKey is empty.
bool setManual(const std::string& trackGuid, const std::string& laneKey);
// Removes the lane from the index entirely (⇒ treated as manual-by-default again).
// Returns true if it was present.
bool remove(const std::string& trackGuid, const std::string& laneKey);
// The ownership for a lane, or nullptr if the lane has no recorded entry (⇒ manual
// by default). Invalidated by any mutating call.
const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const;
// True if the lane is recorded MANAGED (by any mode). A lane absent from the index
// is NOT managed (manual by default) — this is the load-bearing predicate the
// toggle planner and the "which lanes may this toggle touch" query gate on.
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
};
// The play/show state a managed lane takes for a given active mode, matching REAPER's
// item/track-side C_LANEPLAYS values (SDK: 0=lane silent+hidden, 1=lane plays
// exclusively). A managed lane owned by the ACTIVE mode plays (1); every other managed
// lane is silenced+hidden (0) — consistent with exclusive membership and D1's "a mode
// flip is a real change, not cosmetic." Exposed as a free function for direct testing.
// managedMode == activeMode ⇒ 1 (plays exclusively)
// otherwise ⇒ 0 (does not play; hidden + silent)
// The caller must only pass MANAGED lanes here; 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.
// Keyed by track GUID string, never index (reorder-safe).
class MembershipIndex {
@@ -221,6 +324,23 @@ struct FxOfflineOp {
}
};
// One managed-lane play/show write the shell must apply. The shell translates this
// into the REAPER lane setters (track-side C_LANEPLAYS:N and, per item, I_FIXEDLANE /
// C_LANEPLAYS; B_FIXEDLANE_HIDDEN follows from the play state). `lanePlays` is a
// C_LANEPLAYS value: kLanePlaysExclusive when the active mode owns the lane,
// kLaneSilent otherwise. The pure model emits these for MANAGED lanes ONLY — never a
// manual lane (the fixed-lane analog of "never touch mute/solo"), 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 (parking contract); restore uses a snapshot's values.
// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count
@@ -239,6 +359,12 @@ struct TrackPlan {
struct TogglePlan {
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values
// D2 item-level projection: per managed lane, the C_LANEPLAYS state for the target
// mode (active mode's lane plays; every other managed lane silenced+hidden). MANAGED
// lanes ONLY — a manual lane never appears here. Empty when no managed lanes exist,
// so a D1-only project (no fixed lanes) produces an identical plan to before.
std::vector<LanePlayOp> lanes;
};
// -- The view mode model -----------------------------------------------------
@@ -255,6 +381,8 @@ public:
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_; }
const std::string& activeModeId() const { return activeModeId_; }
// Sets the active mode. Returns false (no change) if the id is not registered.
@@ -317,6 +445,16 @@ public:
// access to REAPER FX counts at plan time.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
// The managed-only "which lanes may this toggle touch" query: the set of lane refs
// a toggle is permitted to drive — MANAGED lanes ONLY, from the ownership index.
// Manual lanes are NEVER in the result, regardless of target mode. This is the pure,
// testable decision behind the load-bearing invariant; the shell reads live lane
// state and applies C_LANEPLAYS only to lanes this query returns. Independent of the
// folder tree (lane ownership is not a tree property) — the target mode does not
// filter the SET (every managed lane is touchable), only the play VALUE each takes
// (see planToggle / laneModeState).
std::set<LaneRef> lanesTouchedByToggle() const;
bool operator==(const ViewModeModel& o) const;
std::string serialize() const;
@@ -328,6 +466,7 @@ public:
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
};
@@ -341,6 +480,45 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// -- Auto-tag decision (Phase D2) --------------------------------------------
//
// New content — both new tracks and new items — is tagged to whatever mode is active
// when it is created; pre-existing content defaults to Arrange. The DECISION is pure:
// the Wave-2 shell detects new GUIDs by diffing project state on the panel timer and
// asks this function what to tag. Pre-existing content (a GUID the shell does not
// report as new) never reaches here and stays at its index state (Arrange by default).
//
// Manual-lane exemption: an item that landed in a MANUAL lane is off-limits to auto-tag
// — auto-tag governs normal timeline content, not hand-managed lanes. The shell marks
// such an item `onManualLane = true` (it knows the item's lane and consults the
// ownership index); the decision then emits NO tag for it. New tracks and new items on
// managed/no lane follow the active-mode rule.
// One new item the shell detected this poll. Its lane disposition decides exemption.
struct NewItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT from auto-tag (hand-managed lane)
};
// One membership write the auto-tag decision produced: tag `guid` into `modeId`. The
// shell applies it to the MembershipIndex (a new track/item joins the active mode).
struct AutoTag {
std::string guid;
std::string modeId;
bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; }
};
// The pure auto-tag decision: given the new track GUIDs and new items detected this
// poll plus the active mode, produce the membership writes. Every new track is tagged
// to `activeMode`; every new item is tagged to `activeMode` UNLESS it landed on a
// manual lane (exempt). An empty `activeMode` yields no tags (nothing to tag into).
// Empty GUIDs are skipped. The result is a plan the shell applies; this function
// mutates nothing.
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).