affde0ef53
classifyProjectTransition now checks the stored GUID first, then the pointer, fixing the w10 regression where a recycled ReaProject* address stopped the bank reloading. poll()'s fork re-GUID gate is bound to !sameProjectObject. Full transition matrix pinned in tests.
385 lines
18 KiB
C++
385 lines
18 KiB
C++
// 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 <filesystem>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<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());
|
|
|
|
// 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<ReaProject*>(proj), kProjExtNamespace,
|
|
kProjExtViewKey, viewJson.c_str());
|
|
|
|
MarkProjectDirty(static_cast<ReaProject*>(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<ViewModeModel> 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<ReaProject*>(proj));
|
|
|
|
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
|
|
|
|
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<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));
|
|
lastProject_ = proj;
|
|
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
|
lastRppPath_ = rppPath;
|
|
return;
|
|
}
|
|
|
|
// Pointer identity is the primary signal: a genuine Save-As keeps the SAME
|
|
// ReaProject* (one object saved elsewhere); a tab-switch/open is a different
|
|
// object. Passing the bool (not the pointer) keeps the classifier pure.
|
|
const bool sameProjectObject = (proj == lastProject_);
|
|
const ProjectTransition transition = classifyProjectTransition(
|
|
sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath);
|
|
|
|
switch (transition) {
|
|
case ProjectTransition::NoOp:
|
|
return;
|
|
|
|
case ProjectTransition::Load: {
|
|
// A different project of record is active (open / tab switch / new /
|
|
// reopened / recycled pointer / forked sibling). Load ITS index; never
|
|
// relocate.
|
|
//
|
|
// Forked-sibling divergence: gate on `!sameProjectObject` so this fires
|
|
// ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
|
|
// that copied our GUID and never re-saved (its fresh GUID was runtime-
|
|
// only on the sibling we came from). A recycled-pointer Load (step 1:
|
|
// currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
|
|
// identity. currentGuid == lastGuid_ can only hold here when step 1 did
|
|
// NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
|
|
// makes that intent load-bearing rather than incidental. Do this BEFORE
|
|
// loadFromProject reads the index (order is irrelevant — GUID and
|
|
// bank_index are distinct keys — but self-contained is clearest).
|
|
if (proj && !sameProjectObject && !currentGuid.empty() &&
|
|
currentGuid == lastGuid_ && !rppPath.empty()) {
|
|
const std::string fresh = genProjectGuidString();
|
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
|
kProjExtGuidKey, fresh.c_str());
|
|
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
|
loadFromProject(proj, projectDirOf(rppPath));
|
|
lastProject_ = proj;
|
|
lastGuid_ = fresh;
|
|
lastRppPath_ = rppPath;
|
|
return;
|
|
}
|
|
|
|
// Normal load: establish identity the same way prime does.
|
|
loadFromProject(proj, projectDirOf(rppPath));
|
|
lastProject_ = proj;
|
|
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
|
|
lastRppPath_ = rppPath;
|
|
return;
|
|
}
|
|
|
|
case ProjectTransition::SaveAsRelocate: {
|
|
// SAME project object + new .rpp path: a genuine Save-As (the pointer
|
|
// proves it — a fork tab-switch is a DIFFERENT object and took the Load
|
|
// branch above). Relocate the bank folder from the old dir to the new
|
|
// one so the wavs sit under the new .rpp and the index's relative paths
|
|
// still resolve. Keep the in-memory bank as-is (Save-As copied our ext
|
|
// state, the relative paths are unchanged) — do NOT reload.
|
|
const std::string oldDir = projectDirOf(lastRppPath_);
|
|
const std::string newDir = projectDirOf(rppPath);
|
|
const BankRelocation plan = deriveRelocationPlan(oldDir, newDir);
|
|
if (plan.needed) {
|
|
relocateBankFolder(plan.oldBankDir, plan.newBankDir);
|
|
}
|
|
|
|
// Save-As duplicated our ext state, so the new project B currently
|
|
// shares A's GUID. Mint a FRESH GUID for B and write it, so A and B
|
|
// no longer collide on identity when reopened later. Adopt the fresh
|
|
// GUID as our last-seen identity. Mark dirty so the fresh GUID flushes
|
|
// to the new .rpp on the next normal save / close-prompt.
|
|
const std::string fresh = genProjectGuidString();
|
|
if (proj) {
|
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
|
kProjExtGuidKey, fresh.c_str());
|
|
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
|
}
|
|
lastProject_ = proj; // unchanged (same object) — set for symmetry
|
|
lastGuid_ = fresh;
|
|
lastRppPath_ = rppPath;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
} // namespace reasampler
|