Files
reasampler/src/persist.cpp
T
daniel c1473c42e1 feat(prune): R2 dry-run prune shell + persist wiring, report-only
Add ReaSamplerSession::pruneDryRun enumerating the resolved current bank
folder, feeding the R1 core, and returning a PruneReport (count/bytes/list).
New pure bankRelativeForName + buildPruneReport keep spelling and tally
testable. Register forever-stable BANK_PRUNE_FOLDER action, report-only. No deletion.
2026-07-26 19:12:07 -04:00

604 lines
32 KiB
C++

// persist.cpp — REAPER-facing implementation of the BankIndex <-> project
// ext-state bridge (M4). See persist.h for the contract.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract).
//
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". 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 <vector>
#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<char> buf(4096, '\0');
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
rppPathOut.assign(buf.data());
return proj;
}
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the
// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp
// for the full rationale). normalizeSlashes lives in capture_paths (pure).
std::string projectDirOf(const std::string& rppPath) {
if (rppPath.empty()) return {};
std::string dir = fs::path(rppPath).parent_path().string();
return normalizeSlashes(dir);
}
// GetProjExtState needs a caller-supplied buffer; the index JSON can be large
// (many samples). Query the required size first (a NULL/zero call is not part of
// the documented contract, so we grow a buffer until it fits). Returns "" when
// the key is absent (GetProjExtState returns <=0) — an absent key is a valid
// empty bank, not an error.
std::string getProjExtStateString(ReaProject* proj, const char* ns,
const char* key) {
// Start generous; grow if REAPER reports the value was truncated. The return
// value is the length of the value (SDK: "returns length"); if it equals the
// buffer capacity minus the NUL, the value may have been clipped, so retry.
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
std::vector<char> buf(static_cast<size_t>(cap), '\0');
int rv = GetProjExtState(proj, ns, key, buf.data(), cap);
if (rv <= 0) return {}; // absent / empty -> empty bank
// If the written string fits strictly inside the buffer it is complete.
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) return s;
// else: possibly truncated -> grow and retry.
}
// Pathologically large (>16 MB) — give up rather than loop forever. Warn on
// the console so this reads as "too large to load", not silent data loss
// (mirrors the malformed-JSON warning in loadFromProject).
ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
"' exceeds the 16 MB read ceiling -- ignoring (bank not "
"loaded).\n").c_str());
return {};
}
// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString.
// guidToString wants a >=64-char destination (SDK header line ~3846).
std::string genProjectGuidString() {
GUID g{};
genGuid(&g);
char buf[64] = {0};
guidToString(&g, buf);
return std::string(buf);
}
// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not
// move — see the handoff for the copy-vs-move rationale). Overwrites existing
// files at the destination so a re-save is idempotent. Best-effort: filesystem
// errors are swallowed and reported to the console rather than thrown across the
// REAPER boundary. Returns true if the copy ran (source existed).
bool relocateBankFolder(const std::string& oldBankDir,
const std::string& newBankDir) {
std::error_code ec;
if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
return false; // nothing at the old location to relocate
}
if (oldBankDir == newBankDir) return false; // defensive; plan guards this too
fs::create_directories(newBankDir, ec);
fs::copy(oldBankDir, newBankDir,
fs::copy_options::recursive | fs::copy_options::overwrite_existing,
ec);
if (ec) {
ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
"' failed: " + ec.message() + "\n").c_str());
return false;
}
return true;
}
} // namespace
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());
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;
} // namespace
PruneReport ReaSamplerSession::pruneDryRun() const {
PruneReport report;
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj || rppPath.empty()) return report; // no active/saved project -> nothing
// 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 report; // unresolvable (no project dir) -> nothing
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
return report; // 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.
std::vector<std::string> present;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
for (const auto& entry : fs::directory_iterator(bankDir, ec)) {
if (ec) break;
std::error_code fec;
if (!entry.is_regular_file(fec)) 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);
const std::uintmax_t sz = entry.file_size(fec);
sizeByRel[rel] = fec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the session's book and
// manifest (NO save, NO MarkProjectDirty, NO mutation). referencedPaths() unions
// across the whole book (pool included); owned().paths() is the manifest set. The
// count / byte-sum / display-truncation tally is the pure buildPruneReport, so this
// shell only enumerates, resolves, and stats — no report logic re-implemented here.
const std::vector<std::string> orphans =
pruneOrphans(present, book_.referencedPaths(), owned_.paths());
return buildPruneReport(orphans, sizeByRel, kPruneListDisplayCap);
}
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{});
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);
}
// 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