Merge M4: bank persistence to project ext state + Save-As relocation

This commit is contained in:
2026-07-22 19:57:25 -04:00
7 changed files with 683 additions and 6 deletions
+1
View File
@@ -58,6 +58,7 @@ add_test(NAME capture_paths_tests COMMAND capture_paths_tests)
add_library(reaper_reasampler MODULE add_library(reaper_reasampler MODULE
src/main.cpp src/main.cpp
src/capture.cpp src/capture.cpp
src/persist.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
+60
View File
@@ -73,4 +73,64 @@ BankPaths deriveBankPaths(const std::string& projectDir,
return p; return p;
} }
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
// relative path yields empty, not a bare relative path resolved against CWD.
if (projectDir.empty() || relativePath.empty()) {
return {};
}
const std::string dir = normalizeSlashes(projectDir);
const std::string rel = normalizeSlashes(relativePath);
if (dir.empty() || rel.empty()) {
return {};
}
return dir + "/" + rel;
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
if (oldProjectDir.empty() || newProjectDir.empty()) {
return r; // needed=false, empty dirs — nothing to relocate
}
const std::string oldDir = normalizeSlashes(oldProjectDir);
const std::string newDir = normalizeSlashes(newProjectDir);
r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
return r;
}
ProjectTransition classifyProjectTransition(const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath) {
// A changed GUID is the unambiguous signal of a different project — load it.
// This is the whole point of the content-based identity: it survives pointer
// recycling that the old pointer-equality check could not distinguish from a
// Save-As.
if (currentGuid != lastGuid) {
return ProjectTransition::Load;
}
// Same GUID from here on. If it is empty, we have NO proof the two ticks saw
// the same project (an unsaved project can't carry a stored GUID). A path
// change under an empty GUID is therefore a first-save or a switch between
// unsaved projects — load, never relocate (relocating would copy the wrong
// bank over a real one, the defect being fixed).
if (currentGuid.empty()) {
return (currentPath == lastPath) ? ProjectTransition::NoOp
: ProjectTransition::Load;
}
// Same NON-EMPTY GUID: proven same project. A path change is a genuine
// Save-As to a new location; an unchanged path is Save-in-place / idle.
return (currentPath == lastPath) ? ProjectTransition::NoOp
: ProjectTransition::SaveAsRelocate;
}
} // namespace reasampler } // namespace reasampler
+76
View File
@@ -53,4 +53,80 @@ BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName, const std::string& baseName,
const std::string& uniqueTag); const std::string& uniqueTag);
// --- Persist-side path arithmetic (M4) --------------------------------------
//
// The index stores relative paths only; on project load the persist shell must
// turn each entry's relativePath back into an absolute path against the CURRENT
// project directory (so a project opened from a new location still resolves its
// bank). This is the inverse of the relativePath the capture path produced.
//
// projectDir : absolute directory of the current .rpp (any slash style)
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
//
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
// a caller that ignores an unsaved/unset project fails loudly rather than
// resolving against CWD.
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which is what moved with the .rpp), so relocation is purely a
// folder move: copy/move the whole bank subfolder from the old project dir to
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/reasampler_bank
bool needed = false; // false when old==new (Save in place, not Save-As)
};
// Derives the relocation plan from the old and new project directories.
// oldProjectDir : project dir the bank currently sits under (any slash style)
// newProjectDir : project dir the .rpp was just saved to (any slash style)
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
// Returns a plan with empty dirs and needed=false when either input is empty.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir);
// --- Project-identity transition (M4 defect fix) ----------------------------
//
// What the persist timer must do on each tick, decided purely from the LAST
// observed identity and the CURRENT one. Identity is CONTENT-BASED: a project
// GUID we mint and store in our ext state (REAPER exposes no stable per-project
// GUID). The raw ReaProject* is deliberately NOT part of this decision — REAPER
// recycles pointer addresses across project close/open, and keying Save-As off
// the pointer let a project switch masquerade as a Save-As and clobber a bank.
enum class ProjectTransition {
NoOp, // same project, same location — nothing to do
Load, // a different project is active — load ITS index from ext state
SaveAsRelocate, // same project, new .rpp location — relocate the bank folder
};
// Classifies what a poll tick observed.
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
// lastPath : that project's .rpp path when last seen ("" if unsaved)
// currentGuid : the GUID stored in the now-active project's ext state ("" if
// unsaved or never written)
// currentPath : the now-active project's .rpp path ("" if unsaved)
//
// Rules (GUID is the identity; path only distinguishes Save vs Save-As within
// the SAME identity):
// * currentGuid != lastGuid -> Load (a different project)
// * same non-empty GUID, currentPath == lastPath -> NoOp (Save in place / idle)
// * same non-empty GUID, currentPath != lastPath -> SaveAsRelocate
// * both GUIDs empty, same path -> NoOp (idle unsaved project)
// * both GUIDs empty, different path -> Load (can't PROVE same
// project without a GUID — a
// first-save or a switch
// between unsaved projects;
// never a relocate)
// The both-empty/different-path -> Load rule is what makes the recycled-pointer
// bug impossible: absent GUID corroboration, a path change is treated as a new
// project (safe: load), never a relocate (destructive: copy-over).
ProjectTransition classifyProjectTransition(const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath);
} // namespace reasampler } // namespace reasampler
+28 -6
View File
@@ -22,6 +22,7 @@
#include "bank_model.h" #include "bank_model.h"
#include "capture.h" #include "capture.h"
#include "persist.h"
// Persistent action-id prefix for the ReaSampler action family. // Persistent action-id prefix for the ReaSampler action family.
// Every bindable action (capture / insert / slot / verify) mints its command id // Every bindable action (capture / insert / slot / verify) mints its command id
@@ -43,10 +44,20 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// (user keybindings key off it) — see the prefix note above. // (user keybindings key off it) — see the prefix note above.
static int g_cmdCaptureMasterSpike = 0; static int g_cmdCaptureMasterSpike = 0;
// In-memory bank for the spike. M4 replaces this with project ext-state persist; // The persistence session (M4): owns the in-memory BankIndex and bridges it to
// for M3 the index lives only for the session, proving the capture->Sample->add // project ext state. A timer tick drives g_session.poll() to detect project
// path end to end. // load / Save-As; capture adds Samples to g_session.bank(); after a capture we
static reasampler::BankIndex g_bank; // serialize the bank back into the active project's ext state so it travels with
// the .rpp. Replaces the M3 session-only g_bank.
static reasampler::ReaSamplerSession g_session;
// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
g_session.poll();
}
// Runs the M3 spike: render the time-selection master mix, add the Sample, log. // Runs the M3 spike: render the time-selection master mix, add the Sample, log.
static void RunCaptureMasterSpike() static void RunCaptureMasterSpike()
@@ -76,9 +87,14 @@ static void RunCaptureMasterSpike()
return; return;
} }
reasampler::AddResult added = g_bank.add(res.sample); reasampler::AddResult added = g_session.bank().add(res.sample);
// Persist the updated bank into the active project's ext state so the capture
// survives Save / close+reopen (M4). Non-destructive: writes only our own
// ext-state key. No-ops on an unsaved project (nothing to store into yet).
g_session.saveToActiveProject();
std::string log = "ReaSampler: " + res.message + "\n"; std::string log = "ReaSampler: " + res.message + "\n";
log += " bank size now " + std::to_string(g_bank.size()) + log += " bank size now " + std::to_string(g_session.bank().size()) +
(added == reasampler::AddResult::Added ? " (added)\n" (added == reasampler::AddResult::Added ? " (added)\n"
: added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n" : added == reasampler::AddResult::Collapsed ? " (collapsed on hash)\n"
: " (rejected)\n"); : " (rejected)\n");
@@ -106,6 +122,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// callback with the same strings prefixed '-' (per the contract). // callback with the same strings prefixed '-' (per the contract).
if (g_rec) if (g_rec)
{ {
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-hookcommand", (void*)&OnHookCommand); g_rec->Register("-hookcommand", (void*)&OnHookCommand);
g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster); g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster);
g_rec->Register("-command_id", g_rec->Register("-command_id",
@@ -139,6 +156,11 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("hookcommand", (void*)&OnHookCommand); rec->Register("hookcommand", (void*)&OnHookCommand);
} }
// Drive project-load / Save-As detection (M4 persist). The timer polls the
// active project each tick; on a project load it reloads the bank from ext
// state, on a Save-As it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
ShowConsoleMsg("ReaSampler loaded.\n"); ShowConsoleMsg("ReaSampler loaded.\n");
return 1; // success — REAPER keeps us loaded return 1; // success — REAPER keeps us loaded
+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
+93
View File
@@ -0,0 +1,93 @@
#pragma once
// persist — the REAPER-facing bridge between the in-memory BankIndex and project
// ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths).
//
// Save: serialize the BankIndex 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
// BankIndex, 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 persist.cpp. It depends on bank_model (pure) for JSON round-trip
// and capture_paths (pure) for the path arithmetic it drives.
#include <string>
#include "bank_model.h"
namespace reasampler {
// The ext-state namespace the index JSON is stored under. FOREVER-STABLE once
// shipped: changing it orphans every already-saved project's index.
inline constexpr const char* kProjExtNamespace = "reasampler";
// The ext-state key the index JSON is stored under (one key holds the whole
// serialized BankIndex). FOREVER-STABLE for the same reason.
inline constexpr const char* kProjExtIndexKey = "bank_index";
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
// onto a recycled ReaProject* pointer (different GUID). FOREVER-STABLE: changing
// it strands the identity of every already-saved project. See persist.cpp.
inline constexpr const char* kProjExtGuidKey = "project_guid";
// Owns the session's BankIndex 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
// (identity changed) and a Save-As (same project, path changed) and react:
//
// * 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 CONTENT-BASED, not pointer-based: persist keys Load/Save-As off a
// GUID it mints and stores in each project's ext state, not the ReaProject*
// pointer (REAPER recycles pointer addresses across close/open, which let a
// project switch masquerade as a Save-As and clobber a bank — the M4 defect).
//
// The bank 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 in-memory bank. The action/capture layer adds captures here; persist
// serializes it on save and replaces it on project load.
BankIndex& bank() { return bank_; }
const BankIndex& bank() const { return bank_; }
// Serialize the current bank to the active project's ext state (namespace
// "reasampler"). Non-destructive beyond writing our own ext-state key. Safe
// to call when there is no active/saved project (it no-ops).
void saveToActiveProject();
// 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.
void poll();
private:
BankIndex bank_;
// The project identity last observed by poll(), used to detect load/Save-As.
// Identity is the GUID we mint per project (kProjExtGuidKey), NOT the raw
// ReaProject* pointer — see the class comment for why. The .rpp path is
// tracked alongside so a same-GUID path change (Save-As) is distinguishable
// from a same-GUID same-path idle tick (Save in place / no change).
std::string lastGuid_; // "" until the first saved project is seen
std::string lastRppPath_; // .rpp path last seen for lastGuid_
bool primed_ = false; // false until the first poll() observes state
// Load the index from the given project's ext state and resolve bank paths
// against projectDir. Replaces the in-memory bank. projectDir empty -> clears
// the bank (unsaved project has no resolvable bank).
void loadFromProject(void* proj, const std::string& projectDir);
};
} // namespace reasampler
+143
View File
@@ -109,6 +109,136 @@ static void testFileStem() {
CHECK(q.fileStem + ".wav" == q.fileName); CHECK(q.fileStem + ".wav" == q.fileName);
} }
// --- Persist-side path arithmetic (M4) --------------------------------------
static void testResolveBankFileAgainstProjectDir() {
// A relative index entry resolves to <projectDir>/<relativePath>, forward-
// slashed, regardless of the input slash style.
CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
== "C:/Users/d/proj/reasampler_bank/kick.wav");
CHECK(resolveBankFile("/home/d/proj", "reasampler_bank/mix.wav")
== "/home/d/proj/reasampler_bank/mix.wav");
// Trailing slash on the project dir must not double up.
CHECK(resolveBankFile("/home/d/proj/", "reasampler_bank/mix.wav")
== "/home/d/proj/reasampler_bank/mix.wav");
// Backslashes in the stored relative path are normalized on resolution.
CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav")
== "/p/reasampler_bank/a.wav");
}
static void testResolveBankFileRejectsEmptyInputs() {
// No default-location fallback (CLAUDE.md invariant): empty project dir or
// empty relative path yields empty, never a bare relative resolved to CWD.
CHECK(resolveBankFile("", "reasampler_bank/kick.wav").empty());
CHECK(resolveBankFile("C:/p", "").empty());
CHECK(resolveBankFile("", "").empty());
}
static void testResolveIsInverseOfDerive() {
// The path a capture stored (relativePath) resolves back to the same file the
// capture wrote (absoluteDir/fileName) when resolved against the SAME project
// dir. This is the round-trip persist relies on.
const std::string projectDir = "C:/Users/d/proj";
BankPaths p = deriveBankPaths(projectDir, "master mix", "1753080000");
const std::string absoluteFile = p.absoluteDir + "/" + p.fileName;
CHECK(resolveBankFile(projectDir, p.relativePath) == absoluteFile);
}
static void testResolveAgainstNewProjectDirAfterSaveAs() {
// The Save-As guarantee: the SAME stored relative path, resolved against a
// NEW project dir, points into the new project's bank. The index does not
// need rewriting — resolution against the current dir does the work.
BankPaths p = deriveBankPaths("/old/proj", "kick", "7");
CHECK(resolveBankFile("/new/place/proj", p.relativePath)
== "/new/place/proj/reasampler_bank/kick_7.wav");
}
static void testRelocationPlanForSaveAs() {
// Save-As to a different directory: relocation is needed; both bank dirs are
// <projectDir>/reasampler_bank, forward-slashed, no trailing slash.
BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj");
CHECK(r.needed);
CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank");
CHECK(r.newBankDir == "C:/new/proj/reasampler_bank");
}
static void testRelocationPlanNotNeededForSaveInPlace() {
// Save in place (same dir, any slash style) -> no relocation.
BankRelocation r = deriveRelocationPlan("/home/d/proj", "/home/d/proj/");
CHECK(!r.needed);
// Dirs still computed (harmless), but needed=false is the load-bearing bit.
CHECK(r.oldBankDir == "/home/d/proj/reasampler_bank");
CHECK(r.newBankDir == "/home/d/proj/reasampler_bank");
}
static void testRelocationPlanEmptyInputsNoOp() {
// First-ever save (no old dir) or missing new dir -> nothing to relocate.
CHECK(!deriveRelocationPlan("", "/new/proj").needed);
CHECK(!deriveRelocationPlan("/old/proj", "").needed);
CHECK(!deriveRelocationPlan("", "").needed);
}
// --- Project-identity transition (M4 defect fix) ----------------------------
static void testTransitionRecycledPointerLoadsNotRelocates() {
// THE ORIGINAL BUG. Project A (guidA, /a/a.rpp) is closed; REAPER makes a
// different saved project B active with a recycled pointer. B carries its own
// GUID and a different path. With identity keyed on the GUID this is a Load,
// NOT a Save-As — so the bank is never clobbered.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
// Even the adversarial shape — recycled pointer AND B happens to sit at a
// path the old check would misread — resolves to Load purely on the GUID.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/a/other.rpp")
== ProjectTransition::Load);
}
static void testTransitionGenuineSaveAsRelocates() {
// Same project (same non-empty GUID) saved to a new .rpp location -> the one
// case that legitimately relocates the bank.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate);
}
static void testTransitionTabSwitchLoads() {
// Switching to another open project (different GUID) -> Load, never relocate,
// regardless of whether the paths differ.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
// Switching back also loads (its GUID differs from the one just seen).
CHECK(classifyProjectTransition("guidB", "/b/b.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::Load);
}
static void testTransitionSaveInPlaceIsNoOp() {
// Same GUID, same path (idle tick, or a Save that did not move the .rpp) ->
// nothing to do.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
}
static void testTransitionEmptyGuidPathChangeLoadsNeverRelocates() {
// No GUID on either side (unsaved projects / first-save) means we CANNOT
// prove the two ticks saw the same project. A path change is then a Load
// (first save, or a switch between unsaved projects) — never a relocate,
// which is the safe direction (no destructive copy-over without proof).
CHECK(classifyProjectTransition("", "", "", "/a/a.rpp")
== ProjectTransition::Load);
CHECK(classifyProjectTransition("", "/tmp/untitled.rpp", "", "/a/a.rpp")
== ProjectTransition::Load);
// Both empty, same path -> idle unsaved project -> NoOp.
CHECK(classifyProjectTransition("", "", "", "")
== ProjectTransition::NoOp);
}
static void testTransitionGuidAppearingLoads() {
// A project that had no stored GUID (last seen empty) now reports one (we just
// minted+wrote it, or an older project gained one). The GUID changed, so it
// classifies as Load — harmless: loadFromProject re-reads the same ext state.
CHECK(classifyProjectTransition("", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::Load);
}
int main() { int main() {
testNormalizeSlashes(); testNormalizeSlashes();
testSanitizeStem(); testSanitizeStem();
@@ -118,6 +248,19 @@ int main() {
testDeriveEmptyProjectDirIsRejected(); testDeriveEmptyProjectDirIsRejected();
testDeterministicForSameInputs(); testDeterministicForSameInputs();
testFileStem(); testFileStem();
testResolveBankFileAgainstProjectDir();
testResolveBankFileRejectsEmptyInputs();
testResolveIsInverseOfDerive();
testResolveAgainstNewProjectDirAfterSaveAs();
testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerLoadsNotRelocates();
testTransitionGenuineSaveAsRelocates();
testTransitionTabSwitchLoads();
testTransitionSaveInPlaceIsNoOp();
testTransitionEmptyGuidPathChangeLoadsNeverRelocates();
testTransitionGuidAppearingLoads();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail); else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);