#pragma once // session — the ReaSamplerSession lifecycle owner of the persist seam. One // class, three implementation TUs by responsibility: // * session.cpp — poll() identity-transition detection (load / Save-As / // forked sibling / recycled pointer) + the deferred undo/redo reload drain. // * ext_state_io.cpp — save/load/writeAssignmentRequest: the ext-state <-> // JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h). // * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune // scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank // folder, plus the one self-cleanup carve-out from it, both stated there. // // Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext // state lives inside the .rpp, so the index travels with the project for // free). Load: GetProjExtState -> deserialize -> resolve each entry's bank // file against the CURRENT project dir, so a project opened from a new // location still finds its bank. Save-As: relocate the physical bank folder // so the wavs end up under the new .rpp; the index's relative paths stay valid. // // REAPER-free header — all REAPER API calls live in the three TUs. #include #include #include #include "core/capture/tail_control.h" #include "core/model/bank_book.h" #include "core/model/bank_model.h" #include "core/reclaim/prune_reconcile.h" #include "core/tracking/origin_ledger.h" #include "core/tracking/tracking_authority.h" #include "core/version/app_version.h" #include "core/view/view_mode_model.h" namespace reasampler { // Owns the session's BankBook (pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's // lifetime. Tracks the project identity last seen so the timer tick can // detect a project load (a different project became active, so load the // index from ext state) vs. a Save-As (same project, path changed, so // relocate the bank folder under the new .rpp). // // Identity is layered GUID-primary: the minted GUID (content-based, immune to // REAPER recycling a closed project's ReaProject* address) is checked first; // the live pointer disambiguates only the same-GUID case — a forked sibling // (same GUID, different object -> Load) vs. a genuine Save-As (same GUID, // same object, new path -> relocate). Two prior designs each broke one // direction: GUID-only misread a Save-As fork as the parent project; // pointer-primary misread a recycled ReaProject* address as no-op. GUID-first // catches recycling; the pointer then separates fork from Save-As. // // The book is exposed for the capture/action layer to mutate; persist only // reads it on save and replaces it on load. class ReaSamplerSession { public: ReaSamplerSession() = default; // Pool + named banks + active-bank id; persist serializes under `banks`. BankBook& book() { return book_; } const BankBook& book() const { return book_; } // The capture add-target: the active bank's BankModel (defaults to the pool). model::BankModel& bank() { return book_.activeIndex(); } const model::BankModel& bank() const { return book_.activeIndex(); } // The project whose book/view/tail/ledger poll() last loaded — compare-only, never // dereferenced; nullptr before the first poll. An action that can be fired against a // project other than the one currently loaded here (the VST bake, which targets its own // instance's tab) MUST check this before mutating: the book in memory belongs to one // project, and landing another tab's request would write its capture into this bank. const void* loadedProject() const { return lastProject_; } // Design-View model; persists MODEL STATE only (visibility on open is the view shell's job). ViewModeModel& view() { return view_; } const ViewModeModel& view() const { return view_; } // Docked panel's tail setting, authoritative here so it travels inside the .rpp. capture::TailSetting& tail() { return tail_; } const capture::TailSetting& tail() const { return tail_; } // Record a system-created file at the moment it exists — the ONLY way a birth // record is written. Lineage is read off the Sample's own provenance, the same // act that stamped it, so the two cannot disagree; where they later diverge the // ledger record is authoritative (core/tracking/CLAUDE.md). // // Call it at EVERY creation site regardless of the bank's AddResult: a // hash-collapse still wrote a file the tool owns, and an unrecorded file is a // permanently unreclaimable foreign file. A rejected record is reported to the // console — a file with no record is the gap this track exists to eliminate. // // A consumer outside persist that needs the ledger must take it WITH its // LedgerStatus (a tracking::TrackingState); no accessor exposes one without the // other, because an absent record and an unreadable ledger demand opposite // treatment. void recordCreated(const model::Sample& sample, tracking::OriginKind kind); // Whether a birth record can be written at all this session — the status WITHOUT // the records. That is not a hole in the pairing rule above: the rule exists so an // absent record is never read as a definite answer, and this exposes strictly less // than the pair. The package import gates on it before it opens a file picker. // // A tradeoff, not the only route: `pruneDryRun()` already exposes the same degraded // pair via `PruneReport::ledgerUnreadable`/`ledgerFutureVersion`, with no new // accessor needed. Rejected because that route is genuinely worse for a gate: it // drags a full bank-folder enumeration and every live instance's FX scan onto a // check that only needs to know "can I write a record", and it shapes an import // decision as an answer borrowed from prune's report rather than the session's own. tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; } // The version that last wrote the active project: PreVersioning (no // stamp), Unknown (malformed), or Stamped. const version::WritingVersion& writingVersion() const { return writingVersion_; } // Monotonic per project; recovered on load, written on every saveToActiveProject(). std::int64_t bankGeneration() const { return bankGeneration_; } // Call at every bank-CONTENT mutation that changes what a live instance // would play, NOT the organizational verbs (create/rename/reorder a // bank). Rides the next persist. Over-bumping is safe; under-bumping // misses a hands-free refresh, so call sites err toward bumping. void bumpBankGeneration() { ++bankGeneration_; } // Serializes book/view/tail to ext state, clears the retired legacy // `bank_index` key. No-ops with no active/saved project. Returns true iff // a persist happened, so a caller can skip an undo block when nothing was written. // // "Happened" is the writes being ISSUED, and a false means exactly two things — // no active project, or an unsaved one — with NOTHING written under either. SIX // callers turn a false straight into a discarded undo point, so this must never // grow a third, OBSERVATIONAL failure mode: a false negative would silently // remove the Ctrl-Z for a bank mutation that landed. bool saveToActiveProject(); // The narrowed sibling, for a caller that changed ONLY the Design-View model: // writes `view_state`, marks the project dirty, and touches no other key. The // full save stays the default — this is opt-in per call site. // // Opt-in rather than a per-key dirty flag on the session: a call site that // forgets to opt in merely pays the old cost, whereas a mutation site that // forgets to mark its key dirty would silently stop persisting it, and the // dirty flags would have to be threaded through every writer of book_/tail_/ // tracking_ across the capture, bank-op and ingest layers. // // Returns on the SAME rule as saveToActiveProject above, prohibition included. bool saveViewStateOnly(); // Report-only prune dry-run: feeds the pure core with (present, referenced, // owned) — `present` from the folder enumeration, the other two from the // tracking authority. FAIL-SAFE: tracking state the authority cannot read // sets blockedByTracking with an EMPTY orphan set. Read-only throughout. reclaim::PruneReport pruneDryRun() const; // The full (untruncated) orphan set, same compute as pruneDryRun. The // prune action confirms this set before deleting it. Read-only. std::vector pruneOrphanSet() const; // The resample's replace-vs-add input for one capture, gathered from the SAME live // tracking state the prune scan reads, so the two consumers cannot disagree. Exposed // as the answer rather than as the ledger, because an absent record and an unreadable // ledger demand opposite treatment and only the pair says which. Read-only. tracking::Answer tiedUsageFor(const std::string& capturePath, const std::string& ownUsageKey) const; // Delete the confirmed orphan set — the sole file-deletion path, // callable only after an explicit user confirm. Re-enumerates and runs // the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a // file that vanished or became referenced since confirm is skipped, and // an orphan the user did not see is never swept. Trash-preferred // (Windows Recycle Bin; unlink elsewhere). Does not modify the book or // the ledger, writes no ext-state. No-ops when nothing to delete; // does not prompt. reclaim::PruneDeletionResult pruneReclaim( const std::vector& confirmed) const; // Write the ingest assignment request (`assign_request` key): "the active // sampler instance should now play THIS sample." `wire` is pre-encoded // (assignment_request.h); a sibling one-shot write, not part of // saveToActiveProject's blob. Same two-condition return as that call: false // means no active/saved project and nothing was written, true means the write // was ISSUED — never that the project took it. bool writeAssignmentRequest(const std::string& wire); // Detects a project load or Save-As and reacts. Driven by REAPER's // "timer" register; idempotent per tick. Also drains a pending undo/redo // reload (requestReload): the identity classifier alone would read an // undo/redo as NoOp since identity is unchanged, so the projectconfig // hook's reload flag is honored FIRST, before the identity check. void poll(); // Request a reload of book_/view_/tail_ on the next poll() tick. Raised // by the projectconfig hook only on an undo/redo state restore. Deferred // because the hook fires BEFORE REAPER restores the block — // reading synchronously there would return the pre-undo value. void requestReload(); // Load signal for the reapply-on-open glue: poll() raises this whenever // it (re)loads the view model; consumeLoadSignal() returns true once and // clears it. Signal-based since persist stays model-only (never calls // the view shell); main.cpp owns the glue. bool consumeLoadSignal(); private: BankBook book_; ViewModeModel view_; // reset to default on a project with no stored view_state capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key tracking::OriginLedger tracking_; // reset to empty/stored on EVERY load path, never inherited // Written only by loadFromProject, so a degraded status is sticky until the // project is reloaded (see this directory's CLAUDE.md). tracking::LedgerStatus trackingStatus_ = tracking::LedgerStatus::Fresh; version::WritingVersion writingVersion_; // recovered per load; PreVersioning default std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic // Project identity last observed by poll(). GUID is primary; the pointer // disambiguates the same-GUID case. Held as void* (compare-only, never // dereferenced) so the header stays REAPER-free. void* lastProject_ = nullptr; std::string lastGuid_; // "" until the first saved project is seen std::string lastRppPath_; bool primed_ = false; // false until the first poll() observes state bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal bool reloadRequested_ = false; // raised by requestReload; drained by poll // Load the book from `proj`'s ext state (`banks`, else legacy // `bank_index` migrated into the pool); also restores view_/tail_/tracking_. void loadFromProject(void* proj, const std::string& projectDir); }; } // namespace reasampler