Q-W5: persist → session/ext_state_io/prune_fs (deletion authority concentrated); one GetProjExtState grow-loop in bridge_marshal (T2-04, ×3 rewired); bank_book JSON codec → bank_book_json via private static nameKey; persist.h stays umbrella. 61/61 green.
This commit is contained in:
@@ -21,6 +21,8 @@
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
@@ -34,4 +36,62 @@ namespace reasampler::instrument::map {
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The GetProjExtState GROW-LOOP retry policy (Q-W5 rider, T2-04).
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetProjExtState writes into a caller-supplied buffer with no documented
|
||||
// query-the-size call, so a large value (bank blob, usage record) must be read by
|
||||
// growing a buffer until the value fits strictly inside it. Three shells carried
|
||||
// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's
|
||||
// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE
|
||||
// policy now lives here so the retry/termination rules cannot drift. The fiddly
|
||||
// part is the termination taxonomy, which each caller folds differently:
|
||||
//
|
||||
// * Absent — the API returned <= 0 on some attempt: the key holds no value.
|
||||
// (persist -> "" empty bank; usage_scan / bridge -> nullopt)
|
||||
// * Complete — the written C string fits STRICTLY inside the buffer (size+1 <
|
||||
// cap), so it cannot have been clipped: `value` is the whole value.
|
||||
// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable
|
||||
// WHOLE, which is NOT the same as absent. (persist warns on the
|
||||
// console; usage_scan folds it to the prune fail-safe abort)
|
||||
//
|
||||
// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap),
|
||||
// returning the API's int. A template, statically dispatched per call site — no
|
||||
// virtual calls, no std::function (the §3 performance guardrail); the caller binds
|
||||
// the project/namespace/key (or a resolved function pointer, VST side) in a lambda.
|
||||
struct GrowingExtStateRead {
|
||||
enum class Status { Absent, Complete, Overflow };
|
||||
Status status = Status::Absent;
|
||||
int apiReturn = 0; // the FINAL attempt's return (<= 0 iff Absent); feeds
|
||||
// decodeGetProjExtState on the bridge path unchanged
|
||||
std::string value; // the whole value; meaningful only when Complete
|
||||
};
|
||||
|
||||
template <class ReadFn>
|
||||
GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) {
|
||||
// Start generous; grow ×4 if REAPER reports the value may have been clipped
|
||||
// (the return is the value length; equal-to-capacity-minus-NUL is ambiguous,
|
||||
// so only a strict fit terminates). Ceiling 16 MB — give up rather than loop
|
||||
// forever on a pathological value.
|
||||
GrowingExtStateRead result;
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = read(buf.data(), cap);
|
||||
result.apiReturn = rv;
|
||||
if (rv <= 0) {
|
||||
result.status = GrowingExtStateRead::Status::Absent;
|
||||
return result;
|
||||
}
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) {
|
||||
result.status = GrowingExtStateRead::Status::Complete;
|
||||
result.value = std::move(s);
|
||||
return result;
|
||||
}
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
result.status = GrowingExtStateRead::Status::Overflow;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
+14
-289
@@ -3,18 +3,13 @@
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_book implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
|
||||
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
|
||||
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
|
||||
// so per-bank sample serialization stays owned by bank_model and is not duplicated
|
||||
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
|
||||
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
|
||||
// the book envelope, then hands each nested index blob straight to
|
||||
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
|
||||
// bank_book implementation — the registry RULES half: construction, pool
|
||||
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
|
||||
// and the reference queries. The JSON round-trip half (serialize / deserialize —
|
||||
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled
|
||||
// into the same bank_book target (the slot_map extraction shape: same header, a
|
||||
// second TU). The one symbol both halves share is the private static
|
||||
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -84,14 +79,13 @@ void BankBook::normalizeOrdinals() {
|
||||
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
||||
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
|
||||
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
|
||||
// facility and must not grow one; bank names are short user labels, not full Unicode
|
||||
// case-folding candidates.
|
||||
std::string nameKey(const std::string& s) {
|
||||
// case-folding candidates. Private static member (Q-W5): the one folding rule shared
|
||||
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
|
||||
std::string BankBook::nameKey(const std::string& s) {
|
||||
std::size_t b = 0, e = s.size();
|
||||
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
||||
while (b < e && isWs(s[b])) ++b;
|
||||
@@ -106,8 +100,6 @@ std::string nameKey(const std::string& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The
|
||||
// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name.
|
||||
bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const {
|
||||
@@ -425,279 +417,12 @@ std::vector<std::string> BankBook::referencedPaths() const {
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — writer
|
||||
// JSON — serialize / deserialize / adoptBanks live in bank_book_json.cpp
|
||||
// (Q-W5 extraction; byte-identical format, golden-literal-pinned by the Q-W1
|
||||
// test). loadFromPersisted stays here: it is the load-source PRECEDENCE rule
|
||||
// (banks-blob vs legacy vs empty), not the codec.
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
|
||||
// prior file-local writer carried, so the emitted blob is byte-identical.
|
||||
std::string intToStr(int v) { return json::numToStr(v); }
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string BankBook::serialize() const {
|
||||
std::string out;
|
||||
{
|
||||
ObjWriter root(out);
|
||||
root.keyRaw("version", intToStr(1));
|
||||
root.keyStr("activeBank", activeBankId_);
|
||||
|
||||
// banks: array of { id, displayName, ordinal, index: <BankModel blob> }.
|
||||
// The pool rides in as bank-zero, persisted identically to any named bank.
|
||||
root.keyBegin("banks");
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < banks_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
ObjWriter b(out);
|
||||
b.keyStr("id", banks_[i].id);
|
||||
b.keyStr("displayName", banks_[i].displayName);
|
||||
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
|
||||
// The nested index is bank_model's own JSON, emitted verbatim so the
|
||||
// per-sample shape stays owned by BankModel::serialize (not duplicated).
|
||||
b.keyRaw("index", banks_[i].index.serialize());
|
||||
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
|
||||
// parser defaults such a bank's slots from insertion order on load.
|
||||
b.keyRaw("slots", banks_[i].slots.serialize());
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
|
||||
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
|
||||
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
|
||||
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
|
||||
// blob, distinguishing the legacy shape (a bare bank_index object: has
|
||||
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
|
||||
// yields a single pool bank carrying the migrated index and an empty active id
|
||||
// (⇒ pool). The member deserialize() adopts the result (ordinal normalize +
|
||||
// active resolve).
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
|
||||
|
||||
bool parseBank(json::Reader& r, Bank& b) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // a bank object must at least carry an id
|
||||
|
||||
bool haveId = false;
|
||||
bool haveIndex = false;
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!r.parseString(b.id)) return false;
|
||||
haveId = true;
|
||||
} else if (key == "displayName") {
|
||||
if (!r.parseString(b.displayName)) return false;
|
||||
} else if (key == "ordinal") {
|
||||
if (!r.parseInt(b.ordinal)) return false;
|
||||
} else if (key == "index") {
|
||||
std::string raw;
|
||||
if (!r.captureValue(raw)) return false;
|
||||
auto idx = BankModel::deserialize(raw);
|
||||
if (!idx) return false; // a malformed nested index fails the whole parse
|
||||
b.index = std::move(*idx);
|
||||
haveIndex = true;
|
||||
} else if (key == "slots") {
|
||||
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
|
||||
// nothing because the key never appears); when present it drives the
|
||||
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!parseSlots(r, pairs)) return false;
|
||||
b.slots = SlotMap::fromEntries(pairs);
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || b.id.empty()) return false; // id keys the registry
|
||||
if (!haveIndex) return false; // every bank persists its index
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
|
||||
out.clear();
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool haveId = false, haveSlot = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!r.skipValue()) return false; } // forward-compat
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || !haveSlot) return false; // a slot entry needs both
|
||||
out.emplace_back(std::move(id), slot);
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
|
||||
std::string& activeBank) {
|
||||
banks.clear();
|
||||
activeBank.clear();
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // an empty object is neither shape → malformed
|
||||
|
||||
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
|
||||
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
|
||||
std::vector<Bank> parsedBanks;
|
||||
bool sawBanks = false;
|
||||
bool sawSamples = false;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "banks") {
|
||||
sawBanks = true;
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
Bank b;
|
||||
if (!parseBank(r, b)) return false;
|
||||
parsedBanks.push_back(std::move(b));
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "activeBank") {
|
||||
if (!r.parseString(activeBank)) return false;
|
||||
} else if (key == "samples") {
|
||||
// Legacy marker. The legacy index is re-parsed from the whole input below
|
||||
// (BankModel::deserialize owns that shape); here we only skip the value to
|
||||
// keep the scan well-formed and note that we saw it.
|
||||
sawSamples = true;
|
||||
if (!r.skipValue()) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // version, or unknown
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false; // trailing garbage
|
||||
|
||||
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
|
||||
if (!sawBanks) {
|
||||
if (!sawSamples) return false; // neither shape's marker → malformed
|
||||
auto legacy = BankModel::deserialize(raw);
|
||||
if (!legacy) return false;
|
||||
Bank pool;
|
||||
pool.id = kPoolBankId;
|
||||
pool.displayName = kPoolBankName;
|
||||
pool.ordinal = 0;
|
||||
pool.index = std::move(*legacy);
|
||||
banks.push_back(std::move(pool)); // { pool } with zero named banks
|
||||
activeBank.clear(); // ⇒ pool (default) after adoption
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
|
||||
// The pool must be present as bank-zero (serialize always emits it). Reject a
|
||||
// book blob that omits it rather than silently re-seeding — a book without its
|
||||
// pool is malformed, not a legacy blob.
|
||||
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
|
||||
[](const Bank& b) { return b.isPool(); });
|
||||
if (!hasPool) return false;
|
||||
|
||||
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
|
||||
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
|
||||
if (parsedBanks[i].id == parsedBanks[j].id) return false;
|
||||
|
||||
// Force the pool's fixed display name — it is not user-mutable, so we do not
|
||||
// trust a persisted override for it (keeps kPoolBankName authoritative).
|
||||
for (auto& b : parsedBanks)
|
||||
if (b.isPool()) b.displayName = kPoolBankName;
|
||||
|
||||
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
|
||||
// The in-model create/rename path enforces unique display names under nameKey,
|
||||
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
|
||||
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
|
||||
// would degrade the user's entire library to empty, so instead we AUTO-
|
||||
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
|
||||
// the first time a folded key repeats, suffix that bank's display name (" 2",
|
||||
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
|
||||
// bank to carry a key keeps its name verbatim; only subsequent collisions are
|
||||
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
|
||||
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
|
||||
// is disambiguated away from it, never the reverse.
|
||||
{
|
||||
std::vector<std::string> seenKeys;
|
||||
seenKeys.reserve(parsedBanks.size());
|
||||
for (auto& b : parsedBanks) {
|
||||
if (b.isPool()) { // pool's name is fixed; reserve its key
|
||||
seenKeys.push_back(nameKey(b.displayName));
|
||||
continue;
|
||||
}
|
||||
const auto taken = [&](const std::string& k) {
|
||||
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
|
||||
};
|
||||
std::string key = nameKey(b.displayName);
|
||||
if (taken(key)) {
|
||||
// Suffix with an ascending integer until the folded key is free. Guard
|
||||
// against a pathological blob whose base name already ends in a number
|
||||
// by folding the candidate each attempt (nameKey normalizes it).
|
||||
const std::string base = b.displayName;
|
||||
for (int n = 2;; ++n) {
|
||||
const std::string candidate = base + " " + std::to_string(n);
|
||||
const std::string candKey = nameKey(candidate);
|
||||
if (!taken(candKey)) {
|
||||
b.displayName = candidate;
|
||||
key = candKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
seenKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
|
||||
banks = std::move(parsedBanks);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
|
||||
banks_ = std::move(banks);
|
||||
normalizeOrdinals();
|
||||
// Resolve the active bank defensively: fall back to the pool if the persisted id
|
||||
// names no bank, so a corrupt active id never leaves a dangling capture target.
|
||||
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
|
||||
}
|
||||
|
||||
std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
|
||||
std::vector<Bank> banks;
|
||||
std::string activeBank;
|
||||
json::Reader r(blob);
|
||||
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
|
||||
|
||||
BankBook book;
|
||||
book.adoptBanks(std::move(banks), activeBank);
|
||||
return book;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -342,6 +342,16 @@ private:
|
||||
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
|
||||
std::string activeBankId_; // always names a live bank; defaults to pool
|
||||
|
||||
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
||||
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share
|
||||
// one key and cannot coexist. ASCII-only by design — the pure core carries no
|
||||
// locale facility and must not grow one. A private STATIC member (Q-W5, settled)
|
||||
// because BOTH halves of the split implementation need the ONE folding rule: the
|
||||
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp,
|
||||
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
|
||||
// a parsed book violate the create/rename uniqueness invariant.
|
||||
static std::string nameKey(const std::string& s);
|
||||
|
||||
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
|
||||
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
|
||||
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
#include "core/model/bank_book.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header,
|
||||
// compiled into the same bank_book target; the slot_map second-TU shape). The
|
||||
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private
|
||||
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time
|
||||
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename
|
||||
// uniqueness check does, or a parsed book could violate the in-model invariant.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
|
||||
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
|
||||
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
|
||||
// so per-bank sample serialization stays owned by bank_model and is not duplicated
|
||||
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
|
||||
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
|
||||
// the book envelope, then hands each nested index blob straight to
|
||||
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
|
||||
// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — writer
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
|
||||
// prior file-local writer carried, so the emitted blob is byte-identical.
|
||||
std::string intToStr(int v) { return json::numToStr(v); }
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string BankBook::serialize() const {
|
||||
std::string out;
|
||||
{
|
||||
ObjWriter root(out);
|
||||
root.keyRaw("version", intToStr(1));
|
||||
root.keyStr("activeBank", activeBankId_);
|
||||
|
||||
// banks: array of { id, displayName, ordinal, index: <BankModel blob> }.
|
||||
// The pool rides in as bank-zero, persisted identically to any named bank.
|
||||
root.keyBegin("banks");
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < banks_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
ObjWriter b(out);
|
||||
b.keyStr("id", banks_[i].id);
|
||||
b.keyStr("displayName", banks_[i].displayName);
|
||||
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
|
||||
// The nested index is bank_model's own JSON, emitted verbatim so the
|
||||
// per-sample shape stays owned by BankModel::serialize (not duplicated).
|
||||
b.keyRaw("index", banks_[i].index.serialize());
|
||||
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
|
||||
// parser defaults such a bank's slots from insertion order on load.
|
||||
b.keyRaw("slots", banks_[i].slots.serialize());
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
|
||||
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
|
||||
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
|
||||
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
|
||||
// blob, distinguishing the legacy shape (a bare bank_index object: has
|
||||
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
|
||||
// yields a single pool bank carrying the migrated index and an empty active id
|
||||
// (⇒ pool). The member deserialize() adopts the result (duplicate-display-name
|
||||
// coalesce + ordinal normalize + active resolve — the coalesce lives THERE, not
|
||||
// here, because it folds through the private BankBook::nameKey these free
|
||||
// functions cannot reach).
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
|
||||
|
||||
bool parseBank(json::Reader& r, Bank& b) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // a bank object must at least carry an id
|
||||
|
||||
bool haveId = false;
|
||||
bool haveIndex = false;
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!r.parseString(b.id)) return false;
|
||||
haveId = true;
|
||||
} else if (key == "displayName") {
|
||||
if (!r.parseString(b.displayName)) return false;
|
||||
} else if (key == "ordinal") {
|
||||
if (!r.parseInt(b.ordinal)) return false;
|
||||
} else if (key == "index") {
|
||||
std::string raw;
|
||||
if (!r.captureValue(raw)) return false;
|
||||
auto idx = BankModel::deserialize(raw);
|
||||
if (!idx) return false; // a malformed nested index fails the whole parse
|
||||
b.index = std::move(*idx);
|
||||
haveIndex = true;
|
||||
} else if (key == "slots") {
|
||||
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
|
||||
// nothing because the key never appears); when present it drives the
|
||||
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!parseSlots(r, pairs)) return false;
|
||||
b.slots = SlotMap::fromEntries(pairs);
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || b.id.empty()) return false; // id keys the registry
|
||||
if (!haveIndex) return false; // every bank persists its index
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
|
||||
out.clear();
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool haveId = false, haveSlot = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!r.skipValue()) return false; } // forward-compat
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveId || !haveSlot) return false; // a slot entry needs both
|
||||
out.emplace_back(std::move(id), slot);
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
|
||||
std::string& activeBank) {
|
||||
banks.clear();
|
||||
activeBank.clear();
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume('}')) return false; // an empty object is neither shape → malformed
|
||||
|
||||
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
|
||||
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
|
||||
std::vector<Bank> parsedBanks;
|
||||
bool sawBanks = false;
|
||||
bool sawSamples = false;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!r.parseKey(key)) return false;
|
||||
|
||||
if (key == "banks") {
|
||||
sawBanks = true;
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
do {
|
||||
Bank b;
|
||||
if (!parseBank(r, b)) return false;
|
||||
parsedBanks.push_back(std::move(b));
|
||||
} while (r.consume(','));
|
||||
if (!r.consume(']')) return false;
|
||||
}
|
||||
} else if (key == "activeBank") {
|
||||
if (!r.parseString(activeBank)) return false;
|
||||
} else if (key == "samples") {
|
||||
// Legacy marker. The legacy index is re-parsed from the whole input below
|
||||
// (BankModel::deserialize owns that shape); here we only skip the value to
|
||||
// keep the scan well-formed and note that we saw it.
|
||||
sawSamples = true;
|
||||
if (!r.skipValue()) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // version, or unknown
|
||||
}
|
||||
} while (r.consume(','));
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
r.skipWs();
|
||||
if (!r.eof()) return false; // trailing garbage
|
||||
|
||||
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
|
||||
if (!sawBanks) {
|
||||
if (!sawSamples) return false; // neither shape's marker → malformed
|
||||
auto legacy = BankModel::deserialize(raw);
|
||||
if (!legacy) return false;
|
||||
Bank pool;
|
||||
pool.id = kPoolBankId;
|
||||
pool.displayName = kPoolBankName;
|
||||
pool.ordinal = 0;
|
||||
pool.index = std::move(*legacy);
|
||||
banks.push_back(std::move(pool)); // { pool } with zero named banks
|
||||
activeBank.clear(); // ⇒ pool (default) after adoption
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
|
||||
// The pool must be present as bank-zero (serialize always emits it). Reject a
|
||||
// book blob that omits it rather than silently re-seeding — a book without its
|
||||
// pool is malformed, not a legacy blob.
|
||||
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
|
||||
[](const Bank& b) { return b.isPool(); });
|
||||
if (!hasPool) return false;
|
||||
|
||||
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
|
||||
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
|
||||
if (parsedBanks[i].id == parsedBanks[j].id) return false;
|
||||
|
||||
// Force the pool's fixed display name — it is not user-mutable, so we do not
|
||||
// trust a persisted override for it (keeps kPoolBankName authoritative).
|
||||
for (auto& b : parsedBanks)
|
||||
if (b.isPool()) b.displayName = kPoolBankName;
|
||||
|
||||
banks = std::move(parsedBanks);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
|
||||
banks_ = std::move(banks);
|
||||
normalizeOrdinals();
|
||||
// Resolve the active bank defensively: fall back to the pool if the persisted id
|
||||
// names no bank, so a corrupt active id never leaves a dangling capture target.
|
||||
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
|
||||
}
|
||||
|
||||
std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
|
||||
std::vector<Bank> banks;
|
||||
std::string activeBank;
|
||||
json::Reader r(blob);
|
||||
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
|
||||
|
||||
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
|
||||
// The in-model create/rename path enforces unique display names under nameKey,
|
||||
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
|
||||
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
|
||||
// would degrade the user's entire library to empty, so instead we AUTO-
|
||||
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
|
||||
// the first time a folded key repeats, suffix that bank's display name (" 2",
|
||||
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
|
||||
// bank to carry a key keeps its name verbatim; only subsequent collisions are
|
||||
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
|
||||
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
|
||||
// is disambiguated away from it, never the reverse.
|
||||
//
|
||||
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because
|
||||
// it folds through the PRIVATE BankBook::nameKey — the same rule the
|
||||
// create/rename uniqueness check applies. Runs after parseBook on BOTH shapes;
|
||||
// the legacy path yields { pool } alone, where the scan is a trivial no-op.
|
||||
{
|
||||
std::vector<std::string> seenKeys;
|
||||
seenKeys.reserve(banks.size());
|
||||
for (auto& b : banks) {
|
||||
if (b.isPool()) { // pool's name is fixed; reserve its key
|
||||
seenKeys.push_back(nameKey(b.displayName));
|
||||
continue;
|
||||
}
|
||||
const auto taken = [&](const std::string& k) {
|
||||
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
|
||||
};
|
||||
std::string key = nameKey(b.displayName);
|
||||
if (taken(key)) {
|
||||
// Suffix with an ascending integer until the folded key is free. Guard
|
||||
// against a pathological blob whose base name already ends in a number
|
||||
// by folding the candidate each attempt (nameKey normalizes it).
|
||||
const std::string base = b.displayName;
|
||||
for (int n = 2;; ++n) {
|
||||
const std::string candidate = base + " " + std::to_string(n);
|
||||
const std::string candKey = nameKey(candidate);
|
||||
if (!taken(candKey)) {
|
||||
b.displayName = candidate;
|
||||
key = candKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
seenKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
|
||||
BankBook book;
|
||||
book.adoptBanks(std::move(banks), activeBank);
|
||||
return book;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
-853
@@ -1,853 +0,0 @@
|
||||
#include "core/namespaces.h"
|
||||
// persist.cpp — REAPER-facing implementation of the BankModel <-> project
|
||||
// ext-state bridge (M4). See persist.h for the contract.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the
|
||||
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
|
||||
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
|
||||
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
|
||||
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
|
||||
// the banks travel with the project automatically (CONTEXT.md §Persistence & paths).
|
||||
// The only thing that does NOT travel for free is the physical bank folder; on
|
||||
// Save-As to a new directory we relocate it so the indices' relative paths still
|
||||
// resolve.
|
||||
//
|
||||
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
|
||||
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
|
||||
// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext
|
||||
// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the
|
||||
// secondary disambiguator (classifyProjectTransition owns the exact order):
|
||||
// * different stored GUID -> a different project of record -> LOAD its index;
|
||||
// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's
|
||||
// address, so a reopened/new project can present the previous pointer with a
|
||||
// different GUID), new/unsaved<->saved, and switching between distinct saved
|
||||
// projects.
|
||||
// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via
|
||||
// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings
|
||||
// diverge going forward.
|
||||
// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new
|
||||
// location -> relocate the bank folder from the old dir to the new one, then
|
||||
// re-GUID.
|
||||
// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only)
|
||||
// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a
|
||||
// fork and its parent share a GUID on disk; switching between them read as a
|
||||
// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer
|
||||
// RECYCLING — a reopened/new project reusing the previous project's address read
|
||||
// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first
|
||||
// catches recycling; the pointer then separates a fork (same GUID, different
|
||||
// object -> Load) from a Save-As (same GUID, same object, new path -> relocate).
|
||||
// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject`
|
||||
// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free
|
||||
// and testable; poll() executes the verdict.
|
||||
//
|
||||
// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
|
||||
// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
|
||||
// a cross-open identity), so we MINT one with genGuid/guidToString and store it
|
||||
// under kProjExtGuidKey. On Save-As REAPER copies the whole .rpp incl. our ext
|
||||
// state, so the new project initially shares the old GUID; poll() re-GUIDs it
|
||||
// (after relocating, or on the forked-sibling Load branch) so identities diverge.
|
||||
//
|
||||
// Rationale for the timer: the brief mandates ext-state storage (rules out the
|
||||
// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
|
||||
// ext-state while covering identity-transition load + Save-As detection in one
|
||||
// place.
|
||||
//
|
||||
// DIVISION OF LABOUR (R-B undo):
|
||||
// * Identity-transition poll (this file, classifyProjectTransition) owns
|
||||
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
|
||||
// where the project OF RECORD changes.
|
||||
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
|
||||
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
|
||||
// identity is unchanged but its ext state rolled back/forward on disk. The
|
||||
// identity poll sees NoOp there and would never re-read ext state, so the hook
|
||||
// requests a reload (requestReload) that poll() drains on the next tick, once
|
||||
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
|
||||
// The hook fires on undo AND redo (isUndo true for both), and on normal open
|
||||
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
|
||||
// flows solely through the identity-transition Load path and never double-loads.
|
||||
//
|
||||
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
|
||||
// our own reasampler_bank/ folder. It never touches the user's media, items, or
|
||||
// other ext-state namespaces.
|
||||
|
||||
#include "persist.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
|
||||
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
|
||||
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
|
||||
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
|
||||
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
|
||||
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/reclaim/prune_reconcile.h"
|
||||
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetProjExtState
|
||||
#define REAPERAPI_WANT_MarkProjectDirty
|
||||
#define REAPERAPI_WANT_SetProjExtState
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_genGuid
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Read the active project pointer and its .rpp path in one shot. idx=-1 is the
|
||||
// current project tab (SDK header line ~1262). The out-buffer receives the full
|
||||
// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel —
|
||||
// same fact capture.cpp relies on). Returns nullptr proj only when there is no
|
||||
// active project at all.
|
||||
void* readActiveProject(std::string& rppPathOut) {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
rppPathOut.assign(buf.data());
|
||||
return proj;
|
||||
}
|
||||
|
||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
||||
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
|
||||
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
|
||||
// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths
|
||||
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
|
||||
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
|
||||
std::string projectDirOf(const std::string& rppPath) {
|
||||
return projectDirOfRpp(rppPath);
|
||||
}
|
||||
|
||||
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
||||
// (many samples). Query the required size first (a NULL/zero call is not part of
|
||||
// the documented contract, so we grow a buffer until it fits). Returns "" when
|
||||
// the key is absent (GetProjExtState returns <=0) — an absent key is a valid
|
||||
// empty bank, not an error.
|
||||
std::string getProjExtStateString(ReaProject* proj, const char* ns,
|
||||
const char* key) {
|
||||
// Start generous; grow if REAPER reports the value was truncated. The return
|
||||
// value is the length of the value (SDK: "returns length"); if it equals the
|
||||
// buffer capacity minus the NUL, the value may have been clipped, so retry.
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<size_t>(cap), '\0');
|
||||
int rv = GetProjExtState(proj, ns, key, buf.data(), cap);
|
||||
if (rv <= 0) return {}; // absent / empty -> empty bank
|
||||
// If the written string fits strictly inside the buffer it is complete.
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
// Pathologically large (>16 MB) — give up rather than loop forever. Warn on
|
||||
// the console so this reads as "too large to load", not silent data loss
|
||||
// (mirrors the malformed-JSON warning in loadFromProject).
|
||||
ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
|
||||
"' exceeds the 16 MB read ceiling -- ignoring (bank not "
|
||||
"loaded).\n").c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
|
||||
// guidToString wants a >=64-char destination (SDK header line ~3846).
|
||||
std::string genProjectGuidString() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
char buf[64] = {0};
|
||||
guidToString(&g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
|
||||
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
|
||||
// files at the destination so a re-save is idempotent. Best-effort: filesystem
|
||||
// errors are swallowed and reported to the console rather than thrown across the
|
||||
// REAPER boundary. Returns true if the copy ran (source existed).
|
||||
bool relocateBankFolder(const std::string& oldBankDir,
|
||||
const std::string& newBankDir) {
|
||||
std::error_code ec;
|
||||
if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
|
||||
return false; // nothing at the old location to relocate
|
||||
}
|
||||
if (oldBankDir == newBankDir) return false; // defensive; plan guards this too
|
||||
|
||||
fs::create_directories(newBankDir, ec);
|
||||
fs::copy(oldBankDir, newBankDir,
|
||||
fs::copy_options::recursive | fs::copy_options::overwrite_existing,
|
||||
ec);
|
||||
if (ec) {
|
||||
ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
|
||||
"' failed: " + ec.message() + "\n").c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ReaSamplerSession::saveToActiveProject() {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to persist
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
|
||||
// rides in the `banks` key.
|
||||
const std::string banksJson = book_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBanksKey, banksJson.c_str());
|
||||
|
||||
// Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
|
||||
// value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
|
||||
// realizes retirement concretely — after any save, a formerly-legacy project
|
||||
// carries `banks` and NO `bank_index`, and going forward the legacy key is never
|
||||
// written. Cheap and idempotent when the key is already absent.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtIndexKey, "");
|
||||
|
||||
// Additive: the Design-View model rides alongside the banks in its own key.
|
||||
// Independent write — does not disturb the `banks` blob above.
|
||||
const std::string viewJson = view_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtViewKey, viewJson.c_str());
|
||||
|
||||
// Additive: the docked panel's tail setting rides alongside in its own key, so the
|
||||
// tail choice travels inside the .rpp. Independent write — does not disturb the
|
||||
// bank_index or view_state above.
|
||||
const std::string tailJson = serializeTailSetting(tail_);
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
|
||||
// `owned_files` key. Independent write — does not disturb the blobs above. Written
|
||||
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
|
||||
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
|
||||
// same saveToActiveProject the capture add-path calls). Uses the channel-derived
|
||||
// namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too.
|
||||
const std::string ownedJson = owned_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtOwnedKey, ownedJson.c_str());
|
||||
|
||||
// Phase V (V1/V4): stamp the WRITING version — the build producing this save — under
|
||||
// the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty
|
||||
// stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is
|
||||
// the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as
|
||||
// Stamped on read-back and stays byte-identical to stable regardless of channel; the
|
||||
// channel is already carried by the isolated namespace (projExtNamespace) this writes to.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, stampVersion().c_str());
|
||||
|
||||
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
|
||||
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
|
||||
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
|
||||
// every content mutation's own save carries the fresh generation the instrument reads. The
|
||||
// format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree
|
||||
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
instrument::map::formatBankGeneration(bankGeneration_).c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// One-shot write of the ingest assignment request under its own key (S8). Independent
|
||||
// of the book/view/tail blobs — this is a transient signal to the instrument, not
|
||||
// session state that must ride every save. Uses the channel-derived namespace
|
||||
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
|
||||
// instrument reads only a beta extension's assignment requests.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
|
||||
// exact (tallied over the full orphan set), but the enumerated file list handed to the
|
||||
// console is clipped to this many entries so a project with thousands of orphans does
|
||||
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
|
||||
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
|
||||
constexpr std::size_t kPruneListDisplayCap = 64;
|
||||
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
|
||||
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
|
||||
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
|
||||
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
|
||||
// active project, enumerates the folder) but writes nothing.
|
||||
//
|
||||
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
|
||||
// when there is no active/saved project, no project dir, or no folder on
|
||||
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
|
||||
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
||||
// order, untruncated. The pure core decides; this only supplies inputs.
|
||||
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
||||
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
|
||||
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
|
||||
// the prune must halt rather than proceed with degraded protection.
|
||||
// An empty orphan set is itself the delete-side guarantee (every
|
||||
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
|
||||
// what lets the action TELL the user instead of claiming "no orphans".
|
||||
struct PruneScan {
|
||||
std::string bankDirAbs;
|
||||
std::vector<std::string> orphans;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
};
|
||||
|
||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
|
||||
PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) {
|
||||
PruneScan scan;
|
||||
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
|
||||
|
||||
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
|
||||
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
|
||||
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
|
||||
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
|
||||
const std::string projectDir = projectDirOf(rppPath);
|
||||
const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder);
|
||||
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
|
||||
return scan; // no bank folder captured yet -> nothing to reclaim
|
||||
}
|
||||
|
||||
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
|
||||
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
|
||||
// convention) so the pure core's exact-string match lines up with referencedPaths()
|
||||
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
|
||||
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
|
||||
// path so the report's byte tally reuses the same on-disk read.
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
|
||||
// failure (file removed, permission flip) breaks out with a best-effort partial list
|
||||
// rather than propagating std::filesystem_error across REAPER's C ABI.
|
||||
std::vector<std::string> present;
|
||||
fs::directory_iterator it(bankDir, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
std::error_code reg_ec;
|
||||
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
|
||||
const std::string name = entry.path().filename().string();
|
||||
const std::string rel = bankRelativeForName(name);
|
||||
if (rel.empty()) continue;
|
||||
present.push_back(rel);
|
||||
std::error_code sz_ec;
|
||||
const std::uintmax_t sz = entry.file_size(sz_ec);
|
||||
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
|
||||
}
|
||||
|
||||
// The decision lives in the pure core — read-only inputs from the book and manifest.
|
||||
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
||||
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
|
||||
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
|
||||
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
|
||||
// including the protect-all net when zero instances were identified) — a capture
|
||||
// any live instance holds can NEVER be an orphan, even when its bank entry was
|
||||
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
|
||||
// preserving this scan's no-write contract. This shell only enumerates, resolves,
|
||||
// and stats.
|
||||
scan.bankDirAbs = bankDir;
|
||||
const UsageScanResult usage = liveInstanceHeldPaths(proj);
|
||||
if (usage.abortPrune) {
|
||||
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
|
||||
// protected set is unknowable. Compute NO orphans — every downstream consumer
|
||||
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
|
||||
// nothing. The flag + key names surface the reason so the action can name each
|
||||
// offending key for operator recovery.
|
||||
scan.abortedUnreadableUsage = true;
|
||||
scan.offendingUsageKeys = usage.offendingKeys;
|
||||
return scan;
|
||||
}
|
||||
scan.orphans = pruneOrphans(
|
||||
present, mergeReferenced(book.referencedPaths(), usage.heldPaths),
|
||||
owned.paths());
|
||||
return scan;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
||||
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
||||
PruneReport report =
|
||||
buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
||||
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
|
||||
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
|
||||
// the scan computed nothing — but the user must know the prune refused to run).
|
||||
// The offending key names propagate so the action can name each one for recovery.
|
||||
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||
report.offendingUsageKeys = scan.offendingUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
|
||||
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
|
||||
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
|
||||
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
|
||||
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
|
||||
// the caller folds this into skippedCount.
|
||||
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
|
||||
// may cross the C ABI.
|
||||
//
|
||||
// Per-platform routing:
|
||||
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
|
||||
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
|
||||
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
|
||||
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
|
||||
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
|
||||
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
|
||||
// confirm guardrail. `outUsedTrash` left as-is (false).
|
||||
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
bool& outAlreadyAbsent) {
|
||||
#ifdef _WIN32
|
||||
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
|
||||
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
|
||||
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
|
||||
std::string win = absPath;
|
||||
for (char& c : win) if (c == '/') c = '\\';
|
||||
|
||||
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
|
||||
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
|
||||
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
|
||||
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
|
||||
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
|
||||
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
|
||||
|
||||
SHFILEOPSTRUCTW op{};
|
||||
op.hwnd = nullptr;
|
||||
op.wFunc = FO_DELETE;
|
||||
op.pFrom = wbuf.data();
|
||||
op.pTo = nullptr;
|
||||
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
|
||||
FOF_SILENT | FOF_NOERRORUI);
|
||||
const int rv = SHFileOperationW(&op);
|
||||
if (rv == 0 && !op.fAnyOperationsAborted) {
|
||||
outUsedTrash = true;
|
||||
return true; // deleted this call -> reclaimed
|
||||
}
|
||||
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
|
||||
// versions, or a lock). Distinguish "already absent" from a real failure so the
|
||||
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absPath, ec)) {
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(absPath, ec);
|
||||
if (removed) return true; // deleted this call -> reclaimed
|
||||
if (ec) return false; // a real failure (locked / permission) -> skip
|
||||
// remove returned false with no error == the file did not exist -> already gone.
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const {
|
||||
PruneDeletionResult result;
|
||||
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
|
||||
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
|
||||
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
|
||||
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
||||
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
||||
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
||||
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
|
||||
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
|
||||
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
||||
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, scan.orphans);
|
||||
|
||||
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
|
||||
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
|
||||
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
|
||||
// de-duplicated entries as stale — that would be dishonest.
|
||||
const std::size_t uniqueConfirmedCount =
|
||||
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
|
||||
result.skippedCount += uniqueConfirmedCount - plan.size();
|
||||
|
||||
for (const std::string& rel : plan) {
|
||||
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
|
||||
const std::string::size_type slash = rel.find_last_of('/');
|
||||
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
|
||||
if (name.empty()) { ++result.skippedCount; continue; }
|
||||
const std::string absPath = scan.bankDirAbs + "/" + name;
|
||||
|
||||
const auto szIt = scan.sizeByRel.find(rel);
|
||||
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
|
||||
|
||||
bool alreadyAbsent = false;
|
||||
if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
|
||||
++result.reclaimedCount;
|
||||
result.reclaimedBytes += bytes;
|
||||
} else if (alreadyAbsent) {
|
||||
// File vanished between plan and delete — treat as staleness, same as the
|
||||
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
|
||||
++result.skippedCount;
|
||||
} else {
|
||||
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Load the Design-View model from a project's view_state key, or return a fresh
|
||||
// default. An absent/empty key (older project with no view state) yields a
|
||||
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
|
||||
// never a crash. Malformed JSON is warned and also falls back to default, mirroring
|
||||
// the bank's malformed-index handling. The whole model round-trips: modes,
|
||||
// membership, show-both, snapshots, and active mode all ride inside the one blob.
|
||||
ViewModeModel loadViewModel(ReaProject* proj) {
|
||||
if (!proj) return ViewModeModel{};
|
||||
const std::string viewJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey);
|
||||
if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default
|
||||
std::optional<ViewModeModel> loaded = ViewModeModel::deserialize(viewJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n");
|
||||
return ViewModeModel{};
|
||||
}
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
// Load the tail setting from a project's tail_setting key, or return the default. An
|
||||
// absent/empty key (older / never-adjusted project) yields the default setting (None /
|
||||
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
|
||||
// to default, mirroring the bank's and view's malformed handling.
|
||||
TailSetting loadTailSetting(ReaProject* proj) {
|
||||
if (!proj) return TailSetting{};
|
||||
const std::string tailJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
|
||||
if (tailJson.empty()) return TailSetting{}; // no stored setting -> default
|
||||
std::optional<TailSetting> loaded = deserializeTailSetting(tailJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
|
||||
return TailSetting{};
|
||||
}
|
||||
return *loaded;
|
||||
}
|
||||
|
||||
// Load the owned-file manifest from a project's owned_files key, or return an empty
|
||||
// manifest. An absent/empty key (older / never-captured project) yields an empty
|
||||
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
|
||||
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
|
||||
// sees an empty ownership record and (safely) attributes nothing until the next capture
|
||||
// rebuilds it — losing the record degrades safety, never correctness.
|
||||
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
|
||||
if (!proj) return OwnedFileManifest{};
|
||||
const std::string ownedJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
|
||||
if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty
|
||||
std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
|
||||
return OwnedFileManifest{};
|
||||
}
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||
// Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the
|
||||
// single choke point for every load path (prime, project switch/open, forked-
|
||||
// sibling load), so setting it here — and NOT on the Save-As branch, which keeps
|
||||
// the in-memory model as-is — makes the signal fire exactly when a fresh view
|
||||
// model has been installed and its active mode's visibility needs reapplying.
|
||||
// main.cpp drains it via consumeLoadSignal() on the same tick.
|
||||
loadPending_ = true;
|
||||
|
||||
// The view model is restored on EVERY load path (peer-symmetry with the bank
|
||||
// reset below): switching to a project with no view state must clear stale
|
||||
// in-memory state, not inherit the previous project's. D3 restores MODEL STATE
|
||||
// only — no visibility/processing is applied here (that is D4).
|
||||
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The tail setting is restored on EVERY load path too (peer-symmetry): switching
|
||||
// to a project with no stored setting must fall back to the default, not inherit
|
||||
// the previous project's choice (this REPLACES the old session-carry behavior).
|
||||
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
|
||||
// bank/view/tail resets): switching to a project with no stored manifest must reset
|
||||
// to empty, not inherit the previous project's ownership record; an undo/redo reload
|
||||
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
|
||||
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
|
||||
|
||||
// Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry
|
||||
// with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed
|
||||
// one as Unknown — both silent, no console warning (a pre-versioning project is not
|
||||
// an error). getProjExtStateString returns "" for an absent key, which is exactly the
|
||||
// PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default.
|
||||
writingVersion_ = classifyWritingVersion(
|
||||
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey)
|
||||
: std::string{});
|
||||
|
||||
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
|
||||
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
|
||||
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
|
||||
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
|
||||
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
|
||||
bankGeneration_ = instrument::map::parseBankGeneration(
|
||||
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey)
|
||||
: std::string{});
|
||||
|
||||
if (!proj) {
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
}
|
||||
|
||||
// Read both possible sources: the authoritative `banks` blob and the retired-but-
|
||||
// possibly-still-present legacy `bank_index`. The precedence + migration decision
|
||||
// (`banks` wins; else the legacy index migrates into the pool; else an empty book)
|
||||
// is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
|
||||
// so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
|
||||
// blob must read as "ignored", not silent loss, mirroring the prior malformed-index
|
||||
// warning. A malformed `banks` degrades to an empty book and does NOT fall back to
|
||||
// the stale legacy key (which would resurrect superseded single-bank state).
|
||||
const std::string banksJson =
|
||||
getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBanksKey);
|
||||
if (!banksJson.empty()) {
|
||||
std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n");
|
||||
book_ = BankBook{};
|
||||
} else {
|
||||
book_ = std::move(*loaded);
|
||||
}
|
||||
} else {
|
||||
// No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
|
||||
// by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
|
||||
// empty tail; passing "" for banksJson takes exactly that branch.
|
||||
const std::string legacyJson =
|
||||
getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtIndexKey);
|
||||
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
}
|
||||
|
||||
// L7 slot migration: seed every bank's display-position SlotMap from its index
|
||||
// insertion order when the loaded blob carried none (a pre-L7 project -> dense,
|
||||
// gap-free, visually identical on first post-L7 load), and reconcile a partial map
|
||||
// (drop stale markers, append unmapped samples) for a blob written by an earlier L7
|
||||
// build. One-way: once the book is re-saved the reconciled slot data is authoritative.
|
||||
// Idempotent, so a fresh empty book is a cheap no-op.
|
||||
book_.reconcileSlots();
|
||||
|
||||
// Project-relative resolution is a READ-time concern: every BankModel in the book
|
||||
// stores only relative paths (invariant, enforced per-bank at add()), and consumers
|
||||
// (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
|
||||
// resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
|
||||
// absolute here — that would break the relative-only invariant and travel-with-.rpp.
|
||||
// projectDir is threaded through for those consumers; nothing to do at load time
|
||||
// beyond replacing the in-memory book.
|
||||
(void)projectDir;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Ensure a SAVED project carries a stored GUID, minting and writing one if it
|
||||
// has none yet (a project saved before this feature shipped, or a brand-new
|
||||
// first save). Returns the effective GUID: the existing one, the freshly minted
|
||||
// one, or "" for an unsaved project (no .rpp to store ext state into — the same
|
||||
// gate SetProjExtState/saveToActiveProject already respect on empty path).
|
||||
// Called from BOTH prime and the Load branch so identity is established the same
|
||||
// way on every entry to a project (peer-symmetry: no path skips the mint).
|
||||
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
|
||||
const std::string& currentGuid) {
|
||||
if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID
|
||||
if (!currentGuid.empty()) return currentGuid;
|
||||
const std::string minted = genProjectGuidString();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, minted.c_str());
|
||||
return minted;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ReaSamplerSession::consumeLoadSignal() {
|
||||
const bool pending = loadPending_;
|
||||
loadPending_ = false;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void ReaSamplerSession::requestReload() {
|
||||
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
|
||||
// the read is deferred past the projectconfig callback). Cheap and idempotent —
|
||||
// multiple undo/redo callbacks before the next tick collapse to one reload.
|
||||
reloadRequested_ = true;
|
||||
}
|
||||
|
||||
void ReaSamplerSession::poll() {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
const std::string currentGuid =
|
||||
proj ? getProjExtStateString(static_cast<ReaProject*>(proj),
|
||||
projExtNamespace(), kProjExtGuidKey)
|
||||
: std::string{};
|
||||
|
||||
if (!primed_) {
|
||||
// First observation: adopt current identity and load its index, without
|
||||
// treating it as a "change" (avoids a spurious relocation on startup).
|
||||
primed_ = true;
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
|
||||
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
|
||||
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
|
||||
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
|
||||
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
|
||||
// one or more ticks ago; by NOW REAPER has finished restoring the project's
|
||||
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
|
||||
// current active project and identity-adopt it (no relocation — the path is
|
||||
// unchanged), then return. loadFromProject raises loadPending_, so the existing
|
||||
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
|
||||
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
|
||||
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
|
||||
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
|
||||
if (reloadRequested_) {
|
||||
reloadRequested_ = false;
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pointer identity is the primary signal: a genuine Save-As keeps the SAME
|
||||
// ReaProject* (one object saved elsewhere); a tab-switch/open is a different
|
||||
// object. Passing the bool (not the pointer) keeps the classifier pure.
|
||||
const bool sameProjectObject = (proj == lastProject_);
|
||||
const ProjectTransition transition = classifyProjectTransition(
|
||||
sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath);
|
||||
|
||||
switch (transition) {
|
||||
case ProjectTransition::NoOp:
|
||||
return;
|
||||
|
||||
case ProjectTransition::Load: {
|
||||
// A different project of record is active (open / tab switch / new /
|
||||
// reopened / recycled pointer / forked sibling). Load ITS index; never
|
||||
// relocate.
|
||||
//
|
||||
// Forked-sibling divergence: gate on `!sameProjectObject` so this fires
|
||||
// ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
|
||||
// that copied our GUID and never re-saved (its fresh GUID was runtime-
|
||||
// only on the sibling we came from). A recycled-pointer Load (step 1:
|
||||
// currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
|
||||
// identity. currentGuid == lastGuid_ can only hold here when step 1 did
|
||||
// NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
|
||||
// makes that intent load-bearing rather than incidental. Do this BEFORE
|
||||
// loadFromProject reads the index (order is irrelevant — GUID and
|
||||
// bank_index are distinct keys — but self-contained is clearest).
|
||||
if (proj && !sameProjectObject && !currentGuid.empty() &&
|
||||
currentGuid == lastGuid_ && !rppPath.empty()) {
|
||||
const std::string fresh = genProjectGuidString();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, fresh.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = fresh;
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal load: establish identity the same way prime does.
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
case ProjectTransition::SaveAsRelocate: {
|
||||
// SAME project object + new .rpp path: a genuine Save-As (the pointer
|
||||
// proves it — a fork tab-switch is a DIFFERENT object and took the Load
|
||||
// branch above). Relocate the bank folder from the old dir to the new
|
||||
// one so the wavs sit under the new .rpp and the index's relative paths
|
||||
// still resolve. Keep the in-memory bank as-is (Save-As copied our ext
|
||||
// state, the relative paths are unchanged) — do NOT reload.
|
||||
const std::string oldDir = projectDirOf(lastRppPath_);
|
||||
const std::string newDir = projectDirOf(rppPath);
|
||||
const BankRelocation plan = deriveRelocationPlan(oldDir, newDir);
|
||||
if (plan.needed) {
|
||||
relocateBankFolder(plan.oldBankDir, plan.newBankDir);
|
||||
}
|
||||
|
||||
// Save-As duplicated our ext state, so the new project B currently
|
||||
// shares A's GUID. Mint a FRESH GUID for B and write it, so A and B
|
||||
// no longer collide on identity when reopened later. Adopt the fresh
|
||||
// GUID as our last-seen identity. Mark dirty so the fresh GUID flushes
|
||||
// to the new .rpp on the next normal save / close-prompt.
|
||||
const std::string fresh = genProjectGuidString();
|
||||
if (proj) {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, fresh.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
}
|
||||
lastProject_ = proj; // unchanged (same object) — set for symmetry
|
||||
lastGuid_ = fresh;
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+19
-336
@@ -1,342 +1,25 @@
|
||||
#pragma once
|
||||
#include "core/namespaces.h"
|
||||
// persist — the REAPER-facing bridge between the in-memory BankModel and project
|
||||
// ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths).
|
||||
// persist.h — COMPATIBILITY UMBRELLA (Q-W5). The former persist god-TU split into
|
||||
// three TUs under shell/persist/ by responsibility:
|
||||
//
|
||||
// Save: serialize the BankModel JSON -> SetProjExtState under namespace
|
||||
// "reasampler" (ext state lives inside the .rpp, so the index travels with the
|
||||
// project for free).
|
||||
// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory
|
||||
// BankModel, then resolve each entry's bank file against the CURRENT project
|
||||
// dir (project-relative resolution — a project opened from a new location still
|
||||
// finds its bank).
|
||||
// Save-As: when the project path changes, relocate the physical bank folder so
|
||||
// the wavs end up under the new .rpp (the index's relative paths stay valid).
|
||||
// * shell/persist/session.h + session.cpp — the ReaSamplerSession class (lifecycle,
|
||||
// poll identity-transition detection, the projectconfig undo/redo reload drain).
|
||||
// * shell/persist/ext_state_io.h + ext_state_io.cpp — the ext-state ↔ JSON
|
||||
// serialization bridge, key contract, GUID minting, bank-folder relocation.
|
||||
// * shell/persist/prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION
|
||||
// AUTHORITY (deleteOrphanFile, file-local; nothing else deletes bytes).
|
||||
//
|
||||
// The header is REAPER-free (no SDK types leak here): callers interact through a
|
||||
// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API
|
||||
// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip
|
||||
// and capture_paths (pure) for the path arithmetic it drives.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "ext_keys.h"
|
||||
#include "core/model/owned_manifest.h"
|
||||
#include "core/reclaim/prune_reconcile.h"
|
||||
#include "core/capture/tail_control.h"
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The ext-state namespace + the WIRE-SHARED key names are the contract between this
|
||||
// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h
|
||||
// (pure, REAPER-free) and are included above — not duplicated here. The namespace is
|
||||
// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace()
|
||||
// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte-
|
||||
// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both
|
||||
// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace
|
||||
// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project
|
||||
// saved by stable shows empty/default state in beta and vice versa; that isolation is the
|
||||
// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
||||
// The per-key semantics persist relies on (spellings owned by ext_keys.h):
|
||||
// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks).
|
||||
// AUTHORITATIVE going forward; the VST reads this key to see the live bank.
|
||||
// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared
|
||||
// on save); READ once on load to migrate a legacy project into the pool.
|
||||
// * kProjExtViewKey : the Design-View ViewModeModel JSON.
|
||||
// * kProjExtTailKey : the docked panel's TailSetting JSON.
|
||||
// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll()
|
||||
// tells a Save-As from a recycled-pointer project switch by it).
|
||||
// All are FOREVER-STABLE once shipped: changing any strands every already-saved
|
||||
// project's stored state under that key.
|
||||
// This header re-exports the split APIs so every existing caller (actions.cpp,
|
||||
// main.cpp, ingest.cpp, the panel TUs, capture shells) keeps compiling untouched —
|
||||
// Q-W4 is rewriting actions.cpp in parallel, so touching callers this wave is a
|
||||
// guaranteed conflict. Retiring this umbrella (callers include the split headers
|
||||
// directly) is Q-W6 cleanup.
|
||||
//
|
||||
// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this
|
||||
// is the const char* the SetProjExtState/GetProjExtState calls in persist.cpp pass. Kept
|
||||
// as an accessor (not a literal) because the string is channel-derived at build time.
|
||||
inline const char* projExtNamespace() { return extStateNamespace().c_str(); }
|
||||
// core/namespaces.h stays HERE, not in the split headers/TUs: the unsplit callers
|
||||
// still reference flat-namespace symbols (TailSetting, PruneReport, BankModel, ...)
|
||||
// through this include, while the split persist TUs themselves reference real
|
||||
// namespace homes and are shim-free.
|
||||
|
||||
// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument
|
||||
// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h:
|
||||
//
|
||||
// owned_files — the owned-file manifest JSON (project-relative files the capture path
|
||||
// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's
|
||||
// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT
|
||||
// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it
|
||||
// strands every saved project's ownership record (prune falls back to an empty manifest —
|
||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||
|
||||
// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on
|
||||
// every save, so every saved .rpp records which build produced its state — the seam a
|
||||
// future within-channel forward migration keys off. An absent key is the explicit
|
||||
// pre-versioning case, read silently, never an error. FOREVER-STABLE key string.
|
||||
inline constexpr const char* kProjExtVersionKey = "version";
|
||||
|
||||
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
||||
// against the active REAPER project. One instance lives for the extension's
|
||||
// lifetime (main.cpp). It tracks
|
||||
// the project identity it last saw so the timer tick can detect a project load
|
||||
// (a different project became active) and a Save-As (SAME project, path changed):
|
||||
//
|
||||
// * project load -> load the index from ext state, resolve bank paths
|
||||
// * Save-As (new dir) -> relocate the bank folder under the new .rpp
|
||||
//
|
||||
// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
|
||||
// record, immune to REAPER recycling a closed project's ReaProject* address) is
|
||||
// checked FIRST, and the live pointer disambiguates only the same-GUID case — a
|
||||
// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same
|
||||
// GUID, same object, new path -> relocate). GUID-first catches pointer recycling
|
||||
// (a reopened/new project reusing the previous address with a different GUID — the
|
||||
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
|
||||
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
|
||||
//
|
||||
// The book itself is exposed for the capture/action layer to mutate; persist
|
||||
// only reads it on save and replaces it on load.
|
||||
class ReaSamplerSession {
|
||||
public:
|
||||
ReaSamplerSession() = default;
|
||||
|
||||
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
|
||||
// BankModel, plus the active-bank id. The action layer (B3) creates / renames /
|
||||
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
|
||||
// persist serializes it under the `banks` key on save and replaces it on load.
|
||||
BankBook& book() { return book_; }
|
||||
const BankBook& book() const { return book_; }
|
||||
|
||||
// The capture add-target: the ACTIVE bank's BankModel (defaults to the pool).
|
||||
// The capture path adds a captured Sample through this seam, so a capture lands
|
||||
// in whichever bank is active — the single behavioural change B2 wires in over
|
||||
// M7/M8 (the capture backends are untouched; only the target index moved). The
|
||||
// panel/insert readers that displayed the single index continue to read it here
|
||||
// unchanged; today it resolves to the pool (default active), matching prior
|
||||
// single-bank behaviour, until B3/B4 let the user switch the active bank.
|
||||
BankModel& bank() { return book_.activeIndex(); }
|
||||
const BankModel& bank() const { return book_.activeIndex(); }
|
||||
|
||||
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
||||
// toggle, snapshot); persist serializes it on save and replaces it on project
|
||||
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
|
||||
// visibility/processing (reapply-on-open) is D4's job, not this member's.
|
||||
ViewModeModel& view() { return view_; }
|
||||
const ViewModeModel& view() const { return view_; }
|
||||
|
||||
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
|
||||
// panel state — so it travels inside the .rpp: persist serializes it on save and
|
||||
// replaces it on project load exactly as it treats the bank and view model. The
|
||||
// panel reads/writes it through this seam (bank_panel holds the session), and the
|
||||
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
|
||||
// an unsaved or pre-feature project (no stored key -> this default survives load).
|
||||
TailSetting& tail() { return tail_; }
|
||||
const TailSetting& tail() const { return tail_; }
|
||||
|
||||
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
|
||||
// capture path itself created. The capture add-path records each created file here
|
||||
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
|
||||
// bank; persist serializes it under the `owned_files` key on save and replaces it on
|
||||
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
|
||||
// B-cap only writes and persists it (no prune logic here).
|
||||
OwnedFileManifest& owned() { return owned_; }
|
||||
const OwnedFileManifest& owned() const { return owned_; }
|
||||
|
||||
// The ReaSampler version that last WROTE the active project, recovered from its
|
||||
// ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no
|
||||
// stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the
|
||||
// exact stored string otherwise — all silent, never an error. Replaced on every load
|
||||
// path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved
|
||||
// or never-loaded session. Exposed so a future migration step (or diagnostics) can
|
||||
// reason about the origin build without re-reading ext state.
|
||||
const WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
|
||||
// per project: recovered on load (so it continues from the stored value rather than
|
||||
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
|
||||
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
|
||||
std::int64_t bankGeneration() const { return bankGeneration_; }
|
||||
|
||||
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
|
||||
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
|
||||
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
|
||||
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
|
||||
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
|
||||
// the same mutation already makes (the counter rides the persist blob, so there is no
|
||||
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
|
||||
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
|
||||
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
|
||||
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
|
||||
void bumpBankGeneration() { ++bankGeneration_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
// Safe to call when there is no active/saved project (it no-ops).
|
||||
//
|
||||
// Returns true iff a persist actually happened (an active, SAVED project existed);
|
||||
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
|
||||
// caller wrapping this in an undo block skip the block when nothing was written, so
|
||||
// no dangling no-effect undo entry is opened on an unsaved project.
|
||||
bool saveToActiveProject();
|
||||
|
||||
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
|
||||
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
|
||||
// relative machinery the index/persist use — never a stale absolute path, so it is
|
||||
// correct across a Save-As relocation), spells every enumerated entry with the index's
|
||||
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
||||
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
|
||||
// `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's
|
||||
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
|
||||
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
|
||||
// live instance holds can never be an orphan, so the prune can never delete it.
|
||||
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
|
||||
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
|
||||
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
||||
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
||||
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
||||
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
|
||||
//
|
||||
// Yields an empty report (count 0) when there is no active/saved project or no bank
|
||||
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
|
||||
PruneReport pruneDryRun() const;
|
||||
|
||||
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
|
||||
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
|
||||
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
|
||||
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
|
||||
// (pruneDryRun's truncated list is for the console readout; the delete set must be
|
||||
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
|
||||
// no active/saved project or no bank folder yet.
|
||||
std::vector<std::string> pruneOrphanSet() const;
|
||||
|
||||
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
|
||||
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
|
||||
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
|
||||
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
|
||||
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
|
||||
// so a file that vanished or became referenced between confirm and delete is skipped,
|
||||
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
|
||||
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
|
||||
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
|
||||
// std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform
|
||||
// routing). Non-throwing: every filesystem call uses error_code forms; a per-file
|
||||
// failure (locked, already gone) is recorded and skipped, never thrown across the C ABI.
|
||||
//
|
||||
// Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does
|
||||
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
|
||||
// algebra naturally once it is off disk — no persist write, so no undo-point question
|
||||
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
|
||||
//
|
||||
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
|
||||
// delete plan is empty (everything went stale). The caller is responsible for having
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
|
||||
// `assign_request` key, namespace "reasampler"): the extension telling the active
|
||||
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
|
||||
// encoding (assignment_request.h); this method only routes the already-encoded value
|
||||
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
|
||||
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
|
||||
//
|
||||
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
|
||||
// assignment request is a transient "just assigned" signal the instrument reads and
|
||||
// acts on, so it rides its own key and is written only at ingest time, never on every
|
||||
// book save. Returns true iff written (an active, SAVED project existed); false on a
|
||||
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
|
||||
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
|
||||
bool writeAssignmentRequest(const std::string& wire);
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
//
|
||||
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
|
||||
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
|
||||
// identity classifier below reads it as NoOp and would never re-read ext state.
|
||||
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
|
||||
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
|
||||
// (now-restored) ext state of the current project — before the identity check, so
|
||||
// the undo is reflected in-session without any content polling.
|
||||
void poll();
|
||||
|
||||
// Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext
|
||||
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
|
||||
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
|
||||
// because the projectconfig callback fires BEFORE REAPER has restored the project's
|
||||
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
|
||||
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
|
||||
// is REAPER-facing shell state; the request itself carries no REAPER types.
|
||||
void requestReload();
|
||||
|
||||
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
|
||||
// (re)loads the view model from a project — prime, a project switch/open, or a
|
||||
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
|
||||
// it, so the integration layer (main.cpp) can react by reapplying the saved
|
||||
// active mode's visibility exactly once, then goes quiet on idle ticks.
|
||||
//
|
||||
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
|
||||
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
|
||||
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
|
||||
// where those two already meet. D3 deliberately deferred exactly this to D4.
|
||||
bool consumeLoadSignal();
|
||||
|
||||
private:
|
||||
BankBook book_;
|
||||
|
||||
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
||||
// = Arrange; loadFromProject leaves this default when a project has no stored
|
||||
// view_state (older project), so an absent key is graceful, not a crash.
|
||||
ViewModeModel view_;
|
||||
|
||||
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
|
||||
// to this default when a project has no stored tail_setting key (older / never-
|
||||
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
|
||||
TailSetting tail_;
|
||||
|
||||
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
|
||||
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
|
||||
// a project with no stored manifest must not inherit the previous project's ownership
|
||||
// record, and an undo that rolled back a capture must re-read the restored manifest so
|
||||
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
|
||||
OwnedFileManifest owned_;
|
||||
|
||||
// The writing-version stamp recovered on load (Phase V). Default PreVersioning;
|
||||
// loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so
|
||||
// switching to a pre-versioning project reports PreVersioning rather than inheriting
|
||||
// the previous project's stamp. Read-only to consumers via writingVersion().
|
||||
WritingVersion writingVersion_;
|
||||
|
||||
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
|
||||
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
|
||||
// continues monotonic from the persisted value across reopen and resets cleanly on a
|
||||
// project switch (a different project's counter, not the previous project's). bumped by
|
||||
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
|
||||
// Default 0 for an unsaved / never-loaded / pre-S9 session.
|
||||
std::int64_t bankGeneration_ = 0;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
// same-GUID case (different object = forked sibling -> Load; same object + new
|
||||
// path -> Save-As) and drives forked-sibling re-divergence; the path tells a
|
||||
// Save-As from an idle tick.
|
||||
// Held as void* so the header stays REAPER-free; it is a compared-only opaque
|
||||
// handle (never dereferenced), so a stale/recycled address is harmless.
|
||||
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
|
||||
std::string lastGuid_; // "" until the first saved project is seen
|
||||
std::string lastRppPath_; // .rpp path last seen for lastProject_
|
||||
bool primed_ = false; // false until the first poll() observes state
|
||||
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
|
||||
bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll
|
||||
|
||||
// Load the book from the given project's ext state (the `banks` key, else the
|
||||
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
|
||||
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
|
||||
// and owned_ from their sibling keys on every load path. projectDir empty -> the
|
||||
// book is reset to empty (unsaved project has no resolvable banks).
|
||||
void loadFromProject(void* proj, const std::string& projectDir);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
#include "shell/persist/ext_state_io.h"
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
@@ -93,21 +93,19 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
|
||||
|
||||
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
|
||||
// return value is the value length; if it fits strictly inside the buffer it is
|
||||
// complete, else grow and retry up to a 16 MB ceiling.
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = getProjExtState_(proj, kProjExtNamespace(), key.c_str(),
|
||||
buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt; // absent / empty key
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) {
|
||||
return decodeGetProjExtState(rv, s);
|
||||
}
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
|
||||
// truncation. The retry policy is the SHARED pure
|
||||
// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for the
|
||||
// extension's persist/usage reads and this bridge read; the rules cannot drift):
|
||||
// absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a
|
||||
// complete value still runs through decodeGetProjExtState (the stale/empty-buffer
|
||||
// guard) exactly as before.
|
||||
const auto read = instrument::map::readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap);
|
||||
});
|
||||
if (read.status != instrument::map::GrowingExtStateRead::Status::Complete)
|
||||
return std::nullopt; // absent / empty key, or pathologically large (>16 MB)
|
||||
return decodeGetProjExtState(read.apiReturn, read.value);
|
||||
}
|
||||
|
||||
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam
|
||||
// (Q-W5 split of the former persist.cpp; see session.h for the TU map and
|
||||
// ext_state_io.h for the key contract): the session's save/load/assignment-request
|
||||
// bridge, plus the shared persist_detail helpers (active-project read, growing
|
||||
// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the
|
||||
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
|
||||
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
|
||||
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
|
||||
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
|
||||
// the banks travel with the project automatically (CONTEXT.md §Persistence & paths).
|
||||
// The only thing that does NOT travel for free is the physical bank folder; on
|
||||
// Save-As to a new directory we relocate it so the indices' relative paths still
|
||||
// resolve (poll(), session.cpp, executes the relocation this TU implements).
|
||||
//
|
||||
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
|
||||
// our own reasampler_bank/ folder. It never touches the user's media, items, or
|
||||
// other ext-state namespaces.
|
||||
|
||||
#include "shell/persist/ext_state_io.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/persist/persist_internal.h"
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
|
||||
#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
|
||||
#include "core/version/app_version.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetProjExtState
|
||||
#define REAPERAPI_WANT_MarkProjectDirty
|
||||
#define REAPERAPI_WANT_SetProjExtState
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_genGuid
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::persist_detail {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Read the active project pointer and its .rpp path in one shot. idx=-1 is the
|
||||
// current project tab (SDK header line ~1262). The out-buffer receives the full
|
||||
// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel —
|
||||
// same fact capture.cpp relies on). Returns nullptr proj only when there is no
|
||||
// active project at all.
|
||||
void* readActiveProject(std::string& rppPathOut) {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
rppPathOut.assign(buf.data());
|
||||
return proj;
|
||||
}
|
||||
|
||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
||||
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
|
||||
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
|
||||
// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths
|
||||
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
|
||||
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
|
||||
std::string projectDirOf(const std::string& rppPath) {
|
||||
return capture::projectDirOfRpp(rppPath);
|
||||
}
|
||||
|
||||
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
|
||||
// (many samples). The grow-until-strict-fit retry policy is the SHARED pure
|
||||
// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — the same policy the
|
||||
// usage_scan and VST-bridge reads run); this wrapper binds the REAPER call and
|
||||
// folds the terminal cases persist's callers expect: "" for an absent key (a valid
|
||||
// empty bank, not an error) and a console warning + "" for a value exceeding the
|
||||
// 16 MB ceiling, so an over-large value reads as "too large to load", not silent
|
||||
// data loss (mirrors the malformed-JSON warning in loadFromProject).
|
||||
std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
|
||||
using instrument::map::GrowingExtStateRead;
|
||||
const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
return GetProjExtState(static_cast<ReaProject*>(proj), ns, key, buf, cap);
|
||||
});
|
||||
switch (read.status) {
|
||||
case GrowingExtStateRead::Status::Complete:
|
||||
return read.value;
|
||||
case GrowingExtStateRead::Status::Absent:
|
||||
return {}; // absent / empty -> empty bank
|
||||
case GrowingExtStateRead::Status::Overflow:
|
||||
break;
|
||||
}
|
||||
ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
|
||||
"' exceeds the 16 MB read ceiling -- ignoring (bank not "
|
||||
"loaded).\n").c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
|
||||
// guidToString wants a >=64-char destination (SDK header line ~3846).
|
||||
std::string genProjectGuidString() {
|
||||
GUID g{};
|
||||
genGuid(&g);
|
||||
char buf[64] = {0};
|
||||
guidToString(&g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Ensure a SAVED project carries a stored GUID, minting and writing one if it
|
||||
// has none yet (a project saved before this feature shipped, or a brand-new
|
||||
// first save). Returns the effective GUID: the existing one, the freshly minted
|
||||
// one, or "" for an unsaved project (no .rpp to store ext state into — the same
|
||||
// gate SetProjExtState/saveToActiveProject already respect on empty path).
|
||||
// Called from BOTH prime and the Load branch so identity is established the same
|
||||
// way on every entry to a project (peer-symmetry: no path skips the mint).
|
||||
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
|
||||
const std::string& currentGuid) {
|
||||
if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID
|
||||
if (!currentGuid.empty()) return currentGuid;
|
||||
const std::string minted = genProjectGuidString();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, minted.c_str());
|
||||
return minted;
|
||||
}
|
||||
|
||||
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
|
||||
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
|
||||
// files at the destination so a re-save is idempotent. Best-effort: filesystem
|
||||
// errors are swallowed and reported to the console rather than thrown across the
|
||||
// REAPER boundary. Returns true if the copy ran (source existed).
|
||||
bool relocateBankFolder(const std::string& oldBankDir,
|
||||
const std::string& newBankDir) {
|
||||
std::error_code ec;
|
||||
if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
|
||||
return false; // nothing at the old location to relocate
|
||||
}
|
||||
if (oldBankDir == newBankDir) return false; // defensive; plan guards this too
|
||||
|
||||
fs::create_directories(newBankDir, ec);
|
||||
fs::copy(oldBankDir, newBankDir,
|
||||
fs::copy_options::recursive | fs::copy_options::overwrite_existing,
|
||||
ec);
|
||||
if (ec) {
|
||||
ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
|
||||
"' failed: " + ec.message() + "\n").c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::persist_detail
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using persist_detail::getProjExtStateString;
|
||||
using persist_detail::readActiveProject;
|
||||
|
||||
bool ReaSamplerSession::saveToActiveProject() {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to persist
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
|
||||
// rides in the `banks` key.
|
||||
const std::string banksJson = book_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBanksKey, banksJson.c_str());
|
||||
|
||||
// Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
|
||||
// value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
|
||||
// realizes retirement concretely — after any save, a formerly-legacy project
|
||||
// carries `banks` and NO `bank_index`, and going forward the legacy key is never
|
||||
// written. Cheap and idempotent when the key is already absent.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtIndexKey, "");
|
||||
|
||||
// Additive: the Design-View model rides alongside the banks in its own key.
|
||||
// Independent write — does not disturb the `banks` blob above.
|
||||
const std::string viewJson = view_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtViewKey, viewJson.c_str());
|
||||
|
||||
// Additive: the docked panel's tail setting rides alongside in its own key, so the
|
||||
// tail choice travels inside the .rpp. Independent write — does not disturb the
|
||||
// bank_index or view_state above.
|
||||
const std::string tailJson = capture::serializeTailSetting(tail_);
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
|
||||
// `owned_files` key. Independent write — does not disturb the blobs above. Written
|
||||
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
|
||||
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
|
||||
// same saveToActiveProject the capture add-path calls). Uses the channel-derived
|
||||
// namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too.
|
||||
const std::string ownedJson = owned_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtOwnedKey, ownedJson.c_str());
|
||||
|
||||
// Phase V (V1/V4): stamp the WRITING version — the build producing this save — under
|
||||
// the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty
|
||||
// stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is
|
||||
// the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as
|
||||
// Stamped on read-back and stays byte-identical to stable regardless of channel; the
|
||||
// channel is already carried by the isolated namespace (projExtNamespace) this writes to.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, version::stampVersion().c_str());
|
||||
|
||||
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
|
||||
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
|
||||
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
|
||||
// every content mutation's own save carries the fresh generation the instrument reads. The
|
||||
// format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree
|
||||
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
instrument::map::formatBankGeneration(bankGeneration_).c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
|
||||
// One-shot write of the ingest assignment request under its own key (S8). Independent
|
||||
// of the book/view/tail blobs — this is a transient signal to the instrument, not
|
||||
// session state that must ride every save. Uses the channel-derived namespace
|
||||
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
|
||||
// instrument reads only a beta extension's assignment requests.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Load the Design-View model from a project's view_state key, or return a fresh
|
||||
// default. An absent/empty key (older project with no view state) yields a
|
||||
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
|
||||
// never a crash. Malformed JSON is warned and also falls back to default, mirroring
|
||||
// the bank's malformed-index handling. The whole model round-trips: modes,
|
||||
// membership, show-both, snapshots, and active mode all ride inside the one blob.
|
||||
ViewModeModel loadViewModel(ReaProject* proj) {
|
||||
if (!proj) return ViewModeModel{};
|
||||
const std::string viewJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey);
|
||||
if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default
|
||||
std::optional<ViewModeModel> loaded = ViewModeModel::deserialize(viewJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n");
|
||||
return ViewModeModel{};
|
||||
}
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
// Load the tail setting from a project's tail_setting key, or return the default. An
|
||||
// absent/empty key (older / never-adjusted project) yields the default setting (None /
|
||||
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
|
||||
// to default, mirroring the bank's and view's malformed handling.
|
||||
capture::TailSetting loadTailSetting(ReaProject* proj) {
|
||||
if (!proj) return capture::TailSetting{};
|
||||
const std::string tailJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
|
||||
if (tailJson.empty()) return capture::TailSetting{}; // no stored setting -> default
|
||||
std::optional<capture::TailSetting> loaded =
|
||||
capture::deserializeTailSetting(tailJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
|
||||
return capture::TailSetting{};
|
||||
}
|
||||
return *loaded;
|
||||
}
|
||||
|
||||
// Load the owned-file manifest from a project's owned_files key, or return an empty
|
||||
// manifest. An absent/empty key (older / never-captured project) yields an empty
|
||||
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
|
||||
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
|
||||
// sees an empty ownership record and (safely) attributes nothing until the next capture
|
||||
// rebuilds it — losing the record degrades safety, never correctness.
|
||||
model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
|
||||
if (!proj) return model::OwnedFileManifest{};
|
||||
const std::string ownedJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
|
||||
if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty
|
||||
std::optional<model::OwnedFileManifest> loaded =
|
||||
model::OwnedFileManifest::deserialize(ownedJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
|
||||
return model::OwnedFileManifest{};
|
||||
}
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||
// Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the
|
||||
// single choke point for every load path (prime, project switch/open, forked-
|
||||
// sibling load), so setting it here — and NOT on the Save-As branch, which keeps
|
||||
// the in-memory model as-is — makes the signal fire exactly when a fresh view
|
||||
// model has been installed and its active mode's visibility needs reapplying.
|
||||
// main.cpp drains it via consumeLoadSignal() on the same tick.
|
||||
loadPending_ = true;
|
||||
|
||||
// The view model is restored on EVERY load path (peer-symmetry with the bank
|
||||
// reset below): switching to a project with no view state must clear stale
|
||||
// in-memory state, not inherit the previous project's. D3 restores MODEL STATE
|
||||
// only — no visibility/processing is applied here (that is D4).
|
||||
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The tail setting is restored on EVERY load path too (peer-symmetry): switching
|
||||
// to a project with no stored setting must fall back to the default, not inherit
|
||||
// the previous project's choice (this REPLACES the old session-carry behavior).
|
||||
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
|
||||
// bank/view/tail resets): switching to a project with no stored manifest must reset
|
||||
// to empty, not inherit the previous project's ownership record; an undo/redo reload
|
||||
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
|
||||
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
|
||||
|
||||
// Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry
|
||||
// with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed
|
||||
// one as Unknown — both silent, no console warning (a pre-versioning project is not
|
||||
// an error). getProjExtStateString returns "" for an absent key, which is exactly the
|
||||
// PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default.
|
||||
writingVersion_ = version::classifyWritingVersion(
|
||||
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey)
|
||||
: std::string{});
|
||||
|
||||
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
|
||||
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
|
||||
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
|
||||
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
|
||||
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
|
||||
bankGeneration_ = instrument::map::parseBankGeneration(
|
||||
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey)
|
||||
: std::string{});
|
||||
|
||||
if (!proj) {
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
}
|
||||
|
||||
// Read both possible sources: the authoritative `banks` blob and the retired-but-
|
||||
// possibly-still-present legacy `bank_index`. The precedence + migration decision
|
||||
// (`banks` wins; else the legacy index migrates into the pool; else an empty book)
|
||||
// is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
|
||||
// so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
|
||||
// blob must read as "ignored", not silent loss, mirroring the prior malformed-index
|
||||
// warning. A malformed `banks` degrades to an empty book and does NOT fall back to
|
||||
// the stale legacy key (which would resurrect superseded single-bank state).
|
||||
const std::string banksJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey);
|
||||
if (!banksJson.empty()) {
|
||||
std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n");
|
||||
book_ = BankBook{};
|
||||
} else {
|
||||
book_ = std::move(*loaded);
|
||||
}
|
||||
} else {
|
||||
// No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
|
||||
// by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
|
||||
// empty tail; passing "" for banksJson takes exactly that branch.
|
||||
const std::string legacyJson =
|
||||
getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey);
|
||||
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
}
|
||||
|
||||
// L7 slot migration: seed every bank's display-position SlotMap from its index
|
||||
// insertion order when the loaded blob carried none (a pre-L7 project -> dense,
|
||||
// gap-free, visually identical on first post-L7 load), and reconcile a partial map
|
||||
// (drop stale markers, append unmapped samples) for a blob written by an earlier L7
|
||||
// build. One-way: once the book is re-saved the reconciled slot data is authoritative.
|
||||
// Idempotent, so a fresh empty book is a cheap no-op.
|
||||
book_.reconcileSlots();
|
||||
|
||||
// Project-relative resolution is a READ-time concern: every BankModel in the book
|
||||
// stores only relative paths (invariant, enforced per-bank at add()), and consumers
|
||||
// (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
|
||||
// resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
|
||||
// absolute here — that would break the relative-only invariant and travel-with-.rpp.
|
||||
// projectDir is threaded through for those consumers; nothing to do at load time
|
||||
// beyond replacing the in-memory book.
|
||||
(void)projectDir;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam
|
||||
// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession
|
||||
// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority).
|
||||
// This header owns the persist-side key spellings and the channel-derived
|
||||
// namespace accessor; the TU (ext_state_io.cpp) implements the session's
|
||||
// save/load/assignment-request bridge plus the GUID minting and bank-folder
|
||||
// relocation helpers the poll executes.
|
||||
//
|
||||
// The ext-state namespace + the WIRE-SHARED key names are the contract between this
|
||||
// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h
|
||||
// (pure, REAPER-free) and are included here — not duplicated. The namespace is
|
||||
// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace()
|
||||
// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte-
|
||||
// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both
|
||||
// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace
|
||||
// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project
|
||||
// saved by stable shows empty/default state in beta and vice versa; that isolation is the
|
||||
// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
||||
// The per-key semantics persist relies on (spellings owned by ext_keys.h):
|
||||
// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks).
|
||||
// AUTHORITATIVE going forward; the VST reads this key to see the live bank.
|
||||
// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared
|
||||
// on save); READ once on load to migrate a legacy project into the pool.
|
||||
// * kProjExtViewKey : the Design-View ViewModeModel JSON.
|
||||
// * kProjExtTailKey : the docked panel's TailSetting JSON.
|
||||
// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll()
|
||||
// tells a Save-As from a recycled-pointer project switch by it).
|
||||
// All are FOREVER-STABLE once shipped: changing any strands every already-saved
|
||||
// project's stored state under that key.
|
||||
|
||||
#include "core/version/app_version.h"
|
||||
#include "ext_keys.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this
|
||||
// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an
|
||||
// accessor (not a literal) because the string is channel-derived at build time.
|
||||
inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); }
|
||||
|
||||
// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument
|
||||
// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h:
|
||||
//
|
||||
// owned_files — the owned-file manifest JSON (project-relative files the capture path
|
||||
// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's
|
||||
// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT
|
||||
// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it
|
||||
// strands every saved project's ownership record (prune falls back to an empty manifest —
|
||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||
|
||||
// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on
|
||||
// every save, so every saved .rpp records which build produced its state — the seam a
|
||||
// future within-channel forward migration keys off. An absent key is the explicit
|
||||
// pre-versioning case, read silently, never an error. FOREVER-STABLE key string.
|
||||
inline constexpr const char* kProjExtVersionKey = "version";
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,51 @@
|
||||
// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5:
|
||||
// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU).
|
||||
// Included ONLY by those three TUs — never a public seam (mirror of the panel's
|
||||
// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the
|
||||
// former anonymous-namespace helpers that more than one split TU needs; every
|
||||
// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder
|
||||
// machinery). Behavior-identical to the pre-split definitions.
|
||||
//
|
||||
// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void*
|
||||
// the public session header already uses, so no SDK type leaks; the .cpps cast at
|
||||
// the API boundary.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::persist_detail {
|
||||
|
||||
// Read the active project pointer and its .rpp path in one shot (EnumProjects(-1)).
|
||||
// The out-string receives the full .rpp path, EMPTY for a never-saved project (the
|
||||
// reliable unsaved sentinel). Returns nullptr only when there is no active project.
|
||||
void* readActiveProject(std::string& rppPathOut);
|
||||
|
||||
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
|
||||
// empty out. Delegates to the pure capture::projectDirOfRpp — the SAME convention
|
||||
// the VST3 instrument resolves audio paths by.
|
||||
std::string projectDirOf(const std::string& rppPath);
|
||||
|
||||
// Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns
|
||||
// "" when the key is absent (a valid empty bank, not an error) and warns on the
|
||||
// console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored).
|
||||
// The retry policy itself is the shared pure instrument::map::readProjExtStateGrowing
|
||||
// (Q-W5 rider, T2-04); this wrapper binds the REAPER call + persist's fold.
|
||||
std::string getProjExtStateString(void* proj, const char* ns, const char* key);
|
||||
|
||||
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
|
||||
std::string genProjectGuidString();
|
||||
|
||||
// Ensure a SAVED project carries a stored GUID, minting and writing one if it has
|
||||
// none yet. Returns the effective GUID, or "" for an unsaved project. Called from
|
||||
// BOTH prime and the Load branch (peer-symmetry: no path skips the mint).
|
||||
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
|
||||
const std::string& currentGuid);
|
||||
|
||||
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
|
||||
// move). Best-effort: filesystem errors are swallowed and reported to the console.
|
||||
// Returns true if the copy ran (source existed).
|
||||
bool relocateBankFolder(const std::string& oldBankDir,
|
||||
const std::string& newBankDir);
|
||||
|
||||
} // namespace reasampler::persist_detail
|
||||
@@ -0,0 +1,304 @@
|
||||
// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler
|
||||
// (Q-W5 split of the former persist.cpp; see session.h for the TU map).
|
||||
//
|
||||
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on
|
||||
// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole
|
||||
// deletion authority over the bank folder's bytes (the R3 prune; shells removing a
|
||||
// transient scratch file they themselves just created, e.g. the drop path's temp
|
||||
// .vstpreset, are self-cleanup, not authority over user data). It is deliberately
|
||||
// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5
|
||||
// split CONCENTRATES the deletion authority here — it must never
|
||||
// spread (CONTEXT.md §Phase Q deletion-authority isolation;
|
||||
// docs/product/code-organization.md §7). The safety-critical "which files are
|
||||
// orphans" decision stays in the pure core (prune_reconcile); this TU only
|
||||
// enumerates, resolves, stats, and — after the R3 confirm — executes.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the
|
||||
// persist_detail helpers (active-project read) and usage_scan (the pS-usage
|
||||
// instance-hold reads); this TU itself calls no REAPER API directly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
|
||||
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
|
||||
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
|
||||
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
|
||||
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
|
||||
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
|
||||
#include "shell/persist/persist_internal.h"
|
||||
#include "shell/persist/session.h"
|
||||
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
|
||||
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
using persist_detail::projectDirOf;
|
||||
using persist_detail::readActiveProject;
|
||||
|
||||
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
|
||||
// exact (tallied over the full orphan set), but the enumerated file list handed to the
|
||||
// console is clipped to this many entries so a project with thousands of orphans does
|
||||
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
|
||||
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
|
||||
constexpr std::size_t kPruneListDisplayCap = 64;
|
||||
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
|
||||
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
|
||||
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
|
||||
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
|
||||
// active project, enumerates the folder) but writes nothing.
|
||||
//
|
||||
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
|
||||
// when there is no active/saved project, no project dir, or no folder on
|
||||
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
|
||||
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
||||
// order, untruncated. The pure core decides; this only supplies inputs.
|
||||
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
||||
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
|
||||
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
|
||||
// the prune must halt rather than proceed with degraded protection.
|
||||
// An empty orphan set is itself the delete-side guarantee (every
|
||||
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
|
||||
// what lets the action TELL the user instead of claiming "no orphans".
|
||||
struct PruneScan {
|
||||
std::string bankDirAbs;
|
||||
std::vector<std::string> orphans;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
bool abortedUnreadableUsage = false;
|
||||
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
|
||||
};
|
||||
|
||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
|
||||
PruneScan scanPruneOrphans(const BankBook& book,
|
||||
const model::OwnedFileManifest& owned) {
|
||||
PruneScan scan;
|
||||
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
|
||||
|
||||
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
|
||||
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
|
||||
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
|
||||
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
|
||||
const std::string projectDir = projectDirOf(rppPath);
|
||||
const std::string bankDir =
|
||||
capture::resolveBankFile(projectDir, capture::kBankSubfolder);
|
||||
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
|
||||
return scan; // no bank folder captured yet -> nothing to reclaim
|
||||
}
|
||||
|
||||
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
|
||||
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
|
||||
// convention) so the pure core's exact-string match lines up with referencedPaths()
|
||||
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
|
||||
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
|
||||
// path so the report's byte tally reuses the same on-disk read.
|
||||
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
|
||||
// failure (file removed, permission flip) breaks out with a best-effort partial list
|
||||
// rather than propagating std::filesystem_error across REAPER's C ABI.
|
||||
std::vector<std::string> present;
|
||||
fs::directory_iterator it(bankDir, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
std::error_code reg_ec;
|
||||
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
|
||||
const std::string name = entry.path().filename().string();
|
||||
const std::string rel = capture::bankRelativeForName(name);
|
||||
if (rel.empty()) continue;
|
||||
present.push_back(rel);
|
||||
std::error_code sz_ec;
|
||||
const std::uintmax_t sz = entry.file_size(sz_ec);
|
||||
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
|
||||
}
|
||||
|
||||
// The decision lives in the pure core — read-only inputs from the book and manifest.
|
||||
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
||||
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
|
||||
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
|
||||
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
|
||||
// including the protect-all net when zero instances were identified) — a capture
|
||||
// any live instance holds can NEVER be an orphan, even when its bank entry was
|
||||
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
|
||||
// preserving this scan's no-write contract. This shell only enumerates, resolves,
|
||||
// and stats.
|
||||
scan.bankDirAbs = bankDir;
|
||||
const UsageScanResult usage = liveInstanceHeldPaths(proj);
|
||||
if (usage.abortPrune) {
|
||||
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
|
||||
// protected set is unknowable. Compute NO orphans — every downstream consumer
|
||||
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
|
||||
// nothing. The flag + key names surface the reason so the action can name each
|
||||
// offending key for operator recovery.
|
||||
scan.abortedUnreadableUsage = true;
|
||||
scan.offendingUsageKeys = usage.offendingKeys;
|
||||
return scan;
|
||||
}
|
||||
scan.orphans = reclaim::pruneOrphans(
|
||||
present,
|
||||
reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths),
|
||||
owned.paths());
|
||||
return scan;
|
||||
}
|
||||
|
||||
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
|
||||
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
|
||||
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
|
||||
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
|
||||
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
|
||||
// the caller folds this into skippedCount.
|
||||
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
|
||||
// may cross the C ABI.
|
||||
//
|
||||
// Per-platform routing:
|
||||
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
|
||||
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
|
||||
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
|
||||
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
|
||||
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
|
||||
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
|
||||
// confirm guardrail. `outUsedTrash` left as-is (false).
|
||||
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
|
||||
bool& outAlreadyAbsent) {
|
||||
#ifdef _WIN32
|
||||
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
|
||||
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
|
||||
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
|
||||
std::string win = absPath;
|
||||
for (char& c : win) if (c == '/') c = '\\';
|
||||
|
||||
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
|
||||
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
|
||||
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
|
||||
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
|
||||
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
|
||||
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
|
||||
|
||||
SHFILEOPSTRUCTW op{};
|
||||
op.hwnd = nullptr;
|
||||
op.wFunc = FO_DELETE;
|
||||
op.pFrom = wbuf.data();
|
||||
op.pTo = nullptr;
|
||||
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
|
||||
FOF_SILENT | FOF_NOERRORUI);
|
||||
const int rv = SHFileOperationW(&op);
|
||||
if (rv == 0 && !op.fAnyOperationsAborted) {
|
||||
outUsedTrash = true;
|
||||
return true; // deleted this call -> reclaimed
|
||||
}
|
||||
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
|
||||
// versions, or a lock). Distinguish "already absent" from a real failure so the
|
||||
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
|
||||
std::error_code ec;
|
||||
if (!fs::exists(absPath, ec)) {
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(absPath, ec);
|
||||
if (removed) return true; // deleted this call -> reclaimed
|
||||
if (ec) return false; // a real failure (locked / permission) -> skip
|
||||
// remove returned false with no error == the file did not exist -> already gone.
|
||||
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
||||
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
||||
reclaim::PruneReport report =
|
||||
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
||||
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
|
||||
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
|
||||
// the scan computed nothing — but the user must know the prune refused to run).
|
||||
// The offending key names propagate so the action can name each one for recovery.
|
||||
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
|
||||
report.offendingUsageKeys = scan.offendingUsageKeys;
|
||||
return report;
|
||||
}
|
||||
|
||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
|
||||
}
|
||||
|
||||
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const {
|
||||
reclaim::PruneDeletionResult result;
|
||||
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
|
||||
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
|
||||
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
|
||||
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
||||
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
||||
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
||||
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
|
||||
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
|
||||
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
||||
|
||||
const std::vector<std::string> plan =
|
||||
reclaim::pruneDeletePlan(confirmed, scan.orphans);
|
||||
|
||||
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
|
||||
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
|
||||
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
|
||||
// de-duplicated entries as stale — that would be dishonest.
|
||||
const std::size_t uniqueConfirmedCount =
|
||||
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
|
||||
result.skippedCount += uniqueConfirmedCount - plan.size();
|
||||
|
||||
for (const std::string& rel : plan) {
|
||||
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
|
||||
const std::string::size_type slash = rel.find_last_of('/');
|
||||
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
|
||||
if (name.empty()) { ++result.skippedCount; continue; }
|
||||
const std::string absPath = scan.bankDirAbs + "/" + name;
|
||||
|
||||
const auto szIt = scan.sizeByRel.find(rel);
|
||||
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
|
||||
|
||||
bool alreadyAbsent = false;
|
||||
if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
|
||||
++result.reclaimedCount;
|
||||
result.reclaimedBytes += bytes;
|
||||
} else if (alreadyAbsent) {
|
||||
// File vanished between plan and delete — treat as staleness, same as the
|
||||
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
|
||||
++result.skippedCount;
|
||||
} else {
|
||||
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,222 @@
|
||||
// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5
|
||||
// split of the former persist.cpp; see session.h for the TU map): the poll-driven
|
||||
// identity-transition detection and the deferred undo/redo reload drain.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
|
||||
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
|
||||
// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext
|
||||
// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the
|
||||
// secondary disambiguator (classifyProjectTransition owns the exact order):
|
||||
// * different stored GUID -> a different project of record -> LOAD its index;
|
||||
// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's
|
||||
// address, so a reopened/new project can present the previous pointer with a
|
||||
// different GUID), new/unsaved<->saved, and switching between distinct saved
|
||||
// projects.
|
||||
// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via
|
||||
// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings
|
||||
// diverge going forward.
|
||||
// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new
|
||||
// location -> relocate the bank folder from the old dir to the new one, then
|
||||
// re-GUID.
|
||||
// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only)
|
||||
// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a
|
||||
// fork and its parent share a GUID on disk; switching between them read as a
|
||||
// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer
|
||||
// RECYCLING — a reopened/new project reusing the previous project's address read
|
||||
// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first
|
||||
// catches recycling; the pointer then separates a fork (same GUID, different
|
||||
// object -> Load) from a Save-As (same GUID, same object, new path -> relocate).
|
||||
// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject`
|
||||
// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free
|
||||
// and testable; poll() executes the verdict.
|
||||
//
|
||||
// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
|
||||
// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
|
||||
// a cross-open identity), so we MINT one with genGuid/guidToString and store it
|
||||
// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As
|
||||
// REAPER copies the whole .rpp incl. our ext state, so the new project initially
|
||||
// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked-
|
||||
// sibling Load branch) so identities diverge.
|
||||
//
|
||||
// Rationale for the timer: the brief mandates ext-state storage (rules out the
|
||||
// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
|
||||
// ext-state while covering identity-transition load + Save-As detection in one
|
||||
// place.
|
||||
//
|
||||
// DIVISION OF LABOUR (R-B undo):
|
||||
// * Identity-transition poll (this file, classifyProjectTransition) owns
|
||||
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
|
||||
// where the project OF RECORD changes.
|
||||
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
|
||||
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
|
||||
// identity is unchanged but its ext state rolled back/forward on disk. The
|
||||
// identity poll sees NoOp there and would never re-read ext state, so the hook
|
||||
// requests a reload (requestReload) that poll() drains on the next tick, once
|
||||
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
|
||||
// The hook fires on undo AND redo (isUndo true for both), and on normal open
|
||||
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
|
||||
// flows solely through the identity-transition Load path and never double-loads.
|
||||
|
||||
#include "shell/persist/session.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "shell/persist/ext_state_io.h"
|
||||
#include "shell/persist/persist_internal.h"
|
||||
|
||||
#include "core/capture/capture_paths.h" // classifyProjectTransition / deriveRelocationPlan (pure)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_MarkProjectDirty
|
||||
#define REAPERAPI_WANT_SetProjExtState
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using persist_detail::ensureProjectGuid;
|
||||
using persist_detail::genProjectGuidString;
|
||||
using persist_detail::getProjExtStateString;
|
||||
using persist_detail::projectDirOf;
|
||||
using persist_detail::readActiveProject;
|
||||
using persist_detail::relocateBankFolder;
|
||||
|
||||
bool ReaSamplerSession::consumeLoadSignal() {
|
||||
const bool pending = loadPending_;
|
||||
loadPending_ = false;
|
||||
return pending;
|
||||
}
|
||||
|
||||
void ReaSamplerSession::requestReload() {
|
||||
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
|
||||
// the read is deferred past the projectconfig callback). Cheap and idempotent —
|
||||
// multiple undo/redo callbacks before the next tick collapse to one reload.
|
||||
reloadRequested_ = true;
|
||||
}
|
||||
|
||||
void ReaSamplerSession::poll() {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
const std::string currentGuid =
|
||||
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtGuidKey)
|
||||
: std::string{};
|
||||
|
||||
if (!primed_) {
|
||||
// First observation: adopt current identity and load its index, without
|
||||
// treating it as a "change" (avoids a spurious relocation on startup).
|
||||
primed_ = true;
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
|
||||
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
|
||||
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
|
||||
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
|
||||
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
|
||||
// one or more ticks ago; by NOW REAPER has finished restoring the project's
|
||||
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
|
||||
// current active project and identity-adopt it (no relocation — the path is
|
||||
// unchanged), then return. loadFromProject raises loadPending_, so the existing
|
||||
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
|
||||
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
|
||||
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
|
||||
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
|
||||
if (reloadRequested_) {
|
||||
reloadRequested_ = false;
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pointer identity is the primary signal: a genuine Save-As keeps the SAME
|
||||
// ReaProject* (one object saved elsewhere); a tab-switch/open is a different
|
||||
// object. Passing the bool (not the pointer) keeps the classifier pure.
|
||||
const bool sameProjectObject = (proj == lastProject_);
|
||||
const capture::ProjectTransition transition = capture::classifyProjectTransition(
|
||||
sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath);
|
||||
|
||||
switch (transition) {
|
||||
case capture::ProjectTransition::NoOp:
|
||||
return;
|
||||
|
||||
case capture::ProjectTransition::Load: {
|
||||
// A different project of record is active (open / tab switch / new /
|
||||
// reopened / recycled pointer / forked sibling). Load ITS index; never
|
||||
// relocate.
|
||||
//
|
||||
// Forked-sibling divergence: gate on `!sameProjectObject` so this fires
|
||||
// ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
|
||||
// that copied our GUID and never re-saved (its fresh GUID was runtime-
|
||||
// only on the sibling we came from). A recycled-pointer Load (step 1:
|
||||
// currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
|
||||
// identity. currentGuid == lastGuid_ can only hold here when step 1 did
|
||||
// NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
|
||||
// makes that intent load-bearing rather than incidental. Do this BEFORE
|
||||
// loadFromProject reads the index (order is irrelevant — GUID and
|
||||
// bank_index are distinct keys — but self-contained is clearest).
|
||||
if (proj && !sameProjectObject && !currentGuid.empty() &&
|
||||
currentGuid == lastGuid_ && !rppPath.empty()) {
|
||||
const std::string fresh = genProjectGuidString();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, fresh.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = fresh;
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal load: establish identity the same way prime does.
|
||||
loadFromProject(proj, projectDirOf(rppPath));
|
||||
lastProject_ = proj;
|
||||
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
|
||||
case capture::ProjectTransition::SaveAsRelocate: {
|
||||
// SAME project object + new .rpp path: a genuine Save-As (the pointer
|
||||
// proves it — a fork tab-switch is a DIFFERENT object and took the Load
|
||||
// branch above). Relocate the bank folder from the old dir to the new
|
||||
// one so the wavs sit under the new .rpp and the index's relative paths
|
||||
// still resolve. Keep the in-memory bank as-is (Save-As copied our ext
|
||||
// state, the relative paths are unchanged) — do NOT reload.
|
||||
const std::string oldDir = projectDirOf(lastRppPath_);
|
||||
const std::string newDir = projectDirOf(rppPath);
|
||||
const capture::BankRelocation plan =
|
||||
capture::deriveRelocationPlan(oldDir, newDir);
|
||||
if (plan.needed) {
|
||||
relocateBankFolder(plan.oldBankDir, plan.newBankDir);
|
||||
}
|
||||
|
||||
// Save-As duplicated our ext state, so the new project B currently
|
||||
// shares A's GUID. Mint a FRESH GUID for B and write it, so A and B
|
||||
// no longer collide on identity when reopened later. Adopt the fresh
|
||||
// GUID as our last-seen identity. Mark dirty so the fresh GUID flushes
|
||||
// to the new .rpp on the next normal save / close-prompt.
|
||||
const std::string fresh = genProjectGuidString();
|
||||
if (proj) {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtGuidKey, fresh.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
}
|
||||
lastProject_ = proj; // unchanged (same object) — set for symmetry
|
||||
lastGuid_ = fresh;
|
||||
lastRppPath_ = rppPath;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,311 @@
|
||||
#pragma once
|
||||
// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of
|
||||
// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence &
|
||||
// paths). One class, three implementation TUs by responsibility:
|
||||
//
|
||||
// * session.cpp — poll() (identity-transition detection: load / Save-As /
|
||||
// forked sibling / recycled pointer) + the deferred undo/redo reload drain
|
||||
// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook)
|
||||
// + the D4 load signal.
|
||||
// * ext_state_io.cpp — saveToActiveProject / loadFromProject /
|
||||
// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID
|
||||
// minting and bank-folder relocation (see ext_state_io.h for the key contract).
|
||||
// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan
|
||||
// and THE SINGLE FILE-DELETION AUTHORITY in ReaSampler (deleteOrphanFile via
|
||||
// SHFileOperationW). Nothing else in the system deletes bytes.
|
||||
//
|
||||
// Save: serialize the BankModel JSON -> SetProjExtState under namespace
|
||||
// "reasampler" (ext state lives inside the .rpp, so the index travels with the
|
||||
// project for free).
|
||||
// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory
|
||||
// BankModel, then resolve each entry's bank file against the CURRENT project
|
||||
// dir (project-relative resolution — a project opened from a new location still
|
||||
// finds its bank).
|
||||
// Save-As: when the project path changes, relocate the physical bank folder so
|
||||
// the wavs end up under the new .rpp (the index's relative paths stay valid).
|
||||
//
|
||||
// The header is REAPER-free (no SDK types leak here): callers interact through a
|
||||
// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API
|
||||
// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip
|
||||
// and capture_paths (pure) for the path arithmetic it drives.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/tail_control.h"
|
||||
#include "core/model/bank_book.h"
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/owned_manifest.h"
|
||||
#include "core/reclaim/prune_reconcile.h"
|
||||
#include "core/version/app_version.h"
|
||||
#include "core/view/view_mode_model.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
||||
// against the active REAPER project. One instance lives for the extension's
|
||||
// lifetime (main.cpp). It tracks
|
||||
// the project identity it last saw so the timer tick can detect a project load
|
||||
// (a different project became active) and a Save-As (SAME project, path changed):
|
||||
//
|
||||
// * project load -> load the index from ext state, resolve bank paths
|
||||
// * Save-As (new dir) -> relocate the bank folder under the new .rpp
|
||||
//
|
||||
// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
|
||||
// record, immune to REAPER recycling a closed project's ReaProject* address) is
|
||||
// checked FIRST, and the live pointer disambiguates only the same-GUID case — a
|
||||
// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same
|
||||
// GUID, same object, new path -> relocate). GUID-first catches pointer recycling
|
||||
// (a reopened/new project reusing the previous address with a different GUID — the
|
||||
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
|
||||
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
|
||||
//
|
||||
// The book itself is exposed for the capture/action layer to mutate; persist
|
||||
// only reads it on save and replaces it on load.
|
||||
class ReaSamplerSession {
|
||||
public:
|
||||
ReaSamplerSession() = default;
|
||||
|
||||
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
|
||||
// BankModel, plus the active-bank id. The action layer (B3) creates / renames /
|
||||
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
|
||||
// persist serializes it under the `banks` key on save and replaces it on load.
|
||||
BankBook& book() { return book_; }
|
||||
const BankBook& book() const { return book_; }
|
||||
|
||||
// The capture add-target: the ACTIVE bank's BankModel (defaults to the pool).
|
||||
// The capture path adds a captured Sample through this seam, so a capture lands
|
||||
// in whichever bank is active — the single behavioural change B2 wires in over
|
||||
// M7/M8 (the capture backends are untouched; only the target index moved). The
|
||||
// panel/insert readers that displayed the single index continue to read it here
|
||||
// unchanged; today it resolves to the pool (default active), matching prior
|
||||
// single-bank behaviour, until B3/B4 let the user switch the active bank.
|
||||
model::BankModel& bank() { return book_.activeIndex(); }
|
||||
const model::BankModel& bank() const { return book_.activeIndex(); }
|
||||
|
||||
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
||||
// toggle, snapshot); persist serializes it on save and replaces it on project
|
||||
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
|
||||
// visibility/processing (reapply-on-open) is D4's job, not this member's.
|
||||
ViewModeModel& view() { return view_; }
|
||||
const ViewModeModel& view() const { return view_; }
|
||||
|
||||
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
|
||||
// panel state — so it travels inside the .rpp: persist serializes it on save and
|
||||
// replaces it on project load exactly as it treats the bank and view model. The
|
||||
// panel reads/writes it through this seam (bank_panel holds the session), and the
|
||||
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
|
||||
// an unsaved or pre-feature project (no stored key -> this default survives load).
|
||||
capture::TailSetting& tail() { return tail_; }
|
||||
const capture::TailSetting& tail() const { return tail_; }
|
||||
|
||||
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
|
||||
// capture path itself created. The capture add-path records each created file here
|
||||
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
|
||||
// bank; persist serializes it under the `owned_files` key on save and replaces it on
|
||||
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
|
||||
// B-cap only writes and persists it (no prune logic here).
|
||||
model::OwnedFileManifest& owned() { return owned_; }
|
||||
const model::OwnedFileManifest& owned() const { return owned_; }
|
||||
|
||||
// The ReaSampler version that last WROTE the active project, recovered from its
|
||||
// ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no
|
||||
// stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the
|
||||
// exact stored string otherwise — all silent, never an error. Replaced on every load
|
||||
// path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved
|
||||
// or never-loaded session. Exposed so a future migration step (or diagnostics) can
|
||||
// reason about the origin build without re-reading ext state.
|
||||
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
|
||||
// per project: recovered on load (so it continues from the stored value rather than
|
||||
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
|
||||
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
|
||||
std::int64_t bankGeneration() const { return bankGeneration_; }
|
||||
|
||||
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
|
||||
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
|
||||
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
|
||||
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
|
||||
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
|
||||
// the same mutation already makes (the counter rides the persist blob, so there is no
|
||||
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
|
||||
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
|
||||
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
|
||||
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
|
||||
void bumpBankGeneration() { ++bankGeneration_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
// Safe to call when there is no active/saved project (it no-ops).
|
||||
//
|
||||
// Returns true iff a persist actually happened (an active, SAVED project existed);
|
||||
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
|
||||
// caller wrapping this in an undo block skip the block when nothing was written, so
|
||||
// no dangling no-effect undo entry is opened on an unsaved project.
|
||||
bool saveToActiveProject();
|
||||
|
||||
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
|
||||
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
|
||||
// relative machinery the index/persist use — never a stale absolute path, so it is
|
||||
// correct across a Save-As relocation), spells every enumerated entry with the index's
|
||||
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
||||
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
|
||||
// `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's
|
||||
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
|
||||
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
|
||||
// live instance holds can never be an orphan, so the prune can never delete it.
|
||||
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
|
||||
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
|
||||
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
||||
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
||||
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
||||
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
|
||||
//
|
||||
// Yields an empty report (count 0) when there is no active/saved project or no bank
|
||||
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
|
||||
reclaim::PruneReport pruneDryRun() const;
|
||||
|
||||
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
|
||||
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
|
||||
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
|
||||
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
|
||||
// (pruneDryRun's truncated list is for the console readout; the delete set must be
|
||||
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
|
||||
// no active/saved project or no bank folder yet.
|
||||
std::vector<std::string> pruneOrphanSet() const;
|
||||
|
||||
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
|
||||
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
|
||||
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
|
||||
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
|
||||
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
|
||||
// so a file that vanished or became referenced between confirm and delete is skipped,
|
||||
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
|
||||
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
|
||||
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
|
||||
// std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for
|
||||
// per-platform routing). Non-throwing: every filesystem call uses error_code forms; a
|
||||
// per-file failure (locked, already gone) is recorded and skipped, never thrown across
|
||||
// the C ABI.
|
||||
//
|
||||
// Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does
|
||||
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
|
||||
// algebra naturally once it is off disk — no persist write, so no undo-point question
|
||||
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
|
||||
//
|
||||
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
|
||||
// delete plan is empty (everything went stale). The caller is responsible for having
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
reclaim::PruneDeletionResult pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
|
||||
// `assign_request` key, namespace "reasampler"): the extension telling the active
|
||||
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
|
||||
// encoding (assignment_request.h); this method only routes the already-encoded value
|
||||
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
|
||||
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
|
||||
//
|
||||
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
|
||||
// assignment request is a transient "just assigned" signal the instrument reads and
|
||||
// acts on, so it rides its own key and is written only at ingest time, never on every
|
||||
// book save. Returns true iff written (an active, SAVED project existed); false on a
|
||||
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
|
||||
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
|
||||
bool writeAssignmentRequest(const std::string& wire);
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
//
|
||||
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
|
||||
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
|
||||
// identity classifier below reads it as NoOp and would never re-read ext state.
|
||||
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
|
||||
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
|
||||
// (now-restored) ext state of the current project — before the identity check, so
|
||||
// the undo is reflected in-session without any content polling.
|
||||
void poll();
|
||||
|
||||
// Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext
|
||||
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
|
||||
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
|
||||
// because the projectconfig callback fires BEFORE REAPER has restored the project's
|
||||
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
|
||||
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
|
||||
// is REAPER-facing shell state; the request itself carries no REAPER types.
|
||||
void requestReload();
|
||||
|
||||
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
|
||||
// (re)loads the view model from a project — prime, a project switch/open, or a
|
||||
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
|
||||
// it, so the integration layer (main.cpp) can react by reapplying the saved
|
||||
// active mode's visibility exactly once, then goes quiet on idle ticks.
|
||||
//
|
||||
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
|
||||
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
|
||||
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
|
||||
// where those two already meet. D3 deliberately deferred exactly this to D4.
|
||||
bool consumeLoadSignal();
|
||||
|
||||
private:
|
||||
BankBook book_;
|
||||
|
||||
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
||||
// = Arrange; loadFromProject leaves this default when a project has no stored
|
||||
// view_state (older project), so an absent key is graceful, not a crash.
|
||||
ViewModeModel view_;
|
||||
|
||||
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
|
||||
// to this default when a project has no stored tail_setting key (older / never-
|
||||
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
|
||||
capture::TailSetting tail_;
|
||||
|
||||
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
|
||||
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
|
||||
// a project with no stored manifest must not inherit the previous project's ownership
|
||||
// record, and an undo that rolled back a capture must re-read the restored manifest so
|
||||
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
|
||||
model::OwnedFileManifest owned_;
|
||||
|
||||
// The writing-version stamp recovered on load (Phase V). Default PreVersioning;
|
||||
// loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so
|
||||
// switching to a pre-versioning project reports PreVersioning rather than inheriting
|
||||
// the previous project's stamp. Read-only to consumers via writingVersion().
|
||||
version::WritingVersion writingVersion_;
|
||||
|
||||
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
|
||||
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
|
||||
// continues monotonic from the persisted value across reopen and resets cleanly on a
|
||||
// project switch (a different project's counter, not the previous one's). bumped by
|
||||
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
|
||||
// Default 0 for an unsaved / never-loaded / pre-S9 session.
|
||||
std::int64_t bankGeneration_ = 0;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
// same-GUID case (different object = forked sibling -> Load; same object + new
|
||||
// path -> Save-As) and drives forked-sibling re-divergence; the path tells a
|
||||
// Save-As from an idle tick.
|
||||
// Held as void* so the header stays REAPER-free; it is a compared-only opaque
|
||||
// handle (never dereferenced), so a stale/recycled address is harmless.
|
||||
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
|
||||
std::string lastGuid_; // "" until the first saved project is seen
|
||||
std::string lastRppPath_; // .rpp path last seen for lastProject_
|
||||
bool primed_ = false; // false until the first poll() observes state
|
||||
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
|
||||
bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll
|
||||
|
||||
// Load the book from the given project's ext state (the `banks` key, else the
|
||||
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
|
||||
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
|
||||
// and owned_ from their sibling keys on every load path. projectDir empty -> the
|
||||
// book is reset to empty (unsaved project has no resolvable banks).
|
||||
void loadFromProject(void* proj, const std::string& projectDir);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles)
|
||||
#include "core/instrument/map/bridge_marshal.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
|
||||
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
|
||||
#include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
|
||||
#include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions)
|
||||
@@ -166,22 +167,23 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
|
||||
// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the
|
||||
// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large
|
||||
// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a
|
||||
// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe —
|
||||
// silently reduced protection is the delete direction).
|
||||
// Growing GetProjExtState read: the usage record scales with the hold count, so a
|
||||
// fixed buffer risks a truncated decode. The retry policy is the SHARED pure
|
||||
// instrument::map::readProjExtStateGrowing (Q-W5 rider T2-04 — one loop for persist,
|
||||
// this prune-safety-adjacent read, and the VST bridge; the rules cannot drift).
|
||||
// Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration
|
||||
// (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys
|
||||
// the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record:
|
||||
// it folds to abortPrune (fail-safe — silently reduced protection is the delete
|
||||
// direction).
|
||||
std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key) {
|
||||
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt;
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
||||
// else possibly truncated -> grow and retry
|
||||
}
|
||||
return std::nullopt; // > 16 MB — unreadable whole, never "absent"
|
||||
using instrument::map::GrowingExtStateRead;
|
||||
const GrowingExtStateRead read = instrument::map::readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
return GetProjExtState(proj, kProjExtNamespace(), key, buf, cap);
|
||||
});
|
||||
if (read.status != GrowingExtStateRead::Status::Complete) return std::nullopt;
|
||||
return read.value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Reference in New Issue
Block a user