fix(persist): use project-object identity to stop forked-bank cross-contamination

M4's GUID-only classifier read a tab-switch between two Save-As forks (shared
copied GUID, different paths) as a Save-As and clobbered a bank. Thread
sameProjectObject into classifyProjectTransition: a different object always
Loads, never relocates; a forked sibling gets re-GUID'd to diverge.
This commit is contained in:
2026-07-22 21:28:54 -04:00
parent aacf8b49e8
commit 36270e2064
5 changed files with 212 additions and 116 deletions
+63 -22
View File
@@ -14,23 +14,33 @@
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext
// state. Identity is that GUID — CONTENT-BASED, not the ReaProject* pointer:
// * GUID changed -> a different project became active (open a project, switch
// tab, new project, OR a recycled pointer) -> LOAD its index from ext state.
// * same GUID, .rpp path changed -> genuine Save-As to a new location ->
// relocate the bank folder from the old dir to the new one.
// Why not the pointer: REAPER recycles a closed project's ReaProject* address
// for a newly-active project. Keying Save-As off "same pointer + new path" let
// that recycling read as a Save-As and copy the wrong bank over a real one
// (the M4 defect). classifyProjectTransition (pure, capture_paths) encodes the
// decision; poll() only supplies the observed identity and executes the verdict.
// state. Identity rests on the live ReaProject* POINTER first, with the GUID as
// a secondary signal:
// * different project object (proj != lastProject_) -> a switch/open/new
// project -> LOAD its index; NEVER relocate. If its stored GUID still equals
// the one we just left (a forked sibling that copied our GUID via Save-As),
// re-GUID it so the siblings diverge going forward.
// * SAME object, .rpp path changed -> genuine Save-As to a new location ->
// relocate the bank folder from the old dir to the new one, then re-GUID.
// Why the pointer is back (W10 fix): the M4 GUID-only scheme could not tell a
// Save-As from a tab-switch between two FORKED projects. Save-As copies the whole
// .rpp incl. our stored GUID, so a fork and its parent share a GUID on disk;
// switching between them (same GUID, different paths) read as a Save-As and
// clobbered a bank. The pointer is the only signal that separates "same object
// saved elsewhere" (Save-As) from "different object sharing a copied GUID"
// (a switch). classifyProjectTransition (pure, capture_paths) takes a
// `sameProjectObject` bool (poll() computes `proj == lastProject_`) so the
// decision stays REAPER-free and testable; poll() executes the verdict.
// Pointer *reuse* (a recycled address for a genuinely different project) stays
// correct: it is a different object at that moment, so it Loads, and its GUID
// differs or gets re-diverged.
//
// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
// a cross-open identity), so we MINT one with genGuid/guidToString and store it
// under kProjExtGuidKey. On Save-As REAPER copies the whole .rpp incl. our ext
// state, so the new project initially shares the old GUID; poll() re-GUIDs it
// after relocating so the two projects' identities diverge going forward.
// (after relocating, or on the forked-sibling Load branch) so identities diverge.
//
// Rationale for the timer: the brief mandates ext-state storage (rules out the
// projectconfig .rpp-line hook), and the timer composes cleanly with ext-state
@@ -267,33 +277,63 @@ void ReaSamplerSession::poll() {
// 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;
return;
}
const ProjectTransition transition =
classifyProjectTransition(lastGuid_, lastRppPath_, currentGuid, rppPath);
// Pointer identity is the primary signal: a genuine Save-As keeps the SAME
// ReaProject* (one object saved elsewhere); a tab-switch/open is a different
// object. Passing the bool (not the pointer) keeps the classifier pure.
const bool sameProjectObject = (proj == lastProject_);
const ProjectTransition transition = classifyProjectTransition(
sameProjectObject, lastGuid_, lastRppPath_, currentGuid, rppPath);
switch (transition) {
case ProjectTransition::NoOp:
return;
case ProjectTransition::Load:
// A different project is active (open / tab switch / new project /
// recycled pointer). Load ITS index; never relocate. Establish its
// identity the same way prime does.
case ProjectTransition::Load: {
// A DIFFERENT project object is active (open / tab switch / new project
// / recycled pointer). Load ITS index; never relocate.
//
// Forked-sibling divergence: if the now-active project's stored GUID
// still equals the one we just left, it is a Save-As fork that copied
// our GUID and never re-saved (its fresh GUID was runtime-only on the
// sibling we came from). Left alone, the two keep colliding on identity.
// Mint a fresh GUID for the now-active project so the siblings diverge
// going forward. Do this BEFORE loadFromProject reads the index (order
// is irrelevant to the index — GUID and bank_index are distinct keys —
// but keeping the write self-contained is clearest).
if (proj && !currentGuid.empty() && currentGuid == lastGuid_ &&
!rppPath.empty()) {
const std::string fresh = genProjectGuidString();
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
loadFromProject(proj, projectDirOf(rppPath));
lastProject_ = proj;
lastGuid_ = fresh;
lastRppPath_ = rppPath;
return;
}
// Normal load: establish identity the same way prime does.
loadFromProject(proj, projectDirOf(rppPath));
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
return;
}
case ProjectTransition::SaveAsRelocate: {
// Same GUID + new .rpp path: a genuine Save-As. Relocate the bank
// folder from the old dir to the new one 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, the relative
// paths are unchanged) — do NOT reload.
// SAME project object + new .rpp path: a genuine Save-As (the pointer
// proves it — a fork tab-switch is a DIFFERENT object and took the Load
// branch above). Relocate the bank folder from the old dir to the new
// one 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, the relative paths are unchanged) — do NOT reload.
const std::string oldDir = projectDirOf(lastRppPath_);
const std::string newDir = projectDirOf(rppPath);
const BankRelocation plan = deriveRelocationPlan(oldDir, newDir);
@@ -312,6 +352,7 @@ void ReaSamplerSession::poll() {
kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
}
lastProject_ = proj; // unchanged (same object) — set for symmetry
lastGuid_ = fresh;
lastRppPath_ = rppPath;
return;