Merge dev (Phase D1) into p1-w9-bank-panel

# Conflicts:
#	CMakeLists.txt
This commit is contained in:
2026-07-22 21:07:59 -04:00
9 changed files with 1486 additions and 30 deletions
+3 -1
View File
@@ -22,13 +22,14 @@ Vendors two submodules (see `.gitmodules`):
cmake --build build
ctest --test-dir build
Four targets:
Five targets:
| Target | Kind | Purpose |
|---|---|---|
| `bank_model_tests` | executable | Pure unit tests for `bank_model` — no REAPER, no DAW. |
| `peaks_tests` | executable | Pure unit tests for `peaks` — no REAPER, no DAW. |
| `capture_paths_tests` | executable | Pure unit tests for `capture_paths` — no REAPER, no DAW. |
| `view_mode_model_tests` | executable | Pure unit tests for `view_mode_model` — no REAPER, no DAW. |
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
### macOS / Linux: SWELL dialog resources
@@ -48,6 +49,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
**Pure core (no REAPER types, unit-testable outside the DAW):**
- `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
- `peaks` — waveform min/max bin computation from raw PCM. Fed a known signal, asserts envelope. Does not depend on REAPER's peak API.
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. Mirror of `bank_model` for the Design View phase.
**REAPER-facing shells:**
- `capture``ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
+14 -1
View File
@@ -45,6 +45,15 @@ target_include_directories(capture_paths PUBLIC src)
add_library(bank_grid STATIC src/bank_grid.cpp)
target_include_directories(bank_grid PUBLIC src)
# ---------------------------------------------------------------------------
# 2d) Pure view_mode_model library — NO REAPER, NO SWELL. The Design View heart
# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware
# visibility derivation + parking/restore planner + JSON round-trip. Mirror of
# bank_model; the folder tree is an INPUT supplied by the D2 shell.
# ---------------------------------------------------------------------------
add_library(view_mode_model STATIC src/view_mode_model.cpp)
target_include_directories(view_mode_model PUBLIC src)
# ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER).
# ---------------------------------------------------------------------------
@@ -65,6 +74,10 @@ add_executable(bank_grid_tests tests/test_bank_grid.cpp)
target_link_libraries(bank_grid_tests PRIVATE bank_grid)
add_test(NAME bank_grid_tests COMMAND bank_grid_tests)
add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
# ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# ---------------------------------------------------------------------------
@@ -86,7 +99,7 @@ add_library(reaper_reasampler MODULE
src/bank_panel.cpp
${LICE_SRC}
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+26
View File
@@ -115,3 +115,29 @@ folder still resolves the bank).
- Project identity: keyed off a **minted GUID** stored in ext state (REAPER exposes no native per-project GUID), not the raw `ReaProject*` — a recycled pointer cannot misread a project switch as a Save-As.
- Save-As: **copy** semantics (Daniel's decision) — the `reasampler_bank/` folder is copied under the new `.rpp`; the old project's bank stays intact. Every ext-state write calls `MarkProjectDirty` so captures/GUID changes flush on the normal save.
- Known limitation (narrow, accepted): if a user does Save-As, closes the copy *without saving*, then reopens that copy to a *different* folder while the original is also open, identities can collide. Force-saving after Save-As would close the hole but was rejected as violating non-destructive.
---
## D1 — view_mode_model (pure)
**Goal:** REAPER-free mode registry + membership index + folder-tree-aware
visibility derivation + parking/restore planner + JSON round-trip. The heart of the
phase; mirror of `bank_model`. CONTEXT.md §Design View (Module architecture — pure).
**Verify:** CTest green. N-mode model (not a boolean); Arrange + Design seeded.
Restore-planner round-trip (snapshot → park → restore) returns every driven flag to
its captured value. Parent-derivation correct against a supplied folder tree.
JSON round-trip lossless across modes + membership + show-both + snapshots + active
mode.
- [x] Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded;
add/query more modes (prove N-mode, not binary).
- [x] Membership index: `GUID → { mode ids }` + per-track show-both flag;
add / remove / retag / query; untagged = Arrange.
- [x] Folder-tree-aware visibility derivation: given a supplied parent↔child tree +
active mode, compute the visible set (active leaves, derived-visible parents,
show-both leaves, master always in).
- [x] Parking/restore planner: emit exact (track, flag, value) op-lists for park and
restore from active mode + snapshot record.
- [x] JSON round-trip: modes + membership + show-both + snapshots + active mode.
- [x] Tests: N-mode add/query; parent follows tagged leaf (multi-mode parent);
restore-round-trip returns snapshot values (never hardcoded "on"); show-both leaf
never parked; unknown/stale GUID tolerated; JSON lossless.
+2 -2
View File
@@ -239,7 +239,7 @@ the CPU reclaim; surface it at the toggle affordance (tooltip).
## Module architecture (preserve the pure/shell split)
Pure (no REAPER types, unit-tested — the mirror of `bank_model`):
- `view_model` — mode registry (id/name/ordinal; Arrange + Design seeded);
- `view_mode_model` — mode registry (id/name/ordinal; Arrange + Design seeded);
membership index (`track GUID → { mode ids }` + per-track show-both flag; add /
remove / retag / query); **folder-tree-aware** visibility derivation (given the
current parent↔child tree supplied by the shell + the active mode, compute the
@@ -250,7 +250,7 @@ Pure (no REAPER types, unit-tested — the mirror of `bank_model`):
REAPER-facing:
- `view` shell — reads `I_FOLDERDEPTH` across the track list to build the
parent↔child tree and feeds it to `view_model`; applies the planner's operations
parent↔child tree and feeds it to `view_mode_model`; applies the planner's operations
via `SetMediaTrackInfo_Value` (`B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` /
`I_FXEN`) and `TrackFX_GetCount` + per-FX `TrackFX_SetOffline`; snapshots prior
flag values before parking; resolves GUIDs via
+1 -25
View File
@@ -127,30 +127,6 @@ landed milestone.
> spec: **CONTEXT.md §Design View**. Product framing: `docs/product/design-view.md`.
> When a point lands, doc-keeper moves it to `COMPLETED.md`.
## D1 — view_model (pure)
**Goal:** REAPER-free mode registry + membership index + folder-tree-aware
visibility derivation + parking/restore planner + JSON round-trip. The heart of the
phase; mirror of `bank_model`. CONTEXT.md §Design View (Module architecture — pure).
**Verify:** CTest green. N-mode model (not a boolean); Arrange + Design seeded.
Restore-planner round-trip (snapshot → park → restore) returns every driven flag to
its captured value. Parent-derivation correct against a supplied folder tree.
JSON round-trip lossless across modes + membership + show-both + snapshots + active
mode.
- [ ] Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded;
add/query more modes (prove N-mode, not binary).
- [ ] Membership index: `GUID → { mode ids }` + per-track show-both flag;
add / remove / retag / query; untagged = Arrange.
- [ ] Folder-tree-aware visibility derivation: given a supplied parent↔child tree +
active mode, compute the visible set (active leaves, derived-visible parents,
show-both leaves, master always in).
- [ ] Parking/restore planner: emit exact (track, flag, value) op-lists for park and
restore from active mode + snapshot record.
- [ ] JSON round-trip: modes + membership + show-both + snapshots + active mode.
- [ ] Tests: N-mode add/query; parent follows tagged leaf (multi-mode parent);
restore-round-trip returns snapshot values (never hardcoded "on"); show-both leaf
never parked; unknown/stale GUID tolerated; JSON lossless.
## D2 — view shell (apply flags in the DAW)
**Goal:** Read the folder tree and drive REAPER flags per the planner.
CONTEXT.md §Design View (view shell, REAPER API surface).
@@ -159,7 +135,7 @@ CONTEXT.md §Design View (view shell, REAPER API surface).
active ones from snapshot. **Master untouched. `B_MUTE`/`I_SOLO` untouched.**
Untagged tracks untouched. Parents follow their tagged descendants.
- [ ] Build parent↔child tree from `I_FOLDERDEPTH`; feed to `view_model`.
- [ ] Build parent↔child tree from `I_FOLDERDEPTH`; feed to `view_mode_model`.
- [ ] Snapshot prior flag values (`GetMediaTrackInfo_Value`) before parking.
- [ ] Apply park/restore ops (`SetMediaTrackInfo_Value` for the four flags;
`TrackFX_GetCount` + per-FX `TrackFX_SetOffline`). Verify flag names/signatures.
+1 -1
View File
@@ -273,7 +273,7 @@ index, and is togglable per selection.
Mirrors the capture pillar's split exactly.
**Pure `view_model` (REAPER-free, unit-tested — the mirror of `bank_model`):**
**Pure `view_mode_model` (REAPER-free, unit-tested — the mirror of `bank_model`):**
- Mode registry: ordered set of modes (id, display name, ordinal); Arrange + Design
seeded; add/query more.
- Membership index: `track GUID → { mode ids }` (normally one; multiple only via
+724
View File
@@ -0,0 +1,724 @@
#include "view_mode_model.h"
#include <algorithm>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
// view_mode_model implementation.
//
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief:
// keep the pure core dependency-free — no third-party JSON lib). A compact writer
// plus a recursive-descent parser covers the field set: the mode registry, the
// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX
// offline vector), and the active mode. Ints are emitted plainly; strings are
// escaped identically to bank_model so control chars and unicode survive.
namespace reasampler {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool Mode::operator==(const Mode& o) const {
return id == o.id && displayName == o.displayName && ordinal == o.ordinal;
}
// ---------------------------------------------------------------------------
// ModeRegistry
// ---------------------------------------------------------------------------
ModeRegistry::ModeRegistry() {
modes_.push_back(Mode{kArrangeModeId, "Arrange", 0});
modes_.push_back(Mode{kDesignModeId, "Design", 1});
}
bool ModeRegistry::add(const Mode& mode) {
if (mode.id.empty()) return false;
if (query(mode.id) != nullptr) return false; // ids are unique
modes_.push_back(mode);
// Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion
// order (the tie-break documented in the header).
std::stable_sort(modes_.begin(), modes_.end(),
[](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; });
return true;
}
const Mode* ModeRegistry::query(const std::string& id) const {
for (const auto& m : modes_)
if (m.id == id) return &m;
return nullptr;
}
// ---------------------------------------------------------------------------
// MembershipIndex
// ---------------------------------------------------------------------------
bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) {
if (guid.empty() || modeId.empty()) return false;
Membership& m = entries_[guid];
m.modeIds.clear(); // a leaf lives in exactly one mode (show-both aside)
m.modeIds.insert(modeId);
return true;
}
bool MembershipIndex::untag(const std::string& guid) {
return entries_.erase(guid) > 0;
}
bool MembershipIndex::setShowBoth(const std::string& guid, bool showBoth) {
if (guid.empty()) return false;
entries_[guid].showBoth = showBoth; // creates an Arrange-default entry if new
return true;
}
bool MembershipIndex::restore(const std::string& guid, const Membership& membership) {
if (guid.empty()) return false;
entries_[guid] = membership;
return true;
}
const Membership* MembershipIndex::query(const std::string& guid) const {
auto it = entries_.find(guid);
return it == entries_.end() ? nullptr : &it->second;
}
std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
const Membership* m = query(guid);
return m ? m->modeIds : std::set<std::string>{};
}
// ---------------------------------------------------------------------------
// planner helpers
// ---------------------------------------------------------------------------
TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
// Parking contract: hide both panels, out of the mix, FX bypassed, every FX
// offline. All fixed zeros — park never consults a snapshot.
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, 0},
{guid, Flag::ShowInMixer, 0},
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back({guid, i, true});
return p;
}
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
// Restore contract: every driven flag returns to its SNAPSHOTTED value — never
// a hardcoded "on"/default. A flag captured at 0 restores to 0.
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, snap.showInTcp},
{guid, Flag::ShowInMixer, snap.showInMixer},
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
return p;
}
// ---------------------------------------------------------------------------
// ViewModeModel
// ---------------------------------------------------------------------------
ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {}
bool ViewModeModel::setActiveMode(const std::string& modeId) {
if (!modes_.contains(modeId)) return false;
activeModeId_ = modeId;
return true;
}
void ViewModeModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) {
snapshots_[guid] = snap;
}
void ViewModeModel::clearSnapshot(const std::string& guid) {
snapshots_.erase(guid);
}
const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
auto it = snapshots_.find(guid);
return it == snapshots_.end() ? nullptr : &it->second;
}
bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
const Membership* m = membership_.query(guid);
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
if (m->showBoth) return true; // show-both ⇒ every mode
if (m->modeIds.empty()) return modeId == kArrangeModeId; // show-both-cleared, no mode
return m->modeIds.count(modeId) > 0;
}
std::set<std::string> ViewModeModel::visibleTracks(const FolderTree& tree,
const std::string& modeId) const {
std::set<std::string> visible;
// Pass 1: every leaf that belongs to the mode is visible.
for (const auto& node : tree.nodes) {
if (node.isParent) continue; // parents derived in pass 2
if (leafBelongsToMode(node.guid, modeId))
visible.insert(node.guid);
}
// Pass 2: a parent is visible if any descendant leaf is visible. Walk each
// visible leaf up its parent chain and mark ancestors. Parent chains are read
// from the supplied tree only (no REAPER access). A cycle-guard bounds the walk
// in case a malformed tree links a node to itself.
std::map<std::string, std::string> parentOf;
for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid;
// Snapshot the leaf-visible set so we don't re-walk parents we add mid-loop.
const std::vector<std::string> seeds(visible.begin(), visible.end());
for (const auto& leaf : seeds) {
auto it = parentOf.find(leaf);
std::size_t guard = 0;
while (it != parentOf.end() && !it->second.empty() && guard++ < parentOf.size()) {
const std::string& parent = it->second;
if (!visible.insert(parent).second) break; // already marked ⇒ chain done
it = parentOf.find(parent);
}
}
return visible;
}
TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& targetMode) const {
TogglePlan plan;
// Index the tree so we can classify each tagged GUID (leaf vs parent vs stale).
std::map<std::string, const FolderNode*> byGuid;
for (const auto& node : tree.nodes) byGuid[node.guid] = &node;
for (const auto& [guid, m] : membership_.all()) {
auto it = byGuid.find(guid);
if (it == byGuid.end()) continue; // stale GUID: prune-safe, ignore
if (it->second->isParent) continue; // parents are derived, never parked
if (m.showBoth) continue; // show-both leaves are never parked
const bool active = leafBelongsToMode(guid, targetMode);
if (active) {
// Returning to visibility: restore from snapshot if we have one. No
// snapshot ⇒ the track was never parked, nothing to restore.
if (const TrackSnapshot* snap = snapshot(guid))
plan.restore.push_back(makeRestorePlan(guid, *snap));
} else {
// Inactive tagged leaf ⇒ park. fxOffline is intentionally empty here:
// the D2 shell expands per-FX offline writes using TrackFX_GetCount.
// The pure model has no access to REAPER FX counts at plan time;
// makeParkPlan(guid, 0) emits only the scalar flags as a result.
plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
}
}
return plan;
}
bool ViewModeModel::operator==(const ViewModeModel& o) const {
return modes_ == o.modes_ && membership_ == o.membership_ &&
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
}
// ===========================================================================
// JSON — writer
// ===========================================================================
namespace {
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
std::string intToStr(int v) {
char buf[16];
std::snprintf(buf, sizeof(buf), "%d", v);
return buf;
}
void writeIntArray(std::string& out, const std::vector<int>& v) {
out += '[';
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) out += ',';
out += intToStr(v[i]);
}
out += ']';
}
class ObjWriter {
public:
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
~ObjWriter() { out_ += '}'; }
void keyRaw(const char* key, const std::string& rawValue) {
sep();
writeEscaped(out_, key);
out_ += ':';
out_ += rawValue;
}
void keyStr(const char* key, const std::string& value) {
sep();
writeEscaped(out_, key);
out_ += ':';
writeEscaped(out_, value);
}
void keyBegin(const char* key) {
sep();
writeEscaped(out_, key);
out_ += ':';
}
private:
void sep() { if (first_) first_ = false; else out_ += ','; }
std::string& out_;
bool first_ = true;
};
} // namespace
std::string ViewModeModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyStr("activeMode", activeModeId_);
// modes
root.keyBegin("modes");
out += '[';
{
const auto& all = modes_.all();
for (std::size_t i = 0; i < all.size(); ++i) {
if (i) out += ',';
ObjWriter m(out);
m.keyStr("id", all[i].id);
m.keyStr("displayName", all[i].displayName);
m.keyRaw("ordinal", intToStr(all[i].ordinal));
}
}
out += ']';
// membership: array of { guid, modes[], showBoth }
root.keyBegin("membership");
out += '[';
{
bool first = true;
for (const auto& [guid, mem] : membership_.all()) {
if (!first) out += ','; first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyBegin("modes");
out += '[';
{
bool mf = true;
for (const auto& id : mem.modeIds) {
if (!mf) out += ','; mf = false;
writeEscaped(out, id);
}
}
out += ']';
e.keyRaw("showBoth", mem.showBoth ? "true" : "false");
}
}
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
root.keyBegin("snapshots");
out += '[';
{
bool first = true;
for (const auto& [guid, snap] : snapshots_) {
if (!first) out += ','; first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
e.keyRaw("mainSend", intToStr(snap.mainSend));
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
e.keyBegin("fxOffline");
writeIntArray(out, snap.fxOffline);
}
}
out += ']';
} // root closes here (see bank_model note on NRVO + deferred close)
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; false on any malformed input, never UB)
// ===========================================================================
namespace {
class Parser {
public:
explicit Parser(const std::string& s) : s_(s) {}
bool parseModel(ViewModeModel& out);
private:
const std::string& s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
void skipWs() {
while (!eof()) {
char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
else break;
}
}
bool consume(char c) {
skipWs();
if (eof() || s_[pos_] != c) return false;
++pos_;
return true;
}
bool parseString(std::string& out);
bool parseRawScalar(std::string& out);
bool parseInt(int& out);
bool parseBool(bool& out);
bool parseKey(std::string& key);
bool skipValue();
bool parseModes(ModeRegistry& reg);
bool parseMembership(MembershipIndex& idx);
bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps);
bool parseIntArray(std::vector<int>& out);
};
bool Parser::parseString(std::string& out) {
skipWs();
if (eof() || s_[pos_] != '"') return false;
++pos_;
out.clear();
while (!eof()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (eof()) return false;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char h = s_[pos_++];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
else return false;
}
return true;
};
unsigned int hi = 0;
if (!readHex4(hi)) return false;
unsigned int codePoint = hi;
if (hi >= 0xD800 && hi <= 0xDBFF) {
if (pos_ + 6 > s_.size()) return false;
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
pos_ += 2;
unsigned int lo = 0;
if (!readHex4(lo)) return false;
if (lo < 0xDC00 || lo > 0xDFFF) return false;
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
return false;
}
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated
}
bool Parser::parseRawScalar(std::string& out) {
skipWs();
std::size_t start = pos_;
while (!eof()) {
char c = s_[pos_];
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
c == '\n' || c == '\r')
break;
++pos_;
}
if (pos_ == start) return false;
out.assign(s_, start, pos_ - start);
return true;
}
bool Parser::parseInt(int& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
const char* b = tok.c_str();
char* end = nullptr;
errno = 0;
long long v = std::strtoll(b, &end, 10);
if (end != b + tok.size()) return false;
if (errno == ERANGE) return false;
if (v < INT_MIN || v > INT_MAX) return false;
out = static_cast<int>(v);
return true;
}
bool Parser::parseBool(bool& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
if (tok == "true") { out = true; return true; }
if (tok == "false") { out = false; return true; }
return false;
}
bool Parser::parseKey(std::string& key) {
if (!parseString(key)) return false;
return consume(':');
}
bool Parser::skipValue() {
skipWs();
if (eof()) return false;
char c = s_[pos_];
if (c == '"') { std::string tmp; return parseString(tmp); }
if (c == '{' || c == '[') {
char open = c, close = (c == '{') ? '}' : ']';
++pos_;
int depth = 1;
while (!eof() && depth > 0) {
char d = s_[pos_];
if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; }
if (d == open) ++depth;
else if (d == close) --depth;
++pos_;
}
return depth == 0;
}
std::string tmp;
return parseRawScalar(tmp);
}
bool Parser::parseIntArray(std::vector<int>& out) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true;
do {
int v = 0;
if (!parseInt(v)) return false;
out.push_back(v);
} while (consume(','));
return consume(']');
}
// The registry starts seeded (Arrange + Design). Deserialization must reproduce the
// serialized set exactly, so we replace the seeded contents with the parsed ones —
// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as
// duplicates and the ordinals/names would not round-trip. We therefore parse into a
// fresh vector and swap. `reg` is passed empty (see parseModel).
bool Parser::parseModes(ModeRegistry& reg) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true; // empty array (unusual, but valid)
do {
if (!consume('{')) return false;
Mode m;
bool haveId = false;
do {
std::string k;
if (!parseKey(k)) return false;
if (k == "id") { if (!parseString(m.id)) return false; haveId = true; }
else if (k == "displayName") { if (!parseString(m.displayName)) return false; }
else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; }
else if (!skipValue()) return false;
} while (consume(','));
if (!consume('}')) return false;
if (!haveId || !reg.add(m)) return false; // malformed / duplicate id
} while (consume(','));
return consume(']');
}
bool Parser::parseMembership(MembershipIndex& idx) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true;
do {
if (!consume('{')) return false;
std::string guid;
Membership mem;
bool haveGuid = false;
do {
std::string k;
if (!parseKey(k)) return false;
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
else if (k == "modes") {
if (!consume('[')) return false;
skipWs();
if (!consume(']')) {
do {
std::string id;
if (!parseString(id)) return false;
mem.modeIds.insert(id);
} while (consume(','));
if (!consume(']')) return false;
}
}
else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; }
else if (!skipValue()) return false;
} while (consume(','));
if (!consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
// Install the entry verbatim (tag() would clear a multi-mode set and drop
// show-both). A serialized entry is trusted to already satisfy the model's
// invariants.
//
// Deliberate tolerance: we do NOT validate that membership modeIds reference
// registered modes, and we do not validate snapshot GUIDs against the index.
// Stale-GUID and stale-mode tolerance is a stated invariant of this model —
// a deserialized entry is treated as trusted data, not as live cross-checked
// state. Rejecting stale entries here would violate that invariant. The one
// exception is activeMode (validated below in parseModel): a persisted active
// mode that no longer exists has an immediate behavioral consequence, so it
// is caught and the parse is rejected.
if (!idx.restore(guid, mem)) return false;
} while (consume(','));
return consume(']');
}
bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true;
do {
if (!consume('{')) return false;
std::string guid;
TrackSnapshot snap;
bool haveGuid = false;
do {
std::string k;
if (!parseKey(k)) return false;
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; }
else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; }
else if (!skipValue()) return false;
} while (consume(','));
if (!consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap;
} while (consume(','));
return consume(']');
}
bool Parser::parseModel(ViewModeModel& out) {
if (!consume('{')) return false;
skipWs();
if (consume('}')) return true; // lenient empty root ⇒ default-seeded model
ModeRegistry reg; // seeded default; REPLACED if a modes array is present
bool haveModes = false;
std::string activeMode;
bool haveActive = false;
MembershipIndex membership;
std::map<std::string, TrackSnapshot> snaps;
do {
std::string key;
if (!parseKey(key)) return false;
if (key == "activeMode") {
if (!parseString(activeMode)) return false;
haveActive = true;
} else if (key == "modes") {
ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own
if (!parseModes(fresh)) return false;
reg = fresh;
haveModes = true;
} else if (key == "membership") {
if (!parseMembership(membership)) return false;
} else if (key == "snapshots") {
if (!parseSnapshots(snaps)) return false;
} else {
// Unknown keys and the "version" field are skipped here.
// "version" is serialized as a forward-compat placeholder — there is no
// active version gate yet; all persisted data is parsed the same way
// regardless of the value. A future gate would add a version branch here.
if (!skipValue()) return false;
}
} while (consume(','));
if (!consume('}')) return false;
skipWs();
if (!eof()) return false; // trailing garbage
if (haveModes) out.modes() = reg;
out.membership() = membership;
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
if (haveActive) {
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
}
return true;
}
} // namespace
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) {
ViewModeModel vm;
Parser p(json);
if (!p.parseModel(vm)) return std::nullopt;
return vm;
}
} // namespace reasampler
+316
View File
@@ -0,0 +1,316 @@
#pragma once
// view_mode_model — the pure core of the Design View feature, deliberately free of any
// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of
// bank_model: it owns the mode registry, the GUID-keyed membership index, the
// folder-tree-aware visibility derivation, the parking/restore planner, and the
// JSON round-trip of all of it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The folder structure is an INPUT
// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never
// fetches or stores REAPER's live tree — folder structure is REAPER's truth and
// changes underneath us, so it is passed in per query, not held.
//
// -- Representation decisions (design latitude exercised; invariants below) -----
//
// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange",
// ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the
// fallback home for every untagged leaf; structurally it is just another mode.
//
// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track
// show-both flag. Normally a leaf is in exactly one mode; multiple only via the
// parent-derivation rule (computed, not stored) or the show-both escape hatch.
// An untagged GUID is NOT in the index and belongs to Arrange by default.
//
// * The planner drives exactly four scalar flags (showInTcp, showInMixer,
// mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros
// (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops
// come entirely FROM a TrackSnapshot captured before parking — never a hardcoded
// default. This is where the restore-contract invariant lives and is tested.
//
// * The snapshot stores the full prior per-FX offline vector so a save-while-parked
// project round-trips and restores each FX to its exact prior offline state. The
// pure model does NOT need REAPER FX counts to plan a park (park offlines all N,
// which the shell expands from TrackFX_GetCount); it only needs them to restore,
// and it gets them from the snapshot it captured.
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
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. Add more
// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id
// is rejected.
class ModeRegistry {
public:
ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1)
// Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id.
bool add(const Mode& mode);
// Returns the mode with `id`, or nullptr. Invalidated by any mutating call.
const Mode* query(const std::string& id) const;
bool contains(const std::string& id) const { return query(id) != nullptr; }
// All modes in ordinal order (ties broken by insertion order).
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_; }
// An empty registry (no seed modes). Deserialization parses the persisted mode
// set into this and then owns it; the default ctor's seed would otherwise make
// the serialized Arrange/Design collide on add() and fail to round-trip.
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;
}
};
// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
// Keyed by track GUID string, never index (reorder-safe).
class MembershipIndex {
public:
// Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one
// mode; use showBoth for the cross-mode case). No-op-safe on repeated calls.
// Returns false if guid or modeId is empty.
bool tag(const std::string& guid, const std::string& modeId);
// Removes `guid` from the index entirely (returns it to the Arrange default).
// Returns true if it was present.
bool untag(const std::string& guid);
// Sets the show-both flag for `guid`. Tags the guid into no new mode; if the
// guid is untagged it is created with an empty mode set (Arrange default) so
// show-both alone is representable. Returns false if guid is empty.
bool setShowBoth(const std::string& guid, bool showBoth);
// Installs a complete membership record verbatim (multi-mode set + show-both),
// replacing any existing entry for `guid`. Used by deserialization to rebuild a
// trusted, already-valid entry without tag()'s single-mode clobbering. Returns
// false if guid is empty.
bool restore(const std::string& guid, const Membership& membership);
// Returns the membership for `guid`, or nullptr if untagged. Invalidated by any
// mutating call.
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 (INPUT, not stored) ----------------------------------------
//
// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility
// query. A node is a leaf or a parent; a parent is visible in every mode any of
// its descendant leaves belongs to, and is never parked. The master track is
// modeled implicitly (always visible, never touched) 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)
};
// A flat parent↔child description of the current track tree. Order is arrange-view
// order; parentGuid links each node to its immediate parent folder.
struct FolderTree {
std::vector<FolderNode> nodes;
};
// -- Snapshot + planner ------------------------------------------------------
// The prior value of every tool-driven flag on one track, captured BEFORE parking.
// Restore uses these values verbatim — the restore contract's source of truth.
// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell
// applies them without translation; 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, index = fx slot. Lets restore return each FX to
// exactly its captured offline value rather than a blanket "online".
std::vector<int> fxOffline;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline;
}
};
// 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 per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
std::string guid;
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
}
};
// 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
// (all -> offline); on restore, each slot back to its captured value.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
};
// The plan for a whole toggle to a target mode: which tracks to park, and which to
// restore from their snapshots. Parents and show-both leaves never appear here —
// they are derived-visible and never parked (visibility is answered separately by
// visibleTracks). Untagged tracks never appear either (the tool owns only what it
// tagged).
struct TogglePlan {
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values
};
// -- The view mode model -----------------------------------------------------
//
// Owns the mode registry, the membership index, the active mode, and the durable
// per-track snapshots (kept for tracks currently 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_; }
const std::string& activeModeId() const { return activeModeId_; }
// Sets the active mode. Returns false (no change) if the id is not registered.
bool setActiveMode(const std::string& modeId);
// Records / clears the pre-park snapshot for a track. The shell calls store
// before it parks a track; the model persists it 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_; }
// Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
// is show-both (belongs everywhere), or is untagged and modeId is Arrange (the
// default). Parent derivation is NOT applied here — this is the LEAF rule; use
// visibleTracks for the tree-aware answer.
bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const;
// The set of track GUIDs visible in `modeId`, tree-aware: active leaves,
// show-both leaves, and every parent with at least one descendant leaf in the
// mode. Untagged leaves count as Arrange. Stale GUIDs in the tree are tolerated.
// The master is not represented (always visible; the shell never touches it).
std::set<std::string> visibleTracks(const FolderTree& tree,
const std::string& modeId) const;
// Plans a toggle to `targetMode` against the current tree. Inactive tagged
// leaves (not show-both, not derived-visible-only) are parked with fixed zeros;
// leaves that become active AND have a stored snapshot are restored from it.
// Parents, show-both leaves, the master, and untagged tracks are never parked.
// Unknown/stale membership GUIDs absent from the tree are ignored (prune-safe).
//
// Note: park plans emitted here have an empty fxOffline vector. The D2 shell
// expands per-FX offline writes using TrackFX_GetCount — the pure model has no
// access to REAPER FX counts at plan time.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
bool operator==(const ViewModeModel& o) const;
std::string serialize() const;
// Parses a JSON string produced by serialize(). std::nullopt on malformed
// input. On success deserialize(serialize(x)) == x.
static std::optional<ViewModeModel> deserialize(const std::string& json);
private:
ModeRegistry modes_;
MembershipIndex membership_;
std::string activeModeId_; // always a registered id
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
};
// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed
// for the shell and for direct testing of the parking contract.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// Builds the restore plan for one leaf from its snapshot — every flag set to its
// captured value, never a default. Exposed for the shell and for testing the
// restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
} // namespace reasampler
+399
View File
@@ -0,0 +1,399 @@
// Standalone tests for reasampler::ViewModeModel — no REAPER, no test framework.
// Mirror of test_bank_model: iterate the hard logic outside the DAW.
//
// Covers (PLAN.md D1 test cases):
// 1. N-mode proven — >=3 modes, membership + derivation still correct.
// 2. Parent derivation — a folder with descendant leaves in different modes is
// visible in each of those modes.
// 3. Restore round-trip — snapshot -> park -> restore returns every driven flag to
// its captured value; includes the "flag already at 0 stays 0" (no default).
// 4. show-both leaf never appears in a park op-list and is visible in all modes.
// 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash).
// 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active.
// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract).
#include "../src/view_mode_model.h"
#include <algorithm>
#include <cstdio>
#include <string>
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)
// -- helpers -----------------------------------------------------------------
static bool visibleHas(const std::set<std::string>& v, const std::string& g) {
return v.count(g) > 0;
}
// Does any park TrackPlan in the plan target `guid`?
static bool parkTargets(const TogglePlan& plan, const std::string& guid) {
for (const auto& p : plan.park)
for (const auto& f : p.flags)
if (f.guid == guid) return true;
return false;
}
// Find the single restore plan for `guid`, or nullptr.
static const TrackPlan* restoreFor(const TogglePlan& plan, const std::string& guid) {
for (const auto& p : plan.restore)
if (!p.flags.empty() && p.flags.front().guid == guid) return &p;
return nullptr;
}
static int flagValue(const TrackPlan& p, Flag f) {
for (const auto& op : p.flags)
if (op.flag == f) return op.value;
return -999; // sentinel: flag absent
}
// -- 1. N-mode proven --------------------------------------------------------
static void testNModeRegistryAndMembership() {
ViewModeModel vm;
// Seeded: Arrange + Design.
CHECK(vm.modes().size() == 2);
CHECK(vm.modes().contains(kArrangeModeId));
CHECK(vm.modes().contains(kDesignModeId));
CHECK(vm.activeModeId() == kArrangeModeId);
// Add a third mode — proves N-mode, not boolean.
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
CHECK(vm.modes().size() == 3);
CHECK(vm.modes().contains("mixdown"));
// Duplicate id and empty id are rejected without mutation.
CHECK(!vm.modes().add(Mode{"mixdown", "Dup", 5}));
CHECK(!vm.modes().add(Mode{"", "Empty", 6}));
CHECK(vm.modes().size() == 3);
// Membership across three modes.
CHECK(vm.membership().tag("{A}", kArrangeModeId));
CHECK(vm.membership().tag("{D}", kDesignModeId));
CHECK(vm.membership().tag("{M}", "mixdown"));
// Leaf-belongs rule holds in each mode.
CHECK(vm.leafBelongsToMode("{D}", kDesignModeId));
CHECK(!vm.leafBelongsToMode("{D}", kArrangeModeId));
CHECK(vm.leafBelongsToMode("{M}", "mixdown"));
CHECK(!vm.leafBelongsToMode("{M}", kDesignModeId));
// Untagged leaf defaults to Arrange, and only Arrange.
CHECK(vm.leafBelongsToMode("{UNTAGGED}", kArrangeModeId));
CHECK(!vm.leafBelongsToMode("{UNTAGGED}", kDesignModeId));
// Retag moves the leaf (single-mode semantics).
CHECK(vm.membership().tag("{D}", "mixdown"));
CHECK(vm.leafBelongsToMode("{D}", "mixdown"));
CHECK(!vm.leafBelongsToMode("{D}", kDesignModeId));
// Untag returns to the Arrange default.
CHECK(vm.membership().untag("{D}"));
CHECK(vm.leafBelongsToMode("{D}", kArrangeModeId));
CHECK(!vm.membership().untag("{D}")); // second untag is a no-op
// setActiveMode rejects an unregistered id, accepts a registered one.
CHECK(!vm.setActiveMode("nope"));
CHECK(vm.activeModeId() == kArrangeModeId);
CHECK(vm.setActiveMode("mixdown"));
CHECK(vm.activeModeId() == "mixdown");
}
// -- 2. Parent derivation ----------------------------------------------------
static void testParentDerivationMultiMode() {
ViewModeModel vm;
// Folder {F} holds two leaves: {L1} in Arrange (untagged default), {L2} in Design.
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
tree.nodes.push_back(FolderNode{"{L1}", "{F}", false});
tree.nodes.push_back(FolderNode{"{L2}", "{F}", false});
vm.membership().tag("{L2}", kDesignModeId);
// {L1} stays untagged ⇒ Arrange.
auto arrange = vm.visibleTracks(tree, kArrangeModeId);
auto design = vm.visibleTracks(tree, kDesignModeId);
// The parent is visible in BOTH modes because it has a descendant in each.
CHECK(visibleHas(arrange, "{F}"));
CHECK(visibleHas(design, "{F}"));
// Leaves appear only in their own mode.
CHECK(visibleHas(arrange, "{L1}") && !visibleHas(arrange, "{L2}"));
CHECK(visibleHas(design, "{L2}") && !visibleHas(design, "{L1}"));
// Nested folder chain: grandparent {G} > parent {F2} > leaf {L3} (Design).
// The whole chain up to the root must be visible in Design.
FolderTree nested;
nested.nodes.push_back(FolderNode{"{G}", "", true});
nested.nodes.push_back(FolderNode{"{F2}", "{G}", true});
nested.nodes.push_back(FolderNode{"{L3}", "{F2}", false});
ViewModeModel vm2;
vm2.membership().tag("{L3}", kDesignModeId);
auto d2 = vm2.visibleTracks(nested, kDesignModeId);
CHECK(visibleHas(d2, "{L3}"));
CHECK(visibleHas(d2, "{F2}"));
CHECK(visibleHas(d2, "{G}"));
// In Arrange, none of the chain is visible (no Arrange leaf under it).
auto a2 = vm2.visibleTracks(nested, kArrangeModeId);
CHECK(!visibleHas(a2, "{L3}") && !visibleHas(a2, "{F2}") && !visibleHas(a2, "{G}"));
// A parent is NEVER parked, in either mode.
auto planD = vm.planToggle(tree, kDesignModeId);
auto planA = vm.planToggle(tree, kArrangeModeId);
CHECK(!parkTargets(planD, "{F}"));
CHECK(!parkTargets(planA, "{F}"));
}
// -- 3. Restore round-trip (the trust anchor) --------------------------------
static void testRestoreRoundTripSnapshotValues() {
// Direct planner check: park is fixed zeros; restore is snapshot verbatim.
TrackSnapshot snap;
snap.showInTcp = 1;
snap.showInMixer = 1;
snap.mainSend = 0; // user had it OUT of the mix for their own reason
snap.fxEnable = 1;
snap.fxOffline = {0, 1, 0}; // slot 1 was already offline before parking
TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3);
CHECK(flagValue(park, Flag::ShowInTcp) == 0);
CHECK(flagValue(park, Flag::ShowInMixer) == 0);
CHECK(flagValue(park, Flag::MainSend) == 0);
CHECK(flagValue(park, Flag::FxEnable) == 0);
CHECK(park.fxOffline.size() == 3);
for (const auto& op : park.fxOffline) CHECK(op.offline == true);
TrackPlan restore = makeRestorePlan("{T}", snap);
// Every flag returns to its CAPTURED value — not a hardcoded "on".
CHECK(flagValue(restore, Flag::ShowInTcp) == 1);
CHECK(flagValue(restore, Flag::ShowInMixer) == 1);
CHECK(flagValue(restore, Flag::MainSend) == 0); // the "already at 0 stays 0" case
CHECK(flagValue(restore, Flag::FxEnable) == 1);
CHECK(restore.fxOffline.size() == 3);
CHECK(restore.fxOffline[0].offline == false);
CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline
CHECK(restore.fxOffline[2].offline == false);
// A snapshot entirely at 0 must restore entirely to 0 (no default leaks in).
TrackSnapshot zero; // all zeros, empty fxOffline
TrackPlan rz = makeRestorePlan("{Z}", zero);
CHECK(flagValue(rz, Flag::ShowInTcp) == 0);
CHECK(flagValue(rz, Flag::ShowInMixer) == 0);
CHECK(flagValue(rz, Flag::MainSend) == 0);
CHECK(flagValue(rz, Flag::FxEnable) == 0);
CHECK(rz.fxOffline.empty());
// End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in
// Arrange, then restored when we toggle back to Design.
ViewModeModel vm;
FolderTree tree;
tree.nodes.push_back(FolderNode{"{DES}", "", false});
vm.membership().tag("{DES}", kDesignModeId);
vm.storeSnapshot("{DES}", snap);
// Toggle to Arrange: {DES} is inactive ⇒ parked.
auto toArrange = vm.planToggle(tree, kArrangeModeId);
CHECK(parkTargets(toArrange, "{DES}"));
CHECK(restoreFor(toArrange, "{DES}") == nullptr); // not restored while inactive
// Toggle to Design: {DES} is active AND has a snapshot ⇒ restored from it.
auto toDesign = vm.planToggle(tree, kDesignModeId);
CHECK(!parkTargets(toDesign, "{DES}"));
const TrackPlan* r = restoreFor(toDesign, "{DES}");
CHECK(r != nullptr);
if (r) {
CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0
CHECK(flagValue(*r, Flag::ShowInTcp) == 1);
}
}
// -- 4. show-both leaf --------------------------------------------------------
static void testShowBothNeverParkedVisibleEverywhere() {
ViewModeModel vm;
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
FolderTree tree;
tree.nodes.push_back(FolderNode{"{SB}", "", false});
// Tag into Design, then pin show-both.
vm.membership().tag("{SB}", kDesignModeId);
CHECK(vm.membership().setShowBoth("{SB}", true));
CHECK(vm.membership().isShowBoth("{SB}"));
// Visible in EVERY mode.
CHECK(visibleHas(vm.visibleTracks(tree, kArrangeModeId), "{SB}"));
CHECK(visibleHas(vm.visibleTracks(tree, kDesignModeId), "{SB}"));
CHECK(visibleHas(vm.visibleTracks(tree, "mixdown"), "{SB}"));
// Never parked, in any mode — even a mode it isn't tagged into.
CHECK(!parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}"));
CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}"));
CHECK(!parkTargets(vm.planToggle(tree, "mixdown"), "{SB}"));
// Clearing show-both restores normal one-mode parking: now in Arrange it parks.
CHECK(vm.membership().setShowBoth("{SB}", false));
CHECK(parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}"));
CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}"));
}
// -- 5. Unknown/stale GUID tolerated -----------------------------------------
static void testStaleGuidTolerated() {
ViewModeModel vm;
// Tag two leaves, but the tree only knows one — the other GUID is stale (its
// track was deleted / restructured while parked).
vm.membership().tag("{LIVE}", kDesignModeId);
vm.membership().tag("{GHOST}", kDesignModeId);
vm.storeSnapshot("{GHOST}", TrackSnapshot{}); // stale snapshot too
FolderTree tree;
tree.nodes.push_back(FolderNode{"{LIVE}", "", false});
// {GHOST} absent from the tree.
// No crash; the stale GUID is simply ignored (prune-safe).
auto plan = vm.planToggle(tree, kArrangeModeId);
CHECK(parkTargets(plan, "{LIVE}")); // live leaf still planned
CHECK(!parkTargets(plan, "{GHOST}")); // stale leaf never emitted
// Visibility derivation also ignores the stale GUID without incident.
auto vis = vm.visibleTracks(tree, kDesignModeId);
CHECK(visibleHas(vis, "{LIVE}"));
CHECK(!visibleHas(vis, "{GHOST}"));
// An empty tree with tagged members: nothing planned, no crash.
FolderTree empty;
auto emptyPlan = vm.planToggle(empty, kDesignModeId);
CHECK(emptyPlan.park.empty() && emptyPlan.restore.empty());
}
// -- 6. JSON round-trip lossless ---------------------------------------------
static void testJsonRoundTrip() {
ViewModeModel vm;
// Modes: seeded pair + a third; also an out-of-order ordinal to prove sorting
// survives round-trip.
CHECK(vm.modes().add(Mode{"print", "Print \"stem\"\n", 5}));
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
// Membership: a plain Design leaf, a show-both leaf, an Arrange leaf, and a
// leaf carrying multiple modes (representable via restore; exercises the set).
vm.membership().tag("{A}", kArrangeModeId);
vm.membership().tag("{D}", kDesignModeId);
vm.membership().tag("{SB}", "mixdown");
vm.membership().setShowBoth("{SB}", true);
Membership multi;
multi.modeIds = {kDesignModeId, "mixdown"};
multi.showBoth = false;
CHECK(vm.membership().restore("{MULTI}", multi));
// Snapshots: one full, one with a per-FX vector, including the tricky 0-values.
TrackSnapshot s1; s1.showInTcp = 1; s1.showInMixer = 0; s1.mainSend = 1;
s1.fxEnable = 0; s1.fxOffline = {1, 0, 1, 1};
vm.storeSnapshot("{D}", s1);
TrackSnapshot s2; // all zeros, empty fx vector
vm.storeSnapshot("{A}", s2);
// Active mode set to a non-default.
CHECK(vm.setActiveMode("mixdown"));
std::string json = vm.serialize();
auto back = ViewModeModel::deserialize(json);
CHECK(back.has_value());
CHECK(back && *back == vm);
// String form is stable across a second round-trip.
if (back) CHECK(back->serialize() == json);
// Spot-check the load-bearing bits survived.
if (back) {
CHECK(back->activeModeId() == "mixdown");
CHECK(back->modes().size() == 4);
const Mode* print = back->modes().query("print");
CHECK(print && print->displayName == "Print \"stem\"\n" && print->ordinal == 5);
CHECK(back->membership().isShowBoth("{SB}"));
const Membership* mm = back->membership().query("{MULTI}");
CHECK(mm && mm->modeIds.size() == 2 && mm->modeIds.count("mixdown"));
const TrackSnapshot* snap = back->snapshot("{D}");
CHECK(snap && snap->mainSend == 1 && snap->fxEnable == 0);
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1] == 0);
}
}
static void testEmptyModelRoundTrip() {
ViewModeModel vm; // default: Arrange + Design seeded, active = Arrange, no members
std::string json = vm.serialize();
auto back = ViewModeModel::deserialize(json);
CHECK(back.has_value());
CHECK(back && *back == vm);
// Lenient empty root ⇒ a default-seeded model.
auto empty = ViewModeModel::deserialize("{}");
CHECK(empty.has_value());
CHECK(empty && empty->modes().size() == 2);
CHECK(empty && empty->activeModeId() == kArrangeModeId);
CHECK(empty && empty->membership().empty());
}
static void testMalformedJson() {
const char* bad[] = {
"",
"{",
"not json",
"{\"modes\":[",
"{\"modes\":[{\"id\":\"x\"", // truncated mode
"{\"activeMode\":\"ghost\"}", // active mode not registered
"{\"modes\":[{\"id\":\"a\",\"ordinal\":0},{\"id\":\"a\",\"ordinal\":1}]}", // dup id
"{\"membership\":[{\"guid\":\"\"}]}", // empty guid
"{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid
"{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}",
"{\"modes\":[]}trailing", // trailing garbage
};
for (const char* j : bad) {
auto r = ViewModeModel::deserialize(j);
CHECK(!r.has_value());
}
}
// -- 7. planToggle park path: fxOffline is empty (shell-expands-FX contract) --
//
// planToggle calls makeParkPlan(guid, /*fxCount=*/0) for each inactive leaf.
// The D2 shell is responsible for expanding per-FX offline ops using
// TrackFX_GetCount — the pure model has no access to REAPER FX counts at plan
// time. This test pins that contract so a regression that passes a non-zero
// count (and emits FX ops prematurely) is caught immediately.
// The direct makeParkPlan(guid, 3) path (non-zero fxCount) is covered by
// testRestoreRoundTripSnapshotValues above.
static void testPlanToggleParkHasEmptyFxOffline() {
ViewModeModel vm;
FolderTree tree;
tree.nodes.push_back(FolderNode{"{LEAF}", "", false});
vm.membership().tag("{LEAF}", kDesignModeId);
// Toggle to Arrange: {LEAF} is inactive ⇒ park plan emitted.
auto plan = vm.planToggle(tree, kArrangeModeId);
CHECK(plan.park.size() == 1);
// The park plan must have an empty fxOffline — the shell expands FX ops.
CHECK(plan.park[0].fxOffline.empty());
// Scalar flags must still be present (the four park zeros).
CHECK(plan.park[0].flags.size() == 4);
}
int main() {
testNModeRegistryAndMembership();
testParentDerivationMultiMode();
testRestoreRoundTripSnapshotValues();
testShowBothNeverParkedVisibleEverywhere();
testStaleGuidTolerated();
testJsonRoundTrip();
testEmptyModelRoundTrip();
testMalformedJson();
testPlanToggleParkHasEmptyFxOffline();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}