// persist.cpp — REAPER-facing implementation of the BankIndex <-> 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", key // "bank_index". Ext state is stored INSIDE the .rpp, so the index travels 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 index's 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), and the timer composes cleanly with ext-state // while covering both load and Save-As detection in one place. The // `projectconfig` BeginLoadProjectState hook is a deterministic alternative for // pure load detection but would still need the timer (or Main_SaveProject // post-hook) for Save-As path-change detection — surfaced in the handoff. // // 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 "capture_paths.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 { 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). normalizeSlashes lives in capture_paths (pure). std::string projectDirOf(const std::string& rppPath) { if (rppPath.empty()) return {}; std::string dir = fs::path(rppPath).parent_path().string(); return normalizeSlashes(dir); } // 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 void ReaSamplerSession::saveToActiveProject() { std::string rppPath; void* proj = readActiveProject(rppPath); if (!proj) return; // no active project — nothing to persist if (rppPath.empty()) return; // unsaved project — no .rpp to store into const std::string json = bank_.serialize(); SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtIndexKey, json.c_str()); // Additive: the Design-View model rides alongside the bank in its own key. // Independent write — does not disturb the bank_index above. const std::string viewJson = view_.serialize(); SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtViewKey, viewJson.c_str()); MarkProjectDirty(static_cast(proj)); } 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, kProjExtNamespace, 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); } } // 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)); if (!proj) { bank_ = BankIndex{}; return; } const std::string json = getProjExtStateString(static_cast(proj), kProjExtNamespace, kProjExtIndexKey); if (json.empty()) { // No stored index (new or never-captured project) — start empty. bank_ = BankIndex{}; return; } std::optional loaded = BankIndex::deserialize(json); if (!loaded) { ShowConsoleMsg("ReaSampler: stored bank index is malformed — ignoring.\n"); bank_ = BankIndex{}; return; } bank_ = std::move(*loaded); // Project-relative resolution is a READ-time concern: the index stores only // relative paths (invariant), and consumers (M5 panel, M6 insert) resolve // each entry against the CURRENT project dir via resolveBankFile(projectDir, // relativePath). We do NOT rewrite the stored paths to absolute here — that // would break the relative-only invariant and the travel-with-.rpp property. // projectDir is threaded through for those consumers; nothing to do at load // time beyond replacing the in-memory bank. (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), kProjExtNamespace, kProjExtGuidKey, minted.c_str()); return minted; } } // namespace bool ReaSamplerSession::consumeLoadSignal() { const bool pending = loadPending_; loadPending_ = false; return pending; } void ReaSamplerSession::poll() { std::string rppPath; void* proj = readActiveProject(rppPath); const std::string currentGuid = proj ? getProjExtStateString(static_cast(proj), kProjExtNamespace, 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; 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), kProjExtNamespace, 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), kProjExtNamespace, kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); } lastProject_ = proj; // unchanged (same object) — set for symmetry lastGuid_ = fresh; lastRppPath_ = rppPath; return; } } } } // namespace reasampler