diff --git a/src/core/instrument/map/sample_roots.cpp b/src/core/instrument/map/sample_roots.cpp index 4ab5649..9074aa2 100644 --- a/src/core/instrument/map/sample_roots.cpp +++ b/src/core/instrument/map/sample_roots.cpp @@ -6,10 +6,12 @@ namespace reasampler::instrument::map { -std::string ownProjectDir(const void* own, const std::vector& open) { - if (!own) return {}; +std::string ownProjectDir(const void* own, const std::vector& open, + const void* activeFallback) { + const void* target = own ? own : activeFallback; + if (!target) return {}; for (const ProjectTab& tab : open) { - if (tab.project == own) return capture::projectDirOfRpp(tab.rppPath); + if (tab.project == target) return capture::projectDirOfRpp(tab.rppPath); } return {}; } diff --git a/src/core/instrument/map/sample_roots.h b/src/core/instrument/map/sample_roots.h index a419087..997197e 100644 --- a/src/core/instrument/map/sample_roots.h +++ b/src/core/instrument/map/sample_roots.h @@ -19,11 +19,17 @@ struct ProjectTab { std::string rppPath; }; -// The directory of the project whose identity is `own`; empty when `own` is null, is not -// among `open`, or was never saved. Deliberately no fallback to any other project: another -// project's bank folder can hold a same-named file, and playing it is the wrong-project -// bug this exists to close. -std::string ownProjectDir(const void* own, const std::vector& open); +// The directory of the project whose identity is `own`; empty when `own` is not among +// `open` or was never saved. Deliberately no fallback to any other project for a +// NON-NULL `own`: another project's bank folder can hold a same-named file, and playing +// it is the wrong-project bug this exists to close. +// +// `activeFallback` is used ONLY when `own` is null (REAPER may not expose +// getReaperParent(3) yet during plugin creation/setState) — then it stands in for `own` +// unchanged, so a still-null fallback (or one absent from `open`) resolves nothing exactly +// as a null `own` always did. +std::string ownProjectDir(const void* own, const std::vector& open, + const void* activeFallback = nullptr); // The roots a bank-relative path is tried against, in order. Only the project directory // today — the ordered list is the seam a later root (tried after it) extends. Empty when diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 1f03b94..1c5ae4f 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -320,12 +320,15 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT ordered root list; every instrument-side sample read (`reloadInstrument`, `bakeSnapshot`, the editor's `channelPcmFor`/`monoPcmFor`) goes through `readFromFirstRoot` over it. The old `EnumProjects(-1)` lookup resolved a background tab's instance against the FOCUSED project's - folder — do not reintroduce it, not even as a fallback. A null parent, an unlisted project - or an unsaved one yields no roots, which plays nothing, exactly as an unsaved project always - did. The ordered list is one element today; a later root is added after the project - directory, in `sampleRootCandidates`. `[verify — DAW]`: that `getReaperParent(3)` is non-null - and enumerable during `setState` on project load, and that a background-tab instance and - two open projects each with an instance play their own project's sample. + folder — do not reintroduce it as the PRIMARY resolve. It survives ONE sanctioned use: when + `getReaperParent(3)` itself comes back null (REAPER may not expose the parent during plugin + creation/setState), `ownProjectDir` falls back to the focused tab — because at that point there + is no "own project" identity to prefer a wrong answer over. A NON-NULL parent that is an + unlisted project or an unsaved one still yields no roots, which plays nothing, exactly as an + unsaved project always did. The ordered list is one element today; a later root is added after + the project directory, in `sampleRootCandidates`. `[verify — DAW]`: that `getReaperParent(3)` is + non-null and enumerable during `setState` on project load, and that a background-tab instance + and two open projects each with an instance play their own project's sample. - The bake's availability probe runs on the SAME tick that paints the button, so the control can never be enabled on one tick and refuse on the next. The bake Hold control's applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index 10464f4..6781524 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -94,8 +94,8 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { std::optional ReaperBridge::readReasamplerExtState(const std::string& key) { if (!getProjExtState_ || !hostApp_) return std::nullopt; - // getReaperParent(3) reads the live "reasampler" ext-state against the active project - // the instrument was instantiated in, so it follows project switches for free. A null + // getReaperParent(3) reads the live "reasampler" ext-state against the project the + // instrument was instantiated in, so it follows project switches for free. A null // project is legitimate (REAPER treats it as the current project) — pass it through // rather than bailing; a fruitless read still yields nullopt to the caller. auto* reaper = static_cast(hostApp_); @@ -204,7 +204,6 @@ std::vector ReaperBridge::sampleRoots() { if (!enumProjects_ || !hostApp_) return {}; auto* reaper = static_cast(hostApp_); const void* own = reaper->getReaperParent(3); - if (!own) return {}; std::vector open; std::vector buf(4096, '\0'); @@ -220,7 +219,15 @@ std::vector ReaperBridge::sampleRoots() { // A duplicate of a listed tab is harmless: the first identity match wins. constexpr int kRenderingProject = 0x40000000; enumerate(kRenderingProject); - return sampleRootCandidates(ownProjectDir(own, open)); + + // getReaperParent(3) can come back null while REAPER has not yet exposed the parent + // (plugin creation/setState) — the focused tab is the one sanctioned fallback, used + // ONLY then; see ownProjectDir. A non-null own that isn't in `open`, or is unsaved, + // still resolves nothing. + const void* activeFallback = + own ? nullptr + : enumProjects_(-1, buf.data(), static_cast(buf.size())); + return sampleRootCandidates(ownProjectDir(own, open, activeFallback)); } } // namespace reasampler::vst diff --git a/src/shell/instrument/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h index e4305fb..ea20450 100644 --- a/src/shell/instrument/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -32,8 +32,9 @@ public: bool isConnected() const { return getProjExtState_ != nullptr; } - // Reads a "reasampler" ext-state value by key from the host's active project. - // Returns nullopt when unconnected, unresolvable, or the key is absent. + // Reads a "reasampler" ext-state value by key from this instance's own project + // (getReaperParent(3)). Returns nullopt when unconnected, unresolvable, or the key is + // absent. // // NOT REAL-TIME SAFE (allocates + calls into REAPER): audio-thread callers MUST NOT // invoke this. The instrument reads on the main/UI thread and hands a snapshot to @@ -42,8 +43,11 @@ public: // The ordered roots this instance's bank-relative sample paths resolve against — its OWN // project's directory (getReaperParent(3) matched among EnumProjects' open projects), - // never the focused tab's; see core/instrument/map/sample_roots. Empty when unconnected, - // when the own project is not found, or when it was never saved. Not RT-safe. + // never the focused tab's; see core/instrument/map/sample_roots. When getReaperParent(3) + // is null (REAPER may not expose it yet during plugin creation/setState), falls back to + // the focused tab (EnumProjects(-1)) — the one sanctioned use of it. Empty when + // unconnected, when neither resolves to an open project, or when it was never saved. + // Not RT-safe. std::vector sampleRoots(); // The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one @@ -110,8 +114,9 @@ private: char* keyOut, int keyOut_sz, char* valOut, int valOut_sz); // EnumProjects(idx, projfnOut, sz) -> the idx'th open project + its .rpp path, null past - // the last. Walked by index only: idx=-1 (the focused tab) is the wrong-project lookup - // sampleRoots replaced. + // the last. Walked by index for the open-tabs list; idx=-1 (the focused tab) was the + // wrong-project lookup sampleRoots replaced as the PRIMARY resolve — it survives only as + // the fallback used when getReaperParent(3) is null (see sampleRoots). using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); // Used ONLY by the two prefix-guarded writers — see the read-only-bank note there. using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, diff --git a/tests/test_sample_roots.cpp b/tests/test_sample_roots.cpp index 581ef89..f7a4ff8 100644 --- a/tests/test_sample_roots.cpp +++ b/tests/test_sample_roots.cpp @@ -64,6 +64,18 @@ static void testNoOpenProjectsResolvesNothing() { CHECK(ownProjectDir(&projA, {}).empty()); } +static void testNullOwnFallsBackToTheActiveTab() { + CHECK(ownProjectDir(nullptr, threeTabs(), &projB) == "c:/projects/b"); +} + +static void testNullOwnAndNullFallbackResolvesNothing() { + CHECK(ownProjectDir(nullptr, threeTabs(), nullptr).empty()); +} + +static void testNonNullOwnIgnoresTheFallback() { + CHECK(ownProjectDir(&stranger, threeTabs(), &projB).empty()); +} + // --- sampleRootCandidates ----------------------------------------------------- static void testProjectDirIsTheOnlyCandidate() { @@ -141,6 +153,9 @@ int main() { testUnlistedProjectResolvesNothingRatherThanAnotherTab(); testUnsavedOwnProjectResolvesNothingEvenBesideSavedTabs(); testNoOpenProjectsResolvesNothing(); + testNullOwnFallsBackToTheActiveTab(); + testNullOwnAndNullFallbackResolvesNothing(); + testNonNullOwnIgnoresTheFallback(); testProjectDirIsTheOnlyCandidate(); testNoProjectDirMeansNoCandidates();