From 53da916917554e51f41bd4d7b9d61a214a775c1f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 19:55:09 -0400 Subject: [PATCH] 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. --- CMakeLists.txt | 1 + src/capture_paths.cpp | 60 ++++++++ src/capture_paths.h | 76 ++++++++++ src/main.cpp | 34 ++++- src/persist.cpp | 282 +++++++++++++++++++++++++++++++++++ src/persist.h | 93 ++++++++++++ tests/test_capture_paths.cpp | 143 ++++++++++++++++++ 7 files changed, 683 insertions(+), 6 deletions(-) create mode 100644 src/persist.cpp create mode 100644 src/persist.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 46e1053..5ab632a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ add_test(NAME capture_paths_tests COMMAND capture_paths_tests) add_library(reaper_reasampler MODULE src/main.cpp src/capture.cpp + src/persist.cpp ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 5cea35a..9e63e3d 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -73,4 +73,64 @@ BankPaths deriveBankPaths(const std::string& projectDir, 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 diff --git a/src/capture_paths.h b/src/capture_paths.h index c9210ac..fc6e4e3 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -53,4 +53,80 @@ BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, 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 "/" 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; // /reasampler_bank + std::string newBankDir; // /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 diff --git a/src/main.cpp b/src/main.cpp index 6476040..848e77f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,7 @@ #include "bank_model.h" #include "capture.h" +#include "persist.h" // Persistent action-id prefix for the ReaSampler action family. // 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. static int g_cmdCaptureMasterSpike = 0; -// In-memory bank for the spike. M4 replaces this with project ext-state persist; -// for M3 the index lives only for the session, proving the capture->Sample->add -// path end to end. -static reasampler::BankIndex g_bank; +// The persistence session (M4): owns the in-memory BankIndex and bridges it to +// project ext state. A timer tick drives g_session.poll() to detect project +// load / Save-As; capture adds Samples to g_session.bank(); after a capture we +// 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. static void RunCaptureMasterSpike() @@ -76,9 +87,14 @@ static void RunCaptureMasterSpike() 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"; - 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::Collapsed ? " (collapsed on hash)\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). if (g_rec) { + g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-hookcommand", (void*)&OnHookCommand); g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster); g_rec->Register("-command_id", @@ -139,6 +156,11 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( 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"); return 1; // success — REAPER keeps us loaded diff --git a/src/persist.cpp b/src/persist.cpp new file mode 100644 index 0000000..1d6c793 --- /dev/null +++ b/src/persist.cpp @@ -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 +#include +#include + +#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 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()); +} + +void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { + 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 + +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)); + 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(proj), kProjExtNamespace, + kProjExtGuidKey, fresh.c_str()); + } + lastGuid_ = fresh; + lastRppPath_ = rppPath; + return; + } + } +} + +} // namespace reasampler diff --git a/src/persist.h b/src/persist.h new file mode 100644 index 0000000..453b36d --- /dev/null +++ b/src/persist.h @@ -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 + +#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 diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index d6ef4e7..6cbbce8 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -109,6 +109,136 @@ static void testFileStem() { CHECK(q.fileStem + ".wav" == q.fileName); } +// --- Persist-side path arithmetic (M4) -------------------------------------- + +static void testResolveBankFileAgainstProjectDir() { + // A relative index entry resolves to /, 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 + // /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() { testNormalizeSlashes(); testSanitizeStem(); @@ -118,6 +248,19 @@ int main() { testDeriveEmptyProjectDirIsRejected(); testDeterministicForSameInputs(); 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"); else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);