// 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 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 #include #include #include #include #include // 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 #include #endif #include "app_version.h" #include "capture_paths.h" #include "prune_reconcile.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetProjExtState #define REAPERAPI_WANT_MarkProjectDirty #define REAPERAPI_WANT_SetProjExtState #define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString #include "reaper_plugin_functions.h" namespace reasampler { namespace { namespace fs = std::filesystem; // Read the active project pointer and its .rpp path in one shot. idx=-1 is the // current project tab (SDK header line ~1262). The out-buffer receives the full // .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — // same fact capture.cpp relies on). Returns nullptr proj only when there is no // active project at all. void* readActiveProject(std::string& rppPathOut) { std::vector 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 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(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(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(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(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(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(proj), projExtNamespace(), kProjExtVersionKey, stampVersion().c_str()); MarkProjectDirty(static_cast(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). struct PruneScan { std::string bankDirAbs; std::vector orphans; std::unordered_map sizeByRel; }; // 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 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(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. This shell only enumerates, resolves, and stats. scan.bankDirAbs = bankDir; scan.orphans = pruneOrphans(present, book.referencedPaths(), 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. return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); } std::vector 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=, 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 wbuf(static_cast(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(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& 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. const PruneScan scan = scanPruneOrphans(book_, owned_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing const std::vector 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(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 "/"; 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 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 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 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(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(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(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(proj), projExtNamespace(), kProjExtVersionKey) : 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(proj), projExtNamespace(), kProjExtBanksKey); if (!banksJson.empty()) { std::optional 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(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(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(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 // 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(proj), projExtNamespace(), kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(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(proj), projExtNamespace(), kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); } lastProject_ = proj; // unchanged (same object) — set for symmetry lastGuid_ = fresh; lastRppPath_ = rppPath; return; } } } } // namespace reasampler