Route every extension bank-directory lookup through one bankRootOf seam

The pure bankRootFor(rppPath, unsavedRoot) and the shell bankRootOf(ReaProject*) replace eleven hand-rolled .rpp-parent derivations. No unsaved root is supplied yet, so behaviour and refusals are unchanged. Save-As relocation now reads the stored lastBankRoot_.
This commit is contained in:
2026-09-25 14:53:48 -04:00
parent c3e7556261
commit 43994e0d9d
27 changed files with 212 additions and 192 deletions
+1
View File
@@ -21,6 +21,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
${REASAMPLER_SRC_DIR}/shell/persist/session.cpp
${REASAMPLER_SRC_DIR}/shell/persist/bank_root.cpp
${REASAMPLER_SRC_DIR}/shell/persist/ext_state_io.cpp
${REASAMPLER_SRC_DIR}/shell/persist/prune_fs.cpp
${REASAMPLER_SRC_DIR}/shell/bank_ops/bank_ops.cpp
+1 -1
View File
@@ -48,7 +48,7 @@ Detail specific to these pure modules:
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the bank-root rule (`bankRootFor(rppPath, unsavedRoot)` — the `.rpp`'s directory, else the unsaved root, else empty, both branches in `normalizeSlashes` form; reached from the extension only through `shell/persist`'s `bankRootOf` seam, while the VST3 instrument still calls `projectDirOfRpp` directly), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
+6
View File
@@ -106,6 +106,12 @@ std::string projectDirOfRpp(const std::string& rppPath) {
return normalizeSlashes(dir);
}
std::string bankRootFor(const std::string& rppPath, const std::string& unsavedRoot) {
const std::string projectDir = projectDirOfRpp(rppPath);
if (!projectDir.empty()) return projectDir;
return normalizeSlashes(unsavedRoot);
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
+8 -2
View File
@@ -1,7 +1,7 @@
#pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
// capture shell resolves the current project directory via REAPER APIs, then
// hands the raw strings here. Forward-slash form throughout, no filesystem
// shell reads the .rpp path via REAPER APIs and resolves the bank root through
// bankRootFor, then hands the raw strings here. Forward-slash form throughout, no filesystem
// access; the bank subfolder name is a fixed constant.
#include <cstddef>
@@ -87,6 +87,12 @@ std::string resolveBankFile(const std::string& projectDir,
// same way persist does.
std::string projectDirOfRpp(const std::string& rppPath);
// The directory a project's bank lives under: the .rpp's directory when it has one,
// else `unsavedRoot` (where a never-saved project banks), else empty — never a
// default-location fallback. Either branch comes back in normalizeSlashes form, so a
// root compares the same whichever branch produced it.
std::string bankRootFor(const std::string& rppPath, const std::string& unsavedRoot);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which moved with the .rpp), so relocation is purely a folder
+4 -12
View File
@@ -17,10 +17,11 @@
#include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex
#include "core/model/bank_model.h" // Sample, AddResult, findByHash
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
#include "core/capture/capture_paths.h" // deriveBankPaths
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/undo/undo_block.h" // the ONE undo-block opener
@@ -29,7 +30,6 @@
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_MediaExplorerGetLastPlayedFileInfo
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
@@ -47,7 +47,6 @@ using capture::buildFloat32Wav;
using capture::deriveBankPaths;
using capture::hashWavContent;
using capture::parseWavLayout;
using capture::projectDirOfRpp;
using capture::WavLayout;
using util::readFileBytes;
using version::channelActionName;
@@ -79,14 +78,6 @@ std::string g_idImportStr;
std::string g_idImportMxStr;
std::string g_labelImportStr;
// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which
// makes the import refuse to place a file (relative-paths invariant, no fallback).
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
// Writes a byte buffer to a file. Returns true on success. The caller is responsible for
// ensuring the directory exists before calling.
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
@@ -170,7 +161,8 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
return out;
}
const std::string projectDir = currentProjectDir();
// Empty refuses to place a file: relative-paths invariant, no fallback.
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) {
out.message = "no saved project, so the bank has no location -- save the "
"project first";
+3 -9
View File
@@ -9,13 +9,13 @@
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp, sanitizeStem
#include "core/capture/capture_paths.h" // sanitizeStem
#include "shell/package/export_bank.h"
#include "shell/package/package_pickers.h"
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_ShowMessageBox
#include "reaper_plugin_functions.h"
@@ -28,12 +28,6 @@ constexpr const char* kUnsavedProjectMsg =
"ReaSampler export: save the project first -- an unsaved project has no bank folder "
"to read from.\n";
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// 6 == YES; anything else cancels (SDK ~6544).
bool confirmed(const std::string& msg, const char* title) {
return ShowMessageBox(msg.c_str(), title, 4) == 6;
@@ -118,7 +112,7 @@ void reportOutcome(const ExportOutcome& out, const std::string& destPath) {
} // namespace
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId) {
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) {
ShowConsoleMsg(kUnsavedProjectMsg);
return;
+4 -6
View File
@@ -13,12 +13,12 @@
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#include "shell/undo/undo_block.h" // the ONE undo-block opener
@@ -77,19 +77,17 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
std::string dir; // the project's bank root; empty when it has no bank location
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
ReaProject* proj = EnumProjects(idx, nullptr, 0);
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
out.push_back(OpenProject{proj, bankRootOf(proj)});
}
return out;
}
+9 -28
View File
@@ -36,6 +36,7 @@
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
#include "shell/capture/render_bounds_gate.h" // the exact-bounds verdict on the landed render
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -391,43 +392,23 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result;
}
// Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path
// out-param is empty for a project that has never been saved — a reliable
// unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not
// the .rpp location, and is never empty even when unsaved (the original bug —
// captures landed in REAPER's default media location instead of by the .rpp).
//
// Flow: read .rpp path; if empty, Main_SaveProject(proj, true) — true =
// No bank root (a never-saved project): Main_SaveProject(proj, true) — true =
// forceSaveAsIn — prompts Save-As and (per SDK header) blocks until dismissed,
// though that blocking behaviour cannot be verified from the header itself;
// re-read; if still empty (cancelled), refuse with NoProject and write nothing.
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
std::string rppPath = readRppPath();
if (rppPath.empty()) {
// re-resolve; if still empty (cancelled), refuse with NoProject and write nothing.
// Never GetProjectPathEx: that is the recording path, never empty even when
// unsaved (the original bug — captures landed in REAPER's default media location).
std::string projectDir = bankRootOf(proj);
if (projectDir.empty()) {
Main_SaveProject(proj, true);
rppPath = readRppPath();
projectDir = bankRootOf(proj);
}
if (rppPath.empty()) {
if (projectDir.empty()) {
result.status = CaptureStatus::NoProject;
result.message = "Project must be saved before capture — nothing captured.";
return result;
}
// Project dir = parent of the .rpp; forward-slash-normalized so the rest of
// the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
const std::string projectDir = [&]() -> std::string {
namespace fs = std::filesystem;
std::string dir = fs::path(rppPath).parent_path().string();
for (char& c : dir) { if (c == '\\') c = '/'; }
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir;
}();
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
// stem and Sample.id diverge (the counter advances per call).
const std::string uniqueTag = makeUniqueTag("");
+3 -6
View File
@@ -16,9 +16,9 @@
#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "core/model/bank_book.h" // BankBook / Bank
#include "core/model/provenance.h" // recipe parse/build, fingerprint
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid
@@ -31,7 +31,6 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItem_Track
#define REAPERAPI_WANT_GetMediaItemInfo_Value
@@ -104,10 +103,8 @@ private:
// at all -- the batch summary reports ordinals only.
std::string withQuarantineNote(std::string line, int quarantinedCount) {
if (quarantinedCount <= 0) return line;
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
const std::string dir = projectDirOfRpp(std::string(buf.data()));
if (dir.empty()) return line; // unreachable: a quarantine implies a saved project
const std::string dir = bankRootOf(nullptr);
if (dir.empty()) return line; // unreachable: a quarantine implies a bank root
line += " " + std::to_string(quarantinedCount) + " refused render" +
(quarantinedCount == 1 ? " was" : "s were") + " retained for diagnosis, "
"normally at " + refusedRenderFolder(dir) + " (delete when done) -- one "
+7 -22
View File
@@ -59,6 +59,7 @@
#include "core/capture/capture_realtime.h" // RecordPhase machine, record-mode plan (pure)
#include "core/capture/render_settings.h" // realtimeRecordWindowEnd
#include "shell/capture/capture_realtime_finalize.h" // recordedFilePath, finalizeRecording
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -84,20 +85,6 @@ namespace reasampler::capture {
namespace {
std::string normSlashes(std::string s) {
for (char& c : s) if (c == '\\') c = '/';
if (s.size() > 1 && s.back() == '/') s.pop_back();
return s;
}
// Reads the ACTIVE project's .rpp path (empty if unsaved). Only needed at begin()
// time, when the record's project IS the active project.
std::string readRppPath() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
}
// -1 if unresolved yet. Used by the flush wait to detect stability (size
// unchanged across a tick) before moving the file — a take REAPER is still
// flushing grows tick over tick.
@@ -290,20 +277,18 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
return nullptr;
}
// Saved-project gate (same as offline): the bank folder resolves against the
// .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved.
std::string rppPath = readRppPath();
if (rppPath.empty()) {
// Bank-root gate (same as offline): prompt Save-As once when there is no bank
// root; refuse if there still is none.
std::string projectDir = bankRootOf(proj);
if (projectDir.empty()) {
Main_SaveProject(proj, true);
rppPath = readRppPath();
projectDir = bankRootOf(proj);
}
if (rppPath.empty()) {
if (projectDir.empty()) {
outFailure.status = CaptureStatus::NoProject;
outFailure.message = "Project must be saved before capture — nothing captured.";
return nullptr;
}
const std::string projectDir =
normSlashes(std::filesystem::path(rppPath).parent_path().string());
RealtimeCaptureHandle st(new RealtimeCaptureState());
st->proj_ = proj;
+3 -16
View File
@@ -32,12 +32,12 @@
#include "core/model/bank_model.h"
#include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId
#include "core/capture/capture_paths.h"
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#include "shell/undo/undo_block.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_InsertMedia
@@ -50,7 +50,6 @@
namespace reasampler {
using capture::computeInsertMode;
using capture::normalizeSlashes;
using capture::resolveBankFile;
using capture::TempoConform;
@@ -58,18 +57,6 @@ namespace {
namespace fs = std::filesystem;
// Mirrors bank_panel/capture/persist's own derivation; the bank index stores
// relative paths, so resolving a file needs the current .rpp dir. A shared "current
// project dir" helper would be a clean small refactor now that a fourth consumer
// exists (also noted in panel_bank_ops.cpp) — out of scope here.
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
// Snapshot of the currently-selected track set (master is skipped, matching
// CountSelectedTracks/GetSelectedTrack), in selection order, for restore later.
std::vector<MediaTrack*> snapshotSelectedTracks() {
@@ -111,9 +98,9 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
const std::string& id = ids.front(); // focused / first selected — single sample
// WHERE: an unsaved project has no resolvable bank dir; no-op rather than
// WHERE: no bank root means no resolvable bank file; no-op rather than
// resolving against CWD.
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
// Resolve against the bank the selection came from — it may be the pool or a
+4 -18
View File
@@ -8,12 +8,12 @@
#include "shell/capture/scope_resolve.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <filesystem> // project-dir derivation for provenance parent resolution
#include <utility>
#include "core/capture/render_window.h" // itemExtentPrintsWindow
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
#include "shell/capture/track_guid.h" // guidString
#include "shell/persist/bank_root.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetSet_LoopTimeRange
@@ -195,28 +195,14 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
return true;
}
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
// makes every bank file resolve empty -> no false parentage.
std::string currentProjectDir()
{
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
const std::string rpp(buf.data());
if (rpp.empty()) return {};
namespace fs = std::filesystem;
std::string dir = fs::path(rpp).parent_path().string();
for (char& c : dir) if (c == '\\') c = '/';
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir;
}
// Detection rule: the capture's source item media file(s) must all resolve, by
// exact normalized absolute path, to one bank sample's file.
// exact normalized absolute path, to one bank sample's file. No bank root makes every
// bank file resolve empty -> no false parentage.
std::optional<model::Provenance> buildCaptureProvenance(
const BankBook& book, const CaptureRequest& req,
CaptureScope scope, const ResolvedSource& src)
{
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir);
// What "the source audio" means depends on scope: item scope uses the selected
-4
View File
@@ -72,10 +72,6 @@ std::string trackName(MediaTrack* tr);
// inferred range. Returns false with a reason on nothing to do.
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
// Current project's directory (parent of its .rpp), forward-slashed, no trailing
// slash. Empty for an unsaved project (no false parentage). Read-only.
std::string currentProjectDir();
// Builds the provenance for a capture if it genuinely resamples from a bank sample
// (detectParent over `book`'s resolved file refs), else returns nullopt (the common,
// non-resample case). Must run BEFORE the FxBypassGuard neutralizes the in-scope
+3 -10
View File
@@ -1,5 +1,5 @@
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
// import: the project directory, the minted bank id, the birth records, and the one
// import: the bank root, the minted bank id, the birth records, and the one
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
//
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
@@ -11,12 +11,11 @@
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
@@ -25,12 +24,6 @@ namespace reasampler {
namespace {
std::string activeProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// The model mints no ids (it stays pure and deterministic), so the shell does — the
// same GUID pair bankOpCreate uses.
std::string mintBankId() {
@@ -62,7 +55,7 @@ ImportBankResult importBankPackage(ReaSamplerSession& session,
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
LandedFileJournal journal;
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
const ImportLanding landing = landPackage(packageAbsPath, bankRootOf(nullptr),
session.book(), uniqueTag, journal);
out.outcome = landing.outcome;
out.header = landing.header;
+2 -1
View File
@@ -7,6 +7,7 @@
#include <string>
#include "shell/panel/panel_state.h"
#include "shell/persist/bank_root.h"
// PlayPreview/StopPreview (stock, not SWS-only) drive a caller-owned preview_register_t.
#define REAPERAPI_MINIMAL
@@ -75,7 +76,7 @@ void startAudition(int idx) {
const Sample* s = index->query(disp.orderedIds[static_cast<std::size_t>(idx)]);
if (!s) return;
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
const std::string abs = resolveBankFile(projectDir, s->relativePath);
if (abs.empty()) return;
+2 -11
View File
@@ -19,10 +19,10 @@
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
#include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetUserInputs
#define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_Main_OnCommand
@@ -32,15 +32,6 @@ namespace reasampler::panel {
namespace fs = std::filesystem;
// Mirrors the persist shell's derivation (ext_state_io.cpp).
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {};
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; }
// The BankModel a region currently displays. Pool region -> the pool; banks region ->
@@ -206,7 +197,7 @@ std::vector<std::string> resolveDragPathsForOs() {
const BankModel* idx = b->index(g_panel.dragSourceBankId);
if (!idx) return {};
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
resolved.reserve(g_panel.dragSampleIds.size());
for (const std::string& sid : g_panel.dragSampleIds) {
const Sample* s = idx->query(sid);
+2 -1
View File
@@ -12,6 +12,7 @@
#include "shell/panel/panel_state.h"
#include "shell/panel/draw_kit.h"
#include "shell/persist/bank_root.h"
#include "shell/persist/session.h"
#include "core/view/view_mode_model.h"
@@ -488,7 +489,7 @@ void paintPanel(HWND hwnd, HDC hdc) {
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
const std::string projectDir = currentProjectDir();
const std::string projectDir = bankRootOf(nullptr);
const std::string activeName = activeBankName();
if (poolShown()) {
-1
View File
@@ -460,7 +460,6 @@ BankBook* book();
const BankModel* indexForRegion(Region r);
std::string bankIdForRegion(Region r);
std::vector<const Bank*> namedBanks();
std::string currentProjectDir();
void doCreateBank();
void transferSamples(const std::vector<std::string>& sampleIds,
const std::string& srcBankId, const std::string& destBankId,
+4 -3
View File
@@ -3,8 +3,8 @@
## Scope
The persist seam: project ext-state read/write (`session` / `ext_state_io`), the
prune path's filesystem half (`prune_fs`), and the extension-side instance-usage
scan (`usage_scan`). Internal helpers shared only within the persist TU family live
extension's one bank-root resolver (`bank_root`), the prune path's filesystem half
(`prune_fs`), and the extension-side instance-usage scan (`usage_scan`). Internal helpers shared only within the persist TU family live
in `persist_internal.h`. The pure orphan computation is owned elsewhere
(`core/reclaim`), the pure usage wire elsewhere again (`core/wire`), and every
tracking *decision* by `core/tracking`'s authority — this directory is the
@@ -54,8 +54,9 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `saveViewStateOnly` is its narrowed sibling for a caller that changed only the Design-View model — `view_state` plus the dirty mark, no other key — and returns on that same rule; the full save stays the default and narrowing is opt-in per call site. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `bank_root` — **the extension's one bank-root resolver**, `bankRootOf(ReaProject*)` (nullptr = the active project): reads the project's `.rpp` path via `EnumProjects` and answers with the pure `capture::bankRootFor`. Every extension site that resolves or writes bank files — capture (offline + realtime), batch, bake landing, insert, provenance, the panel's audition/thumbnails/drag-out, ingest, package import/export, prune, and the session's Save-As relocation — asks here; none derives a directory from an `.rpp` path itself. Empty means "no bank location" and every caller refuses on it exactly as before. No unsaved root is supplied yet, so a never-saved project still has none. It answers WHERE the bank is, not whether the project is saved: the ext-state write gates (`activeSavedProject`, `ensureProjectGuid`), Design View's persist prompt and prune's own saved-project gate stay keyed on the `.rpp` path. The session's Save-As relocation reads its old root from `lastBankRoot_` (why: `session.h`). The VST3 instrument does not use this seam (`shell/instrument` resolves its own paths).
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
## Gotchas
+41
View File
@@ -0,0 +1,41 @@
// bank_root.cpp — see bank_root.h. main.cpp owns the API pointers; this TU gets them
// extern.
#include "shell/persist/bank_root.h"
#include <vector>
#include "core/capture/capture_paths.h" // bankRootFor
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// EnumProjects is the only SDK read of a project's .rpp path — GetProjectPathEx is the
// recording path, GetProjectName the bare file name. Empty for a never-saved project.
// The active project is read through idx -1 first, so the common case is one call.
std::string rppPathOf(ReaProject* proj) {
std::vector<char> buf(4096, '\0');
ReaProject* const active = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
if (!proj || proj == active) return std::string(buf.data());
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* const tab = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
if (!tab) return {}; // not an open tab: no location to answer with
if (tab == proj) return std::string(buf.data());
}
}
} // namespace
std::string bankRootOf(ReaProject* proj) {
// No unsaved root is supplied: a never-saved project has no bank location.
return capture::bankRootFor(rppPathOf(proj), std::string{});
}
} // namespace reasampler
+17
View File
@@ -0,0 +1,17 @@
#pragma once
// bank_root — the extension's ONE answer to "which directory is this project's bank
// under" (the rule is this directory's CLAUDE.md). The index still stores only
// "reasampler_bank/<file>" relative to this root.
#include <string>
class ReaProject;
namespace reasampler {
// `proj`'s bank root per capture::bankRootFor: forward-slashed, no trailing slash, and
// EMPTY when the project has no bank location or is not an open tab — callers refuse on
// empty, never fall back. nullptr = the active project (REAPER's own convention).
std::string bankRootOf(ReaProject* proj);
} // namespace reasampler
-8
View File
@@ -30,7 +30,6 @@
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic)
#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
#include "core/version/app_version.h"
@@ -59,13 +58,6 @@ void* readActiveProject(std::string& rppPathOut) {
return proj;
}
// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own
// directory. Delegates to the pure projectDirOfRpp so both artifacts share one
// implementation.
std::string projectDirOf(const std::string& rppPath) {
return capture::projectDirOfRpp(rppPath);
}
std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
using wire::GrowingExtStateRead;
const GrowingExtStateRead read = wire::readProjExtStateGrowing(
-5
View File
@@ -16,11 +16,6 @@ namespace reasampler::persist_detail {
// reliable unsaved sentinel). Returns nullptr only when there is no active project.
void* readActiveProject(std::string& rppPathOut);
// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in ->
// empty out. Delegates to the pure capture::projectDirOfRpp — the SAME convention
// the VST3 instrument resolves audio paths by.
std::string projectDirOf(const std::string& rppPath);
// Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns
// "" when the key is absent (a valid empty bank, not an error) and warns on the
// console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored).
+5 -4
View File
@@ -17,7 +17,8 @@
// confirm — executes.
//
// Compiled into the reaper_reasampler module. REAPER-facing only through the
// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly.
// persist_detail helpers, bank_root and usage_scan; this TU itself calls no REAPER API
// directly.
#include <cstdint>
#include <filesystem>
@@ -37,6 +38,7 @@
#include <shellapi.h>
#endif
#include "shell/persist/bank_root.h"
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs
@@ -51,7 +53,6 @@ namespace {
namespace fs = std::filesystem;
using persist_detail::projectDirOf;
using persist_detail::readActiveProject;
// The dry-run file-list display cap: count and size are always exact
@@ -94,10 +95,10 @@ PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& l
// Resolve the current bank folder the same way the index does — never a
// stored absolute path, so a Save-As relocation is followed automatically.
const std::string projectDir = projectDirOf(rppPath);
const std::string projectDir = bankRootOf(static_cast<ReaProject*>(proj));
const std::string bankDir =
capture::resolveBankFile(projectDir, capture::kBankSubfolder);
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
if (bankDir.empty()) return scan; // unresolvable (no bank root) -> empty scan
std::error_code ec;
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
+15 -8
View File
@@ -27,6 +27,7 @@
#include <string>
#include "shell/persist/bank_root.h"
#include "shell/persist/ext_state_io.h"
#include "shell/persist/persist_internal.h"
@@ -43,7 +44,6 @@ 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;
@@ -96,10 +96,12 @@ void ReaSamplerSession::poll() {
// 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));
const std::string root = bankRootOf(static_cast<ReaProject*>(proj));
loadFromProject(proj, root);
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
lastBankRoot_ = root;
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
return;
}
@@ -113,10 +115,12 @@ void ReaSamplerSession::poll() {
// path — the timer never polls ext-state content to detect an undo.
if (reloadRequested_) {
reloadRequested_ = false;
loadFromProject(proj, projectDirOf(rppPath));
const std::string root = bankRootOf(static_cast<ReaProject*>(proj));
loadFromProject(proj, root);
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
lastBankRoot_ = root;
return;
}
@@ -133,6 +137,7 @@ void ReaSamplerSession::poll() {
case capture::ProjectTransition::Load: {
// A different project of record is active. Load its index; never relocate.
const std::string root = bankRootOf(static_cast<ReaProject*>(proj));
//
// Forked-sibling re-GUID: gate on `!sameProjectObject` so this fires
// only for the fork case (same GUID, different object) — a Save-As
@@ -146,18 +151,20 @@ void ReaSamplerSession::poll() {
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
loadFromProject(proj, projectDirOf(rppPath));
loadFromProject(proj, root);
lastProject_ = proj;
lastGuid_ = fresh;
lastRppPath_ = rppPath;
lastBankRoot_ = root;
return;
}
// Normal load: establish identity the same way prime does.
loadFromProject(proj, projectDirOf(rppPath));
loadFromProject(proj, root);
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
lastBankRoot_ = root;
return;
}
@@ -166,10 +173,9 @@ void ReaSamplerSession::poll() {
// 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 std::string newRoot = bankRootOf(static_cast<ReaProject*>(proj));
const capture::BankRelocation plan =
capture::deriveRelocationPlan(oldDir, newDir);
capture::deriveRelocationPlan(lastBankRoot_, newRoot);
if (plan.needed) {
relocateBankFolder(plan.oldBankDir, plan.newBankDir);
}
@@ -186,6 +192,7 @@ void ReaSamplerSession::poll() {
lastProject_ = proj; // unchanged (same object) — set for symmetry
lastGuid_ = fresh;
lastRppPath_ = rppPath;
lastBankRoot_ = newRoot;
return;
}
}
+3
View File
@@ -229,6 +229,9 @@ private:
void* lastProject_ = nullptr;
std::string lastGuid_; // "" until the first saved project is seen
std::string lastRppPath_;
// Adopted with lastRppPath_. A Save-As relocates FROM this: by then the project
// object reports only its new location, so the old root cannot be re-asked.
std::string lastBankRoot_;
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
+49
View File
@@ -402,6 +402,49 @@ static void testBankRelativeForNameConventionAndEdge() {
CHECK(bankRelativeForName("").empty());
}
// --- bankRootFor --------------------------------------------------------------
static void testBankRootForSavedProjectIsTheRppDirectory() {
CHECK(bankRootFor("/home/d/proj/song.rpp", "") == "/home/d/proj");
#ifdef _WIN32
CHECK(bankRootFor("C:\\Users\\D\\Proj\\song.rpp", "") == "c:/users/d/proj");
#endif
}
static void testBankRootForSavedProjectIgnoresTheUnsavedRoot() {
// Once the project has a directory, a supplied unsaved root must not leak through.
CHECK(bankRootFor("/home/d/proj/song.rpp", "/tmp/unsaved") == "/home/d/proj");
}
static void testBankRootForUnsavedProjectFallsBackToTheUnsavedRoot() {
CHECK(bankRootFor("", "/tmp/unsaved") == "/tmp/unsaved");
// Normalized like the saved branch: trailing slash stripped, backslashes flipped.
CHECK(bankRootFor("", "/tmp/unsaved/") == "/tmp/unsaved");
#ifdef _WIN32
CHECK(bankRootFor("", "C:\\Temp\\Unsaved\\") == "c:/temp/unsaved");
#endif
}
static void testBankRootForUnsavedProjectWithNoUnsavedRootIsEmpty() {
// No CWD / default-location fallback: an unsaved project with nowhere to bank
// yields empty, which every caller treats as "no bank location".
CHECK(bankRootFor("", "").empty());
}
static void testBankRootForRppWithNoDirectoryIsTreatedAsUnsaved() {
CHECK(bankRootFor("song.rpp", "/tmp/unsaved") == "/tmp/unsaved");
CHECK(bankRootFor("song.rpp", "").empty());
}
static void testBankRootForBothBranchesProduceComparableRoots() {
// The same directory reached through either branch is the same string, so a
// root recorded under one branch compares equal to the other's.
CHECK(bankRootFor("/a/b/song.rpp", "") == bankRootFor("", "/a/b/"));
#ifdef _WIN32
CHECK(bankRootFor("C:\\A\\B\\song.rpp", "") == bankRootFor("", "c:/a/b"));
#endif
}
int main() {
testNormalizeSlashes();
testNormalizeSlashesCaseFolding();
@@ -433,6 +476,12 @@ int main() {
testRenderPathsTakeTheirDirectoryVerbatim();
testRenderPathsEmptyDirectoryStaysEmpty();
testBankRelativeForNameConventionAndEdge();
testBankRootForSavedProjectIsTheRppDirectory();
testBankRootForSavedProjectIgnoresTheUnsavedRoot();
testBankRootForUnsavedProjectFallsBackToTheUnsavedRoot();
testBankRootForUnsavedProjectWithNoUnsavedRootIsEmpty();
testBankRootForRppWithNoDirectoryIsTreatedAsUnsaved();
testBankRootForBothBranchesProduceComparableRoots();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);