Cut core/wire and shell/persist comment bloat ~46% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:49:28 -04:00
parent 1f24c4b095
commit 8dac5b4a54
19 changed files with 638 additions and 1286 deletions
+92 -240
View File
@@ -1,36 +1,23 @@
#pragma once
// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of
// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence &
// paths). One class, three implementation TUs by responsibility:
// 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. Nothing else in the system deletes bank-folder bytes (a shell's
// self-cleanup of its own transient scratch file is not this authority).
//
// * session.cpp — poll() (identity-transition detection: load / Save-As /
// forked sibling / recycled pointer) + the deferred undo/redo reload drain
// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook)
// + the D4 load signal.
// * ext_state_io.cpp — saveToActiveProject / loadFromProject /
// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID
// minting and bank-folder relocation (see ext_state_io.h for the key contract).
// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan
// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in
// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system
// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file
// it just created (the drop path's .vstpreset temp, the realtime finalize temp)
// is excluded from this authority.
// 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.
//
// Save: serialize the BankModel JSON -> SetProjExtState under namespace
// "reasampler" (ext state lives inside the .rpp, so the index travels with the
// project for free).
// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory
// BankModel, then resolve each entry's bank file against the CURRENT project
// dir (project-relative resolution — a project opened from a new location still
// finds its bank).
// Save-As: when the project path changes, relocate the physical bank folder so
// the wavs end up under the new .rpp (the index's relative paths stay valid).
//
// The header is REAPER-free (no SDK types leak here): callers interact through a
// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API
// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip
// and capture_paths (pure) for the path arithmetic it drives.
// REAPER-free header — all REAPER API calls live in the three TUs.
#include <cstdint>
#include <string>
@@ -46,268 +33,133 @@
namespace reasampler {
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
// Owns the session's BankBook (pool + named banks) and drives persistence
// against the active REAPER project. One instance lives for the extension's
// lifetime (main.cpp). It tracks
// the project identity it last saw so the timer tick can detect a project load
// (a different project became active) and a Save-As (SAME project, path changed):
// 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).
//
// * project load -> load the index from ext state, resolve bank paths
// * Save-As (new dir) -> 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.
//
// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
// record, immune to REAPER recycling a closed project's ReaProject* address) is
// checked FIRST, and 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). GUID-first catches pointer recycling
// (a reopened/new project reusing the previous address with a different GUID — the
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
//
// The book itself is exposed for the capture/action layer to mutate; persist
// only reads it on save and replaces it on load.
// 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;
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
// BankModel, plus the active-bank id. The action layer (B3) creates / renames /
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
// persist serializes it under the `banks` key on save and replaces it on load.
// 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).
// The capture path adds a captured Sample through this seam, so a capture lands
// in whichever bank is active — the single behavioural change B2 wires in over
// M7/M8 (the capture backends are untouched; only the target index moved). The
// panel/insert readers that displayed the single index continue to read it here
// unchanged; today it resolves to the pool (default active), matching prior
// single-bank behaviour, until B3/B4 let the user switch the active bank.
// 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 in-memory Design-View model. The view/action layer mutates it (tag,
// toggle, snapshot); persist serializes it on save and replaces it on project
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
// visibility/processing (reapply-on-open) is D4's job, not this member's.
// 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_; }
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
// panel state — so it travels inside the .rpp: persist serializes it on save and
// replaces it on project load exactly as it treats the bank and view model. The
// panel reads/writes it through this seam (bank_panel holds the session), and the
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
// an unsaved or pre-feature project (no stored key -> this default survives load).
// 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_; }
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
// capture path itself created. The capture add-path records each created file here
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
// bank; persist serializes it under the `owned_files` key on save and replaces it on
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
// B-cap only writes and persists it (no prune logic here).
// Project-relative files the capture path itself created; prune consumes it.
model::OwnedFileManifest& owned() { return owned_; }
const model::OwnedFileManifest& owned() const { return owned_; }
// The ReaSampler version that last WROTE the active project, recovered from its
// ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no
// stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the
// exact stored string otherwise — all silent, never an error. Replaced on every load
// path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved
// or never-loaded session. Exposed so a future migration step (or diagnostics) can
// reason about the origin build without re-reading ext state.
// The version that last wrote the active project: PreVersioning (no
// stamp), Unknown (malformed), or Stamped.
const version::WritingVersion& writingVersion() const { return writingVersion_; }
// The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic
// per project: recovered on load (so it continues from the stored value rather than
// resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on
// every saveToActiveProject(). Exposed const for the writer sites to read/log.
// Monotonic per project; recovered on load, written on every saveToActiveProject().
std::int64_t bankGeneration() const { return bankGeneration_; }
// Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes
// what a live instance would PLAY (capture add, re-capture-in-place, sample remove,
// move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create /
// rename / activate / reorder a bank), which change no existing (bankId, sampleId) ->
// content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call
// the same mutation already makes (the counter rides the persist blob, so there is no
// separate write). In-memory only here — cheap and REAPER-free; the persist is the write.
// Over-bumping is safe (a reload that finds unchanged content atomically re-installs the
// same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err
// toward bumping. Idempotent per logical op — call once per mutation, before the persist.
// 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_; }
// Serialize the current book (under the `banks` key), view model, and tail setting
// to the active project's ext state (namespace "reasampler"), and clear the retired
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
// Safe to call when there is no active/saved project (it no-ops).
//
// Returns true iff a persist actually happened (an active, SAVED project existed);
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
// caller wrapping this in an undo block skip the block when nothing was written, so
// no dangling no-effect undo entry is opened on an unsaved project.
// 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.
bool saveToActiveProject();
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
// relative machinery the index/persist use — never a stale absolute path, so it is
// correct across a Save-As relocation), spells every enumerated entry with the index's
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
// `referenced` = book().referencedPaths() every LIVE ReaSampler 9000 instance's
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
// live instance holds can never be an orphan, so the prune can never delete it.
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
// list. The decision stays in the pure core — this method only enumerates, resolves,
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
//
// Yields an empty report (count 0) when there is no active/saved project or no bank
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
// Report-only prune dry-run: feeds the pure core with (present,
// referenced, owned), where `referenced` = book references union every
// live instance's held captures (usage_scan + sample_usage decide
// liveness). FAIL-SAFE: an unreadable usage record sets
// abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout.
reclaim::PruneReport pruneDryRun() const;
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
// (pruneDryRun's truncated list is for the console readout; the delete set must be
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
// no active/saved project or no bank folder yet.
// The full (untruncated) orphan set, same compute as pruneDryRun. The
// prune action confirms this set before deleting it. Read-only.
std::vector<std::string> pruneOrphanSet() const;
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
// so a file that vanished or became referenced between confirm and delete is skipped,
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
// std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for
// per-platform routing). Non-throwing: every filesystem call uses error_code forms; a
// per-file failure (locked, already gone) is recorded and skipped, never thrown across
// the C ABI.
//
// Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
// algebra naturally once it is off disk — no persist write, so no undo-point question
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
//
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
// delete plan is empty (everything went stale). The caller is responsible for having
// shown the confirm; this method does NOT prompt.
// 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
// OwnedFileManifest, writes no ext-state. No-ops when nothing to delete;
// does not prompt.
reclaim::PruneDeletionResult pruneReclaim(
const std::vector<std::string>& confirmed) const;
// Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the
// `assign_request` key, namespace "reasampler"): the extension telling the active
// sampler instance "play THIS sample now." `wire` is the pure assignment_request
// encoding (assignment_request.h); this method only routes the already-encoded value
// to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and
// the encode live in the ingest shell (the pure module) so persist stays a thin bridge.
//
// A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an
// assignment request is a transient "just assigned" signal the instrument reads and
// acts on, so it rides its own key and is written only at ingest time, never on every
// book save. Returns true iff written (an active, SAVED project existed); false on a
// no-active / unsaved project (nothing to write into — the assign is dropped, matching
// the book/manifest quiet-persist idiom the ingest add-path already tolerates).
// 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. Returns true iff written.
bool writeAssignmentRequest(const std::string& wire);
// Poll the active project. Detects a project load (active project changed)
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
//
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
// identity classifier below reads it as NoOp and would never re-read ext state.
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
// (now-restored) ext state of the current project — before the identity check, so
// the undo is reflected in-session without any content polling.
// 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_ from the CURRENT active project's ext
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
// because the projectconfig callback fires BEFORE REAPER has restored the project's
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
// is REAPER-facing shell state; the request itself carries no REAPER types.
// 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 <EXTSTATE> block —
// reading synchronously there would return the pre-undo value.
void requestReload();
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
// (re)loads the view model from a project — prime, a project switch/open, or a
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
// it, so the integration layer (main.cpp) can react by reapplying the saved
// active mode's visibility exactly once, then goes quiet on idle ticks.
//
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
// where those two already meet. D3 deliberately deferred exactly this to D4.
// 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
model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited
version::WritingVersion writingVersion_; // recovered per load; PreVersioning default
std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic
// The Design-View model. Default-constructed = Arrange + Design seeded, active
// = Arrange; loadFromProject leaves this default when a project has no stored
// view_state (older project), so an absent key is graceful, not a crash.
ViewModeModel view_;
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
// to this default when a project has no stored tail_setting key (older / never-
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
capture::TailSetting tail_;
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
// a project with no stored manifest must not inherit the previous project's ownership
// record, and an undo that rolled back a capture must re-read the restored manifest so
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
model::OwnedFileManifest owned_;
// The writing-version stamp recovered on load (Phase V). Default PreVersioning;
// loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so
// switching to a pre-versioning project reports PreVersioning rather than inheriting
// the previous project's stamp. Read-only to consumers via writingVersion().
version::WritingVersion writingVersion_;
// The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path
// from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it
// continues monotonic from the persisted value across reopen and resets cleanly on a
// project switch (a different project's counter, not the previous one's). bumped by
// bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject().
// Default 0 for an unsaved / never-loaded / pre-S9 session.
std::int64_t bankGeneration_ = 0;
// The project identity last observed by poll(), used to detect load/Save-As.
// The GUID is the PRIMARY signal (a different stored GUID = a different project
// of record = Load, immune to pointer recycling). The pointer disambiguates the
// same-GUID case (different object = forked sibling -> Load; same object + new
// path -> Save-As) and drives forked-sibling re-divergence; the path tells a
// Save-As from an idle tick.
// Held as void* so the header stays REAPER-free; it is a compared-only opaque
// handle (never dereferenced), so a stale/recycled address is harmless.
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
// 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_; // .rpp path last seen for lastProject_
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 (projectconfig undo/redo); drained by poll
bool reloadRequested_ = false; // raised by requestReload; drained by poll
// Load the book from the given project's ext state (the `banks` key, else the
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
// and owned_ from their sibling keys on every load path. projectDir empty -> the
// book is reset to empty (unsaved project has no resolvable banks).
// Load the book from `proj`'s ext state (`banks`, else legacy
// `bank_index` migrated into the pool); also restores view_/tail_/owned_.
void loadFromProject(void* proj, const std::string& projectDir);
};