fix: layer GUID-primary project identity so reopened/new projects reload the bank

classifyProjectTransition now checks the stored GUID first, then the pointer,
fixing the w10 regression where a recycled ReaProject* address stopped the bank
reloading. poll()'s fork re-GUID gate is bound to !sameProjectObject. Full
transition matrix pinned in tests.
This commit is contained in:
2026-07-23 04:46:36 -04:00
parent 39baf28c93
commit affde0ef53
5 changed files with 205 additions and 148 deletions
+80 -52
View File
@@ -178,20 +178,47 @@ static void testRelocationPlanEmptyInputsNoOp() {
CHECK(!deriveRelocationPlan("", "").needed);
}
// --- Project-identity transition (W10 forked-project fix) -------------------
// --- Project-identity transition (W12 combined identity 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_`.
// Identity is layered GUID-PRIMARY: the stored GUID (identity of record) leads;
// the pointer only disambiguates the same-GUID case. poll() computes
// sameProjectObject as `proj == lastProject_`. This matrix covers every branch —
// the classifier has regressed twice, so every case is pinned.
static void testTransitionRecycledPointerReopenDifferentProjectLoads() {
// THE W12 REGRESSION. REAPER recycled the previous project's ReaProject* address
// for a DIFFERENT reopened saved project, so sameProjectObject == true, but the
// reopened project carries its OWN (different, non-empty) stored GUID. The old
// pointer-primary classifier decided on path alone and returned NoOp (same path)
// or SaveAsRelocate (new path) — the bank never reloaded. GUID-first makes this
// a Load regardless of path.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
// Same recycled-address regression, but the reopened project happens to sit at
// the SAME path as the one we left (old code returned NoOp here). Still a Load.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidB", "/a/a.rpp")
== ProjectTransition::Load);
}
static void testTransitionOpenNewUnsavedFromSavedLoads() {
// Open a new/unsaved project from a saved one, recycled onto the same address
// (sameProjectObject == true): currentGuid empty, lastGuid non-empty -> the
// record identity differs -> Load (so the bank clears to the new empty project).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "", "")
== ProjectTransition::Load);
}
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.
// THE W10 CASE — must stay fixed. 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). Same GUID -> step 1 falls through; step 2
// (!sameProjectObject) -> Load, so neither bank is ever relocated.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidShared", "/proj2/p.rpp",
"guidShared", "/proj3/p.rpp")
@@ -204,26 +231,24 @@ static void testTransitionForkTabSwitchLoadsNeverRelocates() {
}
static void testTransitionGenuineSaveAsRelocates() {
// The SAME project object (pointer unchanged) saved to a new .rpp location ->
// the one case that legitimately relocates the bank. Same GUID, new path.
// The SAME project object (pointer unchanged) AND same GUID saved to a new .rpp
// location -> the one case that legitimately relocates the bank (step 3).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate);
}
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);
static void testTransitionReopenSameProjectRecycledSameAddrIsNoOp() {
// Reopen the SAME project, recycled onto the same address: same object, same
// (non-empty) GUID, same path -> nothing changed -> NoOp (step 4).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
}
static void testTransitionTabSwitchDistinctProjectsLoads() {
// Ordinary tab-switch between two distinct (non-forked) saved projects:
// different object, different GUID -> Load, regardless of paths.
static void testTransitionTwoDistinctSavedProjectsDistinctPointersLoad() {
// Ordinary tab-switch between two distinct (non-forked) saved projects: distinct
// pointers, different GUIDs -> step 1 (GUID differs) -> Load, regardless of paths.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
@@ -232,39 +257,40 @@ static void testTransitionTabSwitchDistinctProjectsLoads() {
== ProjectTransition::Load);
}
static void testTransitionSaveInPlaceIsNoOp() {
// Same object, same path (idle tick, or a Save that did not move the .rpp) ->
// nothing to do.
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 testTransitionTwoUnsavedProjectsSwitchLoads() {
// Switch between two unsaved projects: both GUIDs empty (step 1 falls through:
// equal), distinct objects -> step 2 (!sameProjectObject) -> Load. Installs the
// right in-memory (empty) state for whichever unsaved project is now active.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"", "", "", "")
== ProjectTransition::Load);
// Distinct unsaved objects may even report distinct (untitled) paths -> Load.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"", "/untitled1", "", "/untitled2")
== ProjectTransition::Load);
}
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.
static void testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps() {
// First save of an unsaved project: same object, both GUIDs empty (step 1 & 2
// fall through), path appears (step 3) -> SaveAsRelocate. The empty-GUID safety
// is preserved at execution: the old project dir is empty, so deriveRelocation-
// Plan makes `needed` false and NOTHING is physically relocated; poll() 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);
CHECK(deriveRelocationPlan(/*oldProjectDir=*/"", "/a").needed == false);
}
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);
static void testTransitionInPlaceSaveIsNoOp() {
// In-place save (or an idle tick): same object, same GUID, same path -> NoOp.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
// Idle unsaved project (same object, both empty GUID, same empty path) -> NoOp.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
== ProjectTransition::NoOp);
}
int main() {
@@ -283,13 +309,15 @@ int main() {
testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerReopenDifferentProjectLoads();
testTransitionOpenNewUnsavedFromSavedLoads();
testTransitionForkTabSwitchLoadsNeverRelocates();
testTransitionGenuineSaveAsRelocates();
testTransitionPointerReuseDifferentGuidLoads();
testTransitionTabSwitchDistinctProjectsLoads();
testTransitionSaveInPlaceIsNoOp();
testTransitionFirstSaveSameObjectRelocatesButPlanNoOps();
testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers();
testTransitionReopenSameProjectRecycledSameAddrIsNoOp();
testTransitionTwoDistinctSavedProjectsDistinctPointersLoad();
testTransitionTwoUnsavedProjectsSwitchLoads();
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
testTransitionInPlaceSaveIsNoOp();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);