// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (see // session.h for the identity-transition design and the TU map). // // 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). // // classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` // bool 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 cross-open identity), so we mint one (genGuid/guidToString) // under kProjExtGuidKey; Save-As copies the whole .rpp including our ext // state, so the new project initially shares the old GUID, and poll() // re-GUIDs it after relocating (or on the forked-sibling Load branch). // // Division of labour for undo/redo: the identity-transition poll (this file) // owns open/tab-switch/new/forked-sibling/Save-As. The `projectconfig` hook // (main.cpp, BeginLoadProjectState with isUndo) owns undo/redo, where identity // is unchanged but ext state rolled back/forward on disk — the identity poll // would see NoOp there, so the hook requests a reload that poll() drains next // tick, once REAPER has restored the block. The hook fires on // undo, redo, AND normal open, but the reload flag is set only for isUndo, so // a normal open never double-loads. #include "shell/persist/session.h" #include #include "shell/persist/ext_state_io.h" #include "shell/persist/persist_internal.h" #include "core/capture/capture_paths.h" // classifyProjectTransition / deriveRelocationPlan (pure) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_MarkProjectDirty #define REAPERAPI_WANT_SetProjExtState #define REAPERAPI_WANT_ShowConsoleMsg #include "reaper_plugin_functions.h" namespace reasampler { using persist_detail::ensureProjectGuid; using persist_detail::genProjectGuidString; using persist_detail::getProjExtStateString; using persist_detail::projectDirOf; using persist_detail::readActiveProject; using persist_detail::relocateBankFolder; void ReaSamplerSession::recordCreated(const model::Sample& sample, tracking::OriginKind kind) { tracking::OriginRecord rec; rec.relativePath = sample.relativePath; rec.kind = kind; rec.sampleId = sample.id; // The Sample's own provenance is where the parent was resolved; reading it here // rather than re-deriving keeps one source for the lineage fact. Absent // provenance is a root capture, not a gap. if (sample.provenance) rec.parentSampleId = sample.provenance->parentSampleId; const tracking::RecordResult result = tracking_.record(rec); // A rejection means a file exists that nothing attributes to us — invisible // otherwise, and exactly the gap this ledger exists to close. AlreadyPresent is // the normal dedup outcome, not a gap. if (result == tracking::RecordResult::RejectedEmptyPath || result == tracking::RecordResult::RejectedAbsolutePath) { ShowConsoleMsg(("ReaSampler: could not record the origin of '" + sample.relativePath + "' -- the path is empty or absolute. The file is NOT tracked and " "prune will treat it as a foreign file.\n").c_str()); } } bool ReaSamplerSession::consumeLoadSignal() { const bool pending = loadPending_; loadPending_ = false; return pending; } void ReaSamplerSession::requestReload() { // Set-only; poll() drains it next tick. 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(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, owned by the projectconfig hook, not the identity // classifier below: an undo/redo keeps the same project identity, so // classifyProjectTransition would return NoOp and never re-read ext // state. By now REAPER has finished restoring the block, so // GetProjExtState returns the post-undo value. Reload and identity-adopt // (no relocation — path unchanged). This is the ONLY undo/redo reload // path — the timer never polls ext-state content to detect an undo. 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 capture::ProjectTransition transition = capture::classifyProjectTransition( sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath); switch (transition) { case capture::ProjectTransition::NoOp: return; case capture::ProjectTransition::Load: { // A different project of record is active. Load its index; never relocate. // // Forked-sibling re-GUID: gate on `!sameProjectObject` so this fires // only for the fork case (same GUID, different object) — a Save-As // fork that copied our GUID and never re-saved. A recycled-pointer // Load (currentGuid != lastGuid_) must NOT re-GUID — it is already // a distinct identity; currentGuid == lastGuid_ can only hold here // when that case did not fire. 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 capture::ProjectTransition::SaveAsRelocate: { // Same project object, new .rpp path: a genuine Save-As. Relocate // the bank folder 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) — do NOT reload. const std::string oldDir = projectDirOf(lastRppPath_); const std::string newDir = projectDirOf(rppPath); const capture::BankRelocation plan = capture::deriveRelocationPlan(oldDir, newDir); if (plan.needed) { relocateBankFolder(plan.oldBankDir, plan.newBankDir); } // Save-As duplicated our ext state, so the new project shares the // old GUID; mint a fresh one and mark dirty so it flushes on the // next save, and A/B no longer collide on identity when reopened. 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