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
+71 -42
View File
@@ -178,64 +178,92 @@ static void testRelocationPlanEmptyInputsNoOp() {
CHECK(!deriveRelocationPlan("", "").needed);
}
// --- Project-identity transition (M4 defect fix) ----------------------------
// --- Project-identity transition (W10 forked-project fix) -------------------
//
// Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath,
// currentGuid, currentPath).
// The first arg — did the SAME ReaProject* stay active across the two ticks —
// is the primary signal; poll() computes it as `proj == lastProject_`.
static void testTransitionRecycledPointerLoadsNotRelocates() {
// THE ORIGINAL BUG. Project A (guidA, /a/a.rpp) is closed; REAPER makes a
// different saved project B active with a recycled pointer. B carries its own
// GUID and a different path. With identity keyed on the GUID this is a Load,
// NOT a Save-As — so the bank is never clobbered.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
static void testTransitionForkTabSwitchLoadsNeverRelocates() {
// THE W10 BUG. proj2 and proj3 are forked siblings (Save-As copied the .rpp
// incl. our GUID), so BOTH carry the same non-empty GUID on disk but sit at
// different paths. Tab-switching between them is a DIFFERENT project object
// (sameProjectObject == false). The old GUID-only classifier read this as a
// Save-As and clobbered a bank; with the pointer back it is a Load, so neither
// bank is ever relocated. This is the regression made provable outside the DAW.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidShared", "/proj2/p.rpp",
"guidShared", "/proj3/p.rpp")
== ProjectTransition::Load);
// Even the adversarial shape — recycled pointer AND B happens to sit at a
// path the old check would misread — resolves to Load purely on the GUID.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/a/other.rpp")
// Switching back the other way is likewise a different object -> Load.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidShared", "/proj3/p.rpp",
"guidShared", "/proj2/p.rpp")
== ProjectTransition::Load);
}
static void testTransitionGenuineSaveAsRelocates() {
// Same project (same non-empty GUID) saved to a new .rpp location -> the one
// case that legitimately relocates the bank.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
// The SAME project object (pointer unchanged) saved to a new .rpp location ->
// the one case that legitimately relocates the bank. Same GUID, new path.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate);
}
static void testTransitionTabSwitchLoads() {
// Switching to another open project (different GUID) -> Load, never relocate,
// regardless of whether the paths differ.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
static void testTransitionPointerReuseDifferentGuidLoads() {
// Pointer *reuse* for a genuinely different project: at THIS tick the object
// differs (sameProjectObject == false) and its GUID differs too -> Load. Never
// a relocate. This is the case the M4 GUID was originally added to handle;
// the pointer check subsumes it (different object) and the GUID corroborates.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
// Switching back also loads (its GUID differs from the one just seen).
CHECK(classifyProjectTransition("guidB", "/b/b.rpp", "guidA", "/a/a.rpp")
}
static void testTransitionTabSwitchDistinctProjectsLoads() {
// Ordinary tab-switch between two distinct (non-forked) saved projects:
// different object, different GUID -> Load, regardless of paths.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidB", "/b/b.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::Load);
}
static void testTransitionSaveInPlaceIsNoOp() {
// Same GUID, same path (idle tick, or a Save that did not move the .rpp) ->
// Same object, same path (idle tick, or a Save that did not move the .rpp) ->
// nothing to do.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
// Empty GUID (unsaved project sitting idle), same (empty) path -> NoOp too.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
== ProjectTransition::NoOp);
}
static void testTransitionEmptyGuidPathChangeLoadsNeverRelocates() {
// No GUID on either side (unsaved projects / first-save) means we CANNOT
// prove the two ticks saw the same project. A path change is then a Load
// (first save, or a switch between unsaved projects) — never a relocate,
// which is the safe direction (no destructive copy-over without proof).
CHECK(classifyProjectTransition("", "", "", "/a/a.rpp")
== ProjectTransition::Load);
CHECK(classifyProjectTransition("", "/tmp/untitled.rpp", "", "/a/a.rpp")
== ProjectTransition::Load);
// Both empty, same path -> idle unsaved project -> NoOp.
CHECK(classifyProjectTransition("", "", "", "")
== ProjectTransition::NoOp);
static void testTransitionFirstSaveSameObjectRelocatesButPlanNoOps() {
// Same object, empty GUID, path appears (first save of an untitled project).
// The classifier says SaveAsRelocate, but the empty-GUID safety is preserved
// at execution: the old project dir is empty, so deriveRelocationPlan makes
// `needed` false and NOTHING is physically relocated; poll() just mints a GUID.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"", "", "", "/a/a.rpp")
== ProjectTransition::SaveAsRelocate);
// Prove the safety end-to-end: the relocation plan for an empty old dir no-ops.
CHECK(!deriveRelocationPlan(/*oldProjectDir=*/"", "/a").needed);
}
static void testTransitionGuidAppearingLoads() {
// A project that had no stored GUID (last seen empty) now reports one (we just
// minted+wrote it, or an older project gained one). The GUID changed, so it
// classifies as Load — harmless: loadFromProject re-reads the same ext state.
CHECK(classifyProjectTransition("", "/a/a.rpp", "guidA", "/a/a.rpp")
static void testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers() {
// Divergence guard's precondition, from the pure side: even if a forked sibling
// still shares our GUID, as long as the OBJECT differs the verdict is Load
// (never Save-As). The actual re-GUID that makes the siblings diverge is a
// REAPER-facing action in poll() (SetProjExtState + MarkProjectDirty) and is
// covered by the DAW procedure; here we lock the pure verdict that gates it.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidShared", "/proj2/p.rpp",
"guidShared", "/proj3/p.rpp")
== ProjectTransition::Load);
}
@@ -255,12 +283,13 @@ int main() {
testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerLoadsNotRelocates();
testTransitionForkTabSwitchLoadsNeverRelocates();
testTransitionGenuineSaveAsRelocates();
testTransitionTabSwitchLoads();
testTransitionPointerReuseDifferentGuidLoads();
testTransitionTabSwitchDistinctProjectsLoads();
testTransitionSaveInPlaceIsNoOp();
testTransitionEmptyGuidPathChangeLoadsNeverRelocates();
testTransitionGuidAppearingLoads();
testTransitionFirstSaveSameObjectRelocatesButPlanNoOps();
testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);