feat(persist): M4 bank persistence + Save-As relocation

Serialize BankIndex to project ext state ('reasampler'), reload on project
load, resolve paths project-relative. Save-As copies the bank to the new .rpp;
identity keyed off a minted GUID (not the recycled ReaProject*) so project
switches don't clobber banks. Pure classifyProjectTransition tested.
This commit is contained in:
2026-07-22 19:55:09 -04:00
parent a9ba532f07
commit 53da916917
7 changed files with 683 additions and 6 deletions
+282
View File
@@ -0,0 +1,282 @@
// 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 that GUID — CONTENT-BASED, not the ReaProject* pointer:
// * GUID changed -> a different project became active (open a project, switch
// tab, new project, OR a recycled pointer) -> LOAD its index from ext state.
// * same GUID, .rpp path changed -> genuine Save-As to a new location ->
// relocate the bank folder from the old dir to the new one.
// Why not the pointer: REAPER recycles a closed project's ReaProject* address
// for a newly-active project. Keying Save-As off "same pointer + new path" let
// that recycling read as a Save-As and copy the wrong bank over a real one
// (the M4 defect). classifyProjectTransition (pure, capture_paths) encodes the
// decision; poll() only supplies the observed identity and 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 so the two projects' identities diverge going forward.
//
// 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 <filesystem>
#include <string>
#include <vector>
#include "capture_paths.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
namespace fs = std::filesystem;
// Read the active project pointer and its .rpp path in one shot. idx=-1 is the
// current project tab (SDK header line ~1262). The out-buffer receives the full
// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel —
// same fact capture.cpp relies on). Returns nullptr proj only when there is no
// active project at all.
void* readActiveProject(std::string& rppPathOut) {
std::vector<char> buf(4096, '\0');
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
rppPathOut.assign(buf.data());
return proj;
}
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
// for the full rationale). 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<char> buf(static_cast<size_t>(cap), '\0');
int rv = GetProjExtState(proj, ns, key, buf.data(), cap);
if (rv <= 0) return {}; // absent / empty -> empty bank
// If the written string fits strictly inside the buffer it is complete.
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) return s;
// else: possibly truncated -> grow and retry.
}
// Pathologically large (>16 MB) — give up rather than loop forever. Warn on
// the console so this reads as "too large to load", not silent data loss
// (mirrors the malformed-JSON warning in loadFromProject).
ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
"' exceeds the 16 MB read ceiling — ignoring (bank not "
"loaded).\n").c_str());
return {};
}
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
// guidToString wants a >=64-char destination (SDK header line ~3846).
std::string genProjectGuidString() {
GUID g{};
genGuid(&g);
char buf[64] = {0};
guidToString(&g, buf);
return std::string(buf);
}
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
// files at the destination so a re-save is idempotent. Best-effort: filesystem
// errors are swallowed and reported to the console rather than thrown across the
// REAPER boundary. Returns true if the copy ran (source existed).
bool relocateBankFolder(const std::string& oldBankDir,
const std::string& newBankDir) {
std::error_code ec;
if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
return false; // nothing at the old location to relocate
}
if (oldBankDir == newBankDir) return false; // defensive; plan guards this too
fs::create_directories(newBankDir, ec);
fs::copy(oldBankDir, newBankDir,
fs::copy_options::recursive | fs::copy_options::overwrite_existing,
ec);
if (ec) {
ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
"' failed: " + ec.message() + "\n").c_str());
return false;
}
return true;
}
} // namespace
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<ReaProject*>(proj), kProjExtNamespace,
kProjExtIndexKey, json.c_str());
}
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
if (!proj) {
bank_ = BankIndex{};
return;
}
const std::string json =
getProjExtStateString(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtIndexKey);
if (json.empty()) {
// No stored index (new or never-captured project) — start empty.
bank_ = BankIndex{};
return;
}
std::optional<BankIndex> 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<ReaProject*>(proj), kProjExtNamespace,
kProjExtGuidKey, minted.c_str());
return minted;
}
} // namespace
void ReaSamplerSession::poll() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
const std::string currentGuid =
proj ? getProjExtStateString(static_cast<ReaProject*>(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));
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
return;
}
const ProjectTransition transition =
classifyProjectTransition(lastGuid_, lastRppPath_, currentGuid, rppPath);
switch (transition) {
case ProjectTransition::NoOp:
return;
case ProjectTransition::Load:
// A different project is active (open / tab switch / new project /
// recycled pointer). Load ITS index; never relocate. Establish its
// identity the same way prime does.
loadFromProject(proj, projectDirOf(rppPath));
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
return;
case ProjectTransition::SaveAsRelocate: {
// Same GUID + new .rpp path: a genuine Save-As. 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.
const std::string fresh = genProjectGuidString();
if (proj) {
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtGuidKey, fresh.c_str());
}
lastGuid_ = fresh;
lastRppPath_ = rppPath;
return;
}
}
}
} // namespace reasampler