Files
reasampler/src/persist.cpp
T

853 lines
46 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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". Phase B: the
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
// the banks travel 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 indices' 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 for STORAGE), and the timer composes cleanly with
// ext-state while covering identity-transition load + Save-As detection in one
// place.
//
// DIVISION OF LABOUR (R-B undo):
// * Identity-transition poll (this file, classifyProjectTransition) owns
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
// where the project OF RECORD changes.
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
// identity is unchanged but its ext state rolled back/forward on disk. The
// identity poll sees NoOp there and would never re-read ext state, so the hook
// requests a reload (requestReload) that poll() drains on the next tick, once
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
// The hook fires on undo AND redo (isUndo true for both), and on normal open
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
// flows solely through the identity-transition Load path and never double-loads.
//
// 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 <system_error>
#include <unordered_map>
#include <unordered_set>
#include <vector>
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#endif
#include "app_version.h"
#include "capture_paths.h"
#include "prune_reconcile.h"
#include "usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
#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). The derivation itself is projectDirOfRpp in capture_paths
// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both
// artifacts share one implementation rather than duplicating the parent-of-.rpp step.
std::string projectDirOf(const std::string& rppPath) {
return projectDirOfRpp(rppPath);
}
// 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
bool ReaSamplerSession::saveToActiveProject() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return false; // no active project — nothing to persist
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
// rides in the `banks` key.
const std::string banksJson = book_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBanksKey, banksJson.c_str());
// Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
// value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
// realizes retirement concretely — after any save, a formerly-legacy project
// carries `banks` and NO `bank_index`, and going forward the legacy key is never
// written. Cheap and idempotent when the key is already absent.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtIndexKey, "");
// Additive: the Design-View model rides alongside the banks in its own key.
// Independent write — does not disturb the `banks` blob above.
const std::string viewJson = view_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtViewKey, viewJson.c_str());
// Additive: the docked panel's tail setting rides alongside in its own key, so the
// tail choice travels inside the .rpp. Independent write — does not disturb the
// bank_index or view_state above.
const std::string tailJson = serializeTailSetting(tail_);
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtTailKey, tailJson.c_str());
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
// `owned_files` key. Independent write — does not disturb the blobs above. Written
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
// same saveToActiveProject the capture add-path calls). Uses the channel-derived
// namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too.
const std::string ownedJson = owned_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ownedJson.c_str());
// Phase V (V1/V4): stamp the WRITING version — the build producing this save — under
// the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty
// stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is
// the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as
// Stamped on read-back and stays byte-identical to stable regardless of channel; the
// channel is already carried by the isolated namespace (projExtNamespace) this writes to.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtVersionKey, stampVersion().c_str());
// S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME
// seam so the counter and MarkProjectDirty stay paired. The value is whatever
// bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so
// every content mutation's own save carries the fresh generation the instrument reads. The
// format is the SHARED pure encoder (vst::formatBankGeneration) so writer and reader agree
// byte-for-byte — a decimal integer. Additive: does not disturb the blobs above.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBankGenKey,
vst::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
}
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return false; // no active project — nothing to signal
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
// One-shot write of the ingest assignment request under its own key (S8). Independent
// of the book/view/tail blobs — this is a transient signal to the instrument, not
// session state that must ride every save. Uses the channel-derived namespace
// (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta
// instrument reads only a beta extension's assignment requests.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtAssignKey, wire.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
}
namespace {
// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always
// exact (tallied over the full orphan set), but the enumerated file list handed to the
// console is clipped to this many entries so a project with thousands of orphans does
// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
constexpr std::size_t kPruneListDisplayCap = 64;
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
// active project, enumerates the folder) but writes nothing.
//
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
// when there is no active/saved project, no project dir, or no folder on
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
// * orphans — the FULL orphan set (owned ∩ present) referenced, in enumeration
// order, untruncated. The pure core decides; this only supplies inputs.
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
// the prune must halt rather than proceed with degraded protection.
// An empty orphan set is itself the delete-side guarantee (every
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
// what lets the action TELL the user instead of claiming "no orphans".
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) {
PruneScan scan;
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
// Save-As relocation is followed automatically. resolveBankFile is the shared M4
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
const std::string projectDir = projectDirOf(rppPath);
const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder);
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
return scan; // no bank folder captured yet -> nothing to reclaim
}
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
// way the capture path spelled them (bankRelativeForName == deriveBankPaths's
// convention) so the pure core's exact-string match lines up with referencedPaths()
// and the manifest. Non-recursive: the bank folder is flat (capture writes files
// directly here); skip any subdirectory. Size is stat'd here and cached by relative
// path so the report's byte tally reuses the same on-disk read.
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration
// failure (file removed, permission flip) breaks out with a best-effort partial list
// rather than propagating std::filesystem_error across REAPER's C ABI.
std::vector<std::string> present;
fs::directory_iterator it(bankDir, ec);
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
const auto& entry = *it;
std::error_code reg_ec;
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
const std::string name = entry.path().filename().string();
const std::string rel = bankRelativeForName(name);
if (rel.empty()) continue;
present.push_back(rel);
std::error_code sz_ec;
const std::uintmax_t sz = entry.file_size(sz_ec);
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the book and manifest.
// referencedPaths() unions across the whole book (pool included); owned().paths() is
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
// including the protect-all net when zero instances were identified) — a capture
// any live instance holds can NEVER be an orphan, even when its bank entry was
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
// preserving this scan's no-write contract. This shell only enumerates, resolves,
// and stats.
scan.bankDirAbs = bankDir;
const UsageScanResult usage = liveInstanceHeldPaths(proj);
if (usage.abortPrune) {
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
// protected set is unknowable. Compute NO orphans — every downstream consumer
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
// nothing. The flag + key names surface the reason so the action can name each
// offending key for operator recovery.
scan.abortedUnreadableUsage = true;
scan.offendingUsageKeys = usage.offendingKeys;
return scan;
}
scan.orphans = pruneOrphans(
present, mergeReferenced(book.referencedPaths(), usage.heldPaths),
owned.paths());
return scan;
}
} // namespace
PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
PruneReport report =
buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
// the scan computed nothing — but the user must know the prune refused to run).
// The offending key names propagate so the action can name each one for recovery.
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
report.offendingUsageKeys = scan.offendingUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
}
namespace {
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases:
// * `outAlreadyAbsent` set true — the file was already gone before we touched it;
// the caller folds this into the stale/staleness tally, NOT reclaimedCount.
// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error);
// the caller folds this into skippedCount.
// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception
// may cross the C ABI.
//
// Per-platform routing:
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
// confirm guardrail. `outUsedTrash` left as-is (false).
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
bool& outAlreadyAbsent) {
#ifdef _WIN32
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
std::string win = absPath;
for (char& c : win) if (c == '/') c = '\\';
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false)
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
SHFILEOPSTRUCTW op{};
op.hwnd = nullptr;
op.wFunc = FO_DELETE;
op.pFrom = wbuf.data();
op.pTo = nullptr;
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
FOF_SILENT | FOF_NOERRORUI);
const int rv = SHFileOperationW(&op);
if (rv == 0 && !op.fAnyOperationsAborted) {
outUsedTrash = true;
return true; // deleted this call -> reclaimed
}
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
// versions, or a lock). Distinguish "already absent" from a real failure so the
// caller can tally them separately (absent -> staleness skip; failure -> locked skip).
std::error_code ec;
if (!fs::exists(absPath, ec)) {
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
}
return false;
#else
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
std::error_code ec;
const bool removed = fs::remove(absPath, ec);
if (removed) return true; // deleted this call -> reclaimed
if (ec) return false; // a real failure (locked / permission) -> skip
// remove returned false with no error == the file did not exist -> already gone.
outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim
return false;
#endif
}
} // namespace
PruneDeletionResult ReaSamplerSession::pruneReclaim(
const std::vector<std::string>& confirmed) const {
PruneDeletionResult result;
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
const PruneScan scan = scanPruneOrphans(book_, owned_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
const std::vector<std::string> plan = pruneDeletePlan(confirmed, scan.orphans);
// Staleness skip count: entries the user confirmed that are no longer fresh orphans
// (vanished or became referenced between confirm and delete). pruneDeletePlan already
// de-dups confirmed internally, so compute the unique-confirmed size to avoid counting
// de-duplicated entries as stale — that would be dishonest.
const std::size_t uniqueConfirmedCount =
std::unordered_set<std::string>(confirmed.begin(), confirmed.end()).size();
result.skippedCount += uniqueConfirmedCount - plan.size();
for (const std::string& rel : plan) {
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
const std::string::size_type slash = rel.find_last_of('/');
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
if (name.empty()) { ++result.skippedCount; continue; }
const std::string absPath = scan.bankDirAbs + "/" + name;
const auto szIt = scan.sizeByRel.find(rel);
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
bool alreadyAbsent = false;
if (deleteOrphanFile(absPath, result.usedTrash, alreadyAbsent)) {
++result.reclaimedCount;
result.reclaimedBytes += bytes;
} else if (alreadyAbsent) {
// File vanished between plan and delete — treat as staleness, same as the
// confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it).
++result.skippedCount;
} else {
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
}
}
return result;
}
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, projExtNamespace(), 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);
}
// Load the tail setting from a project's tail_setting key, or return the default. An
// absent/empty key (older / never-adjusted project) yields the default setting (None /
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
// to default, mirroring the bank's and view's malformed handling.
TailSetting loadTailSetting(ReaProject* proj) {
if (!proj) return TailSetting{};
const std::string tailJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
if (tailJson.empty()) return TailSetting{}; // no stored setting -> default
std::optional<TailSetting> loaded = deserializeTailSetting(tailJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
return TailSetting{};
}
return *loaded;
}
// Load the owned-file manifest from a project's owned_files key, or return an empty
// manifest. An absent/empty key (older / never-captured project) yields an empty
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
// sees an empty ownership record and (safely) attributes nothing until the next capture
// rebuilds it — losing the record degrades safety, never correctness.
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
if (!proj) return OwnedFileManifest{};
const std::string ownedJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty
std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
return OwnedFileManifest{};
}
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));
// The tail setting is restored on EVERY load path too (peer-symmetry): switching
// to a project with no stored setting must fall back to the default, not inherit
// the previous project's choice (this REPLACES the old session-carry behavior).
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
// bank/view/tail resets): switching to a project with no stored manifest must reset
// to empty, not inherit the previous project's ownership record; an undo/redo reload
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
// Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry
// with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed
// one as Unknown — both silent, no console warning (a pre-versioning project is not
// an error). getProjExtStateString returns "" for an absent key, which is exactly the
// PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default.
writingVersion_ = classifyWritingVersion(
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtVersionKey)
: std::string{});
// S9: recover the bank-generation counter on EVERY load path (peer-symmetry with
// writingVersion_/tail_/view_ above), so it continues monotonic from the stored value
// rather than resetting to 0 on reopen — a next bump then reads > the stored value. A
// project switch reads THAT project's counter, not the previous one's; an absent/malformed
// stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0.
bankGeneration_ = vst::parseBankGeneration(
proj ? getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBankGenKey)
: std::string{});
if (!proj) {
book_ = BankBook{};
return;
}
// Read both possible sources: the authoritative `banks` blob and the retired-but-
// possibly-still-present legacy `bank_index`. The precedence + migration decision
// (`banks` wins; else the legacy index migrates into the pool; else an empty book)
// is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
// so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
// blob must read as "ignored", not silent loss, mirroring the prior malformed-index
// warning. A malformed `banks` degrades to an empty book and does NOT fall back to
// the stale legacy key (which would resurrect superseded single-bank state).
const std::string banksJson =
getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBanksKey);
if (!banksJson.empty()) {
std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n");
book_ = BankBook{};
} else {
book_ = std::move(*loaded);
}
} else {
// No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
// by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
// empty tail; passing "" for banksJson takes exactly that branch.
const std::string legacyJson =
getProjExtStateString(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtIndexKey);
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
}
// L7 slot migration: seed every bank's display-position SlotMap from its index
// insertion order when the loaded blob carried none (a pre-L7 project -> dense,
// gap-free, visually identical on first post-L7 load), and reconcile a partial map
// (drop stale markers, append unmapped samples) for a blob written by an earlier L7
// build. One-way: once the book is re-saved the reconciled slot data is authoritative.
// Idempotent, so a fresh empty book is a cheap no-op.
book_.reconcileSlots();
// Project-relative resolution is a READ-time concern: every BankIndex in the book
// stores only relative paths (invariant, enforced per-bank at add()), and consumers
// (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
// resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
// absolute here — that would break the relative-only invariant and travel-with-.rpp.
// projectDir is threaded through for those consumers; nothing to do at load time
// beyond replacing the in-memory book.
(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), projExtNamespace(),
kProjExtGuidKey, minted.c_str());
return minted;
}
} // namespace
bool ReaSamplerSession::consumeLoadSignal() {
const bool pending = loadPending_;
loadPending_ = false;
return pending;
}
void ReaSamplerSession::requestReload() {
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
// the read is deferred past the projectconfig callback). Cheap and idempotent —
// multiple undo/redo callbacks before the next tick collapse to one reload.
reloadRequested_ = true;
}
void ReaSamplerSession::poll() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
const std::string currentGuid =
proj ? getProjExtStateString(static_cast<ReaProject*>(proj),
projExtNamespace(), 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;
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
return;
}
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
// one or more ticks ago; by NOW REAPER has finished restoring the project's
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
// current active project and identity-adopt it (no relocation — the path is
// unchanged), then return. loadFromProject raises loadPending_, so the existing
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
if (reloadRequested_) {
reloadRequested_ = false;
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), projExtNamespace(),
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), projExtNamespace(),
kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
}
lastProject_ = proj; // unchanged (same object) — set for symmetry
lastGuid_ = fresh;
lastRppPath_ = rppPath;
return;
}
}
}
} // namespace reasampler