From 43994e0d9d45d155b1d3f5772d134e4c89aba4d9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 25 Sep 2026 14:53:48 -0400 Subject: [PATCH] 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_. --- src/app/CMakeLists.txt | 1 + src/core/capture/CLAUDE.md | 2 +- src/core/capture/capture_paths.cpp | 6 +++ src/core/capture/capture_paths.h | 10 +++- src/shell/actions/ingest.cpp | 16 ++---- src/shell/actions/package_export_action.cpp | 12 ++--- src/shell/capture/bake_land.cpp | 10 ++-- src/shell/capture/capture.cpp | 37 ++++---------- src/shell/capture/capture_batch.cpp | 9 ++-- src/shell/capture/capture_realtime_shell.cpp | 29 +++-------- src/shell/capture/insert.cpp | 19 ++----- src/shell/capture/scope_resolve.cpp | 22 ++------ src/shell/capture/scope_resolve.h | 4 -- src/shell/package/import_bank.cpp | 13 ++--- src/shell/panel/panel_audition.cpp | 3 +- src/shell/panel/panel_bank_ops.cpp | 13 +---- src/shell/panel/panel_render.cpp | 3 +- src/shell/panel/panel_state.h | 1 - src/shell/persist/CLAUDE.md | 7 +-- src/shell/persist/bank_root.cpp | 41 +++++++++++++++ src/shell/persist/bank_root.h | 17 +++++++ src/shell/persist/ext_state_io.cpp | 8 --- src/shell/persist/persist_internal.h | 5 -- src/shell/persist/prune_fs.cpp | 9 ++-- src/shell/persist/session.cpp | 53 +++++++++++--------- src/shell/persist/session.h | 5 +- tests/test_capture_paths.cpp | 49 ++++++++++++++++++ 27 files changed, 212 insertions(+), 192 deletions(-) create mode 100644 src/shell/persist/bank_root.cpp create mode 100644 src/shell/persist/bank_root.h diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index a295cce..aff3d71 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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 diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 2f0e2fa..792aca4 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -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). diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index e3ab731..cd148f4 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -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; diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 77049b1..1dbe6d7 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -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 @@ -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 diff --git a/src/shell/actions/ingest.cpp b/src/shell/actions/ingest.cpp index 538494d..50a7490 100644 --- a/src/shell/actions/ingest.cpp +++ b/src/shell/actions/ingest.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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& 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"; diff --git a/src/shell/actions/package_export_action.cpp b/src/shell/actions/package_export_action.cpp index fcaebe3..25cf776 100644 --- a/src/shell/actions/package_export_action.cpp +++ b/src/shell/actions/package_export_action.cpp @@ -9,13 +9,13 @@ #include #include -#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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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; diff --git a/src/shell/capture/bake_land.cpp b/src/shell/capture/bake_land.cpp index add8848..341a7fd 100644 --- a/src/shell/capture/bake_land.cpp +++ b/src/shell/capture/bake_land.cpp @@ -13,12 +13,12 @@ #include #include -#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 openProjects() { std::vector out; - std::vector buf(4096, '\0'); for (int idx = 0;; ++idx) { - buf[0] = '\0'; - ReaProject* proj = EnumProjects(idx, buf.data(), static_cast(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; } diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 8c6e892..f6a738b 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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(""); diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 0e5ca41..a552ac2 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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 " diff --git a/src/shell/capture/capture_realtime_shell.cpp b/src/shell/capture/capture_realtime_shell.cpp index e596091..f518bc4 100644 --- a/src/shell/capture/capture_realtime_shell.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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; diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index 3e0411d..78f7dbd 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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 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 diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp index 5d276b0..0405db5 100644 --- a/src/shell/capture/scope_resolve.cpp +++ b/src/shell/capture/scope_resolve.cpp @@ -8,12 +8,12 @@ #include "shell/capture/scope_resolve.h" #include // strnlen — bounded read of GetTrackName's buffer -#include // project-dir derivation for provenance parent resolution #include #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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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 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 bankFiles = bankFileRefs(book, projectDir); // What "the source audio" means depends on scope: item scope uses the selected diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h index a5ba132..b17f35b 100644 --- a/src/shell/capture/scope_resolve.h +++ b/src/shell/capture/scope_resolve.h @@ -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 diff --git a/src/shell/package/import_bank.cpp b/src/shell/package/import_bank.cpp index 2261361..a17f29c 100644 --- a/src/shell/package/import_bank.cpp +++ b/src/shell/package/import_bank.cpp @@ -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 #include -#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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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::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; diff --git a/src/shell/panel/panel_audition.cpp b/src/shell/panel/panel_audition.cpp index 407563a..5bedfca 100644 --- a/src/shell/panel/panel_audition.cpp +++ b/src/shell/panel/panel_audition.cpp @@ -7,6 +7,7 @@ #include #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(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; diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index d3700ef..60fecc4 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -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 buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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 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); diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 9541a27..3bb2a8a 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -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()) { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 3a43271..373816a 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -460,7 +460,6 @@ BankBook* book(); const BankModel* indexForRegion(Region r); std::string bankIdForRegion(Region r); std::vector namedBanks(); -std::string currentProjectDir(); void doCreateBank(); void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, diff --git a/src/shell/persist/CLAUDE.md b/src/shell/persist/CLAUDE.md index b172052..8b39f19 100644 --- a/src/shell/persist/CLAUDE.md +++ b/src/shell/persist/CLAUDE.md @@ -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 diff --git a/src/shell/persist/bank_root.cpp b/src/shell/persist/bank_root.cpp new file mode 100644 index 0000000..4e04e89 --- /dev/null +++ b/src/shell/persist/bank_root.cpp @@ -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 + +#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 buf(4096, '\0'); + ReaProject* const active = EnumProjects(-1, buf.data(), static_cast(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(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 diff --git a/src/shell/persist/bank_root.h b/src/shell/persist/bank_root.h new file mode 100644 index 0000000..509221c --- /dev/null +++ b/src/shell/persist/bank_root.h @@ -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/" relative to this root. + +#include + +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 diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 37eb5c9..e4f88f5 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -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( diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h index c98345a..dffd463 100644 --- a/src/shell/persist/persist_internal.h +++ b/src/shell/persist/persist_internal.h @@ -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). diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp index 7eec6dd..fb8a5d0 100644 --- a/src/shell/persist/prune_fs.cpp +++ b/src/shell/persist/prune_fs.cpp @@ -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 #include @@ -37,6 +38,7 @@ #include #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(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)) { diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp index 3329051..48b6ae7 100644 --- a/src/shell/persist/session.cpp +++ b/src/shell/persist/session.cpp @@ -27,6 +27,7 @@ #include +#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)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; + const std::string root = bankRootOf(static_cast(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)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = rppPath; + const std::string root = bankRootOf(static_cast(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(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(proj), projExtNamespace(), kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); - loadFromProject(proj, projectDirOf(rppPath)); - lastProject_ = proj; - lastGuid_ = fresh; - lastRppPath_ = 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)); - lastProject_ = proj; - lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); - lastRppPath_ = 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(proj)); const capture::BankRelocation plan = - capture::deriveRelocationPlan(oldDir, newDir); + capture::deriveRelocationPlan(lastBankRoot_, newRoot); if (plan.needed) { relocateBankFolder(plan.oldBankDir, plan.newBankDir); } @@ -183,9 +189,10 @@ void ReaSamplerSession::poll() { kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); } - lastProject_ = proj; // unchanged (same object) — set for symmetry - lastGuid_ = fresh; - lastRppPath_ = rppPath; + lastProject_ = proj; // unchanged (same object) — set for symmetry + lastGuid_ = fresh; + lastRppPath_ = rppPath; + lastBankRoot_ = newRoot; return; } } diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index f2a05f8..4ebdf8d 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -229,7 +229,10 @@ private: 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 + // 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 diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 25e9640..ae73961 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -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);