diff --git a/CMakeLists.txt b/CMakeLists.txt index c491bb3..304e682 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -267,7 +267,9 @@ target_link_libraries(slot_map PRIVATE json) # sample between banks, JSON round-trip + legacy-bank_index→pool migration. # Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). # --------------------------------------------------------------------------- -add_library(bank_book STATIC src/core/model/bank_book.cpp) +add_library(bank_book STATIC + src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp) target_include_directories(bank_book PUBLIC src) target_link_libraries(bank_book PUBLIC bank_model) target_link_libraries(bank_book PUBLIC slot_map) @@ -822,8 +824,10 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math # (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge -# read marshalling — GetProjExtState result decode + a small JSON string-field -# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; +# read marshalling — GetProjExtState result decode + the ONE grow-loop retry +# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge +# read AND the extension's persist/usage_scan ext-state reads (hence linked into +# reaper_reasampler too). Both are unit-tested outside the DAW; # the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified. # --------------------------------------------------------------------------- add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp) @@ -1100,7 +1104,9 @@ add_library(reaper_reasampler MODULE src/shell/capture/capture_realtime_shell.cpp src/shell/capture/capture_realtime_finalize.cpp src/core/capture/capture_realtime.cpp - src/persist.cpp + src/shell/persist/session.cpp + src/shell/persist/ext_state_io.cpp + src/shell/persist/prune_fs.cpp src/shell/panel/panel_audition.cpp src/shell/panel/panel_bank_ops.cpp src/shell/panel/panel_drag.cpp @@ -1129,6 +1135,7 @@ add_library(reaper_reasampler MODULE src/shell/actions/prune_action.cpp src/ingest.cpp src/core/model/bank_book.cpp + src/core/model/bank_book_json.cpp src/core/model/owned_manifest.cpp src/shell/actions/drag_out_win.cpp src/shell/actions/instrument_drop_win.cpp @@ -1141,7 +1148,7 @@ add_library(reaper_reasampler MODULE src/core/ui/card_drag.cpp src/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync bridge_marshal sample_usage) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index 9e918f4..d63db44 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -21,6 +21,8 @@ #include #include +#include +#include namespace reasampler::instrument::map { @@ -34,4 +36,62 @@ namespace reasampler::instrument::map { std::optional 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 +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 buf(static_cast(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(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 diff --git a/src/core/model/bank_book.cpp b/src/core/model/bank_book.cpp index 20372ac..4700ae6 100644 --- a/src/core/model/bank_book.cpp +++ b/src/core/model/bank_book.cpp @@ -3,18 +3,13 @@ #include #include -#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 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: }. - // 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>& 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> 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>& 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& 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 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 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&& 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::deserialize(const std::string& blob) { - std::vector 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) // --------------------------------------------------------------------------- diff --git a/src/core/model/bank_book.h b/src/core/model/bank_book.h index 6a66fe6..333d39b 100644 --- a/src/core/model/bank_book.h +++ b/src/core/model/bank_book.h @@ -342,6 +342,16 @@ private: std::vector 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. diff --git a/src/core/model/bank_book_json.cpp b/src/core/model/bank_book_json.cpp new file mode 100644 index 0000000..d3b2289 --- /dev/null +++ b/src/core/model/bank_book_json.cpp @@ -0,0 +1,309 @@ +#include "core/model/bank_book.h" + +#include +#include +#include + +#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: }. + // 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>& 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> 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>& 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& 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 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&& 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::deserialize(const std::string& blob) { + std::vector 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 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 diff --git a/src/persist.cpp b/src/persist.cpp deleted file mode 100644 index dfb3e8f..0000000 --- a/src/persist.cpp +++ /dev/null @@ -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 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 -#include -#include -#include -#include -#include - -// 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 -#include -#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 buf(4096, '\0'); - ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(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 buf(static_cast(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(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(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(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(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(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(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(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(proj), projExtNamespace(), - kProjExtBankGenKey, - instrument::map::formatBankGeneration(bankGeneration_).c_str()); - - MarkProjectDirty(static_cast(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(proj), projExtNamespace(), - kProjExtAssignKey, wire.c_str()); - MarkProjectDirty(static_cast(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 orphans; - std::unordered_map sizeByRel; - bool abortedUnreadableUsage = false; - std::vector 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 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(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 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=, 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 wbuf(static_cast(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(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& 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 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(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 "/"; 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 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 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 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(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(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(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(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(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(proj), projExtNamespace(), - kProjExtBanksKey); - if (!banksJson.empty()) { - std::optional 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(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(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(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 - // 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(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(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(proj), projExtNamespace(), - kProjExtGuidKey, fresh.c_str()); - MarkProjectDirty(static_cast(proj)); - } - lastProject_ = proj; // unchanged (same object) — set for symmetry - lastGuid_ = fresh; - lastRppPath_ = rppPath; - return; - } - } -} - -} // namespace reasampler diff --git a/src/persist.h b/src/persist.h index 74a3d37..8baa1ae 100644 --- a/src/persist.h +++ b/src/persist.h @@ -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 -#include - -#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 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& 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 - // 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" diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index 91fa451..fc2c3b9 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -93,21 +93,19 @@ std::optional 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 buf(static_cast(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(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, diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp new file mode 100644 index 0000000..f5c7e03 --- /dev/null +++ b/src/shell/persist/ext_state_io.cpp @@ -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 +#include +#include +#include +#include +#include + +#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 buf(4096, '\0'); + ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(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(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(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(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(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(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(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(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(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(proj), projExtNamespace(), + kProjExtBankGenKey, + instrument::map::formatBankGeneration(bankGeneration_).c_str()); + + MarkProjectDirty(static_cast(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(proj), projExtNamespace(), + kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(static_cast(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 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 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 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(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(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(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 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 diff --git a/src/shell/persist/ext_state_io.h b/src/shell/persist/ext_state_io.h new file mode 100644 index 0000000..ab5a389 --- /dev/null +++ b/src/shell/persist/ext_state_io.h @@ -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 diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h new file mode 100644 index 0000000..7d41a0f --- /dev/null +++ b/src/shell/persist/persist_internal.h @@ -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 + +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 diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp new file mode 100644 index 0000000..f5c7f27 --- /dev/null +++ b/src/shell/persist/prune_fs.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +// 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 +#include +#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 orphans; + std::unordered_map sizeByRel; + bool abortedUnreadableUsage = false; + std::vector 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 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(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=, 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 wbuf(static_cast(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(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 ReaSamplerSession::pruneOrphanSet() const { + return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated +} + +reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( + const std::vector& 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 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(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 "/"; 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 diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp new file mode 100644 index 0000000..f84b664 --- /dev/null +++ b/src/shell/persist/session.cpp @@ -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 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 + +#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 + // 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(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(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(proj), projExtNamespace(), + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(proj)); + } + lastProject_ = proj; // unchanged (same object) — set for symmetry + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + } +} + +} // namespace reasampler diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h new file mode 100644 index 0000000..b9c7bc7 --- /dev/null +++ b/src/shell/persist/session.h @@ -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 +#include +#include + +#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 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& 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 + // 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 diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 416ab52..82adb98 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -27,6 +27,7 @@ #include #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 readExtStateValue(ReaProject* proj, const char* key) { - for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) { - std::vector buf(static_cast(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(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 diff --git a/tests/test_bridge_marshal.cpp b/tests/test_bridge_marshal.cpp index 926f1d2..04730cd 100644 --- a/tests/test_bridge_marshal.cpp +++ b/tests/test_bridge_marshal.cpp @@ -6,11 +6,17 @@ // Covers: decodeGetProjExtState hit/absent/zero-return/empty-buffer (the stale-buffer // guard). The S1 spike's extractJsonStringField string-scan reader was retired in S4 // (the instrument now parses the bank through the shared bank_book JSON path), so its -// cases are gone with it. +// cases are gone with it. Q-W5 (rider T2-04) adds readProjExtStateGrowing — the ONE +// grow-loop retry policy shared by persist / usage_scan / reaper_bridge — covered +// against a fake read: absent, small-fit, grow-then-fit, empty-complete (composed with +// the decode guard), and the 16 MB overflow give-up. The overflow case is +// prune-safety-adjacent (usage_scan folds it to abortPrune), so it is pinned here. #include "../src/core/instrument/map/bridge_marshal.h" #include +#include +#include using namespace reasampler; using namespace reasampler::instrument::map; @@ -46,12 +52,100 @@ static void testDecodeEmptyBuffer() { CHECK(!v.has_value()); } +// --- readProjExtStateGrowing (the T2-04 shared grow-loop policy) ------------- + +// A fake GetProjExtState: honors the buffer contract (writes at most cap-1 chars + +// NUL — REAPER clips to the caller's capacity) and returns the stored value's length. +static int fakeRead(const std::string& stored, char* buf, int cap) { + const std::size_t n = + stored.size() < static_cast(cap - 1) + ? stored.size() + : static_cast(cap - 1); + std::memcpy(buf, stored.data(), n); + buf[n] = '\0'; + return static_cast(stored.size()); +} + +static void testGrowingAbsent() { + // rv <= 0 on the first attempt: the key holds no value — Absent, no retry. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char*, int) { + ++calls; + return 0; + }); + CHECK(r.status == GrowingExtStateRead::Status::Absent); + CHECK(r.apiReturn == 0); + CHECK(calls == 1); +} + +static void testGrowingSmallValueFitsFirstAttempt() { + const std::string stored = "hello"; + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(r.apiReturn == 5); + CHECK(calls == 1); +} + +static void testGrowingRetriesUntilStrictFit() { + // A value whose C string exactly fills the first buffer (size+1 == cap) is + // AMBIGUOUS — it may have been clipped — so the policy must retry at the next + // capacity, where it fits strictly and returns whole. + const std::string stored(static_cast((1 << 16) - 1), 'x'); + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + return fakeRead(stored, buf, cap); + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value == stored); + CHECK(calls == 2); +} + +static void testGrowingEmptyCompleteComposesWithDecodeGuard() { + // rv > 0 but an empty buffer: the loop reports a Complete empty value, and the + // bridge path's decodeGetProjExtState(apiReturn, value) still rejects it — the + // stale/empty-buffer guard survives the T2-04 rewire unchanged. + const auto r = readProjExtStateGrowing([&](char* buf, int) { + buf[0] = '\0'; + return 3; + }); + CHECK(r.status == GrowingExtStateRead::Status::Complete); + CHECK(r.value.empty()); + CHECK(!decodeGetProjExtState(r.apiReturn, r.value).has_value()); +} + +static void testGrowingOverflowGivesUpAtCeiling() { + // Every attempt clips (the value never fits under 16 MB): Overflow — which is + // "unreadable WHOLE", never Absent. usage_scan folds this to the prune fail-safe + // abort, so the distinction is load-bearing. Caps run 2^16..2^24 step ×4 = 5 tries. + int calls = 0; + const auto r = readProjExtStateGrowing([&](char* buf, int cap) { + ++calls; + std::memset(buf, 'a', static_cast(cap - 1)); + buf[cap - 1] = '\0'; + return cap; // reports a length that never strictly fits + }); + CHECK(r.status == GrowingExtStateRead::Status::Overflow); + CHECK(calls == 5); +} + int main() { testDecodeHit(); testDecodeAbsentKey(); testDecodeNegativeReturn(); testDecodeEmptyBuffer(); + testGrowingAbsent(); + testGrowingSmallValueFitsFirstAttempt(); + testGrowingRetriesUntilStrictFit(); + testGrowingEmptyCompleteComposesWithDecodeGuard(); + testGrowingOverflowGivesUpAtCeiling(); + if (g_fail == 0) std::printf("bridge_marshal: all tests passed\n"); return g_fail != 0; }