From affde0ef53cd79ce6669d46bcece36153bedc993 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 04:46:36 -0400 Subject: [PATCH] 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. --- src/capture_paths.cpp | 49 ++++++++----- src/capture_paths.h | 77 ++++++++++---------- src/persist.cpp | 71 ++++++++++--------- src/persist.h | 24 ++++--- tests/test_capture_paths.cpp | 132 +++++++++++++++++++++-------------- 5 files changed, 205 insertions(+), 148 deletions(-) diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 544f636..8e319c6 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -110,26 +110,43 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath) { - // A DIFFERENT project object is a tab-switch / open / recycled pointer — load - // ITS index; NEVER relocate a bank. This is the load-bearing safety fix: it - // holds even when currentGuid == lastGuid, which is exactly the forked-sibling - // case (Save-As copied our GUID, so two distinct projects share it on disk). - // The GUID is deliberately NOT consulted here — the object identity alone - // decides, and it cannot be fooled by a copied GUID. - (void)lastGuid; - (void)currentGuid; + // 1. The GUID is the identity of record and is checked FIRST. A different + // stored GUID means a genuinely different project is active — Load ITS index. + // This catches the regression that pointer-primary classification missed: + // REAPER RECYCLES ReaProject* addresses across close/open, so a reopened / + // new project can reuse the previous project's address (sameProjectObject == + // true) while carrying a different stored GUID. Deciding on the pointer alone + // then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is + // immune to address recycling, so it leads. Also covers new/unsaved<->saved + // transitions (one GUID empty, the other not) and switching between two + // distinct saved projects. + if (currentGuid != lastGuid) { + return ProjectTransition::Load; + } + + // From here currentGuid == lastGuid (they are equal; both may be empty for + // unsaved projects). The pointer now disambiguates the same-GUID case. + + // 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our + // GUID onto a distinct project object. Load its (own) index; never relocate. + // Two unsaved projects (both GUIDs empty, distinct objects) also land here — + // Load, so switching between them installs the right in-memory state. if (!sameProjectObject) { return ProjectTransition::Load; } - // Same object from here on: identity is PROVEN by the pointer. A path change is - // a Save-As (or a first save, when the old path was empty); an unchanged path - // is Save-in-place / idle. Note SaveAsRelocate is safe even for a first save: - // the old project dir is empty, so deriveRelocationPlan makes `needed` false - // and nothing is physically relocated (empty-GUID safety preserved), while - // poll() still mints a GUID on that branch. - return (currentPath == lastPath) ? ProjectTransition::NoOp - : ProjectTransition::SaveAsRelocate; + // 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object + // identity is proven and the record identity is unchanged — only the .rpp + // moved). Also the first save of an unsaved project (both GUIDs empty, old + // path empty): SaveAsRelocate is safe there because deriveRelocationPlan + // no-ops on the empty old dir (empty-GUID safety preserved) while poll() + // mints a GUID. + if (currentPath != lastPath) { + return ProjectTransition::SaveAsRelocate; + } + + // 4. Same object, same GUID, same path — Save in place / idle tick. + return ProjectTransition::NoOp; } } // namespace reasampler diff --git a/src/capture_paths.h b/src/capture_paths.h index 6e77a6b..fcefcbc 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -89,30 +89,34 @@ struct BankRelocation { BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir); -// --- Project-identity transition (W10 forked-project fix) -------------------- +// --- Project-identity transition (W12 combined identity fix) ----------------- // -// What the persist timer must do on each tick. Identity now rests on TWO facts, -// not the GUID alone: -// 1. sameProjectObject — did the same live ReaProject* stay active across the -// two ticks (computed in poll() as `proj == lastProject_`)? This is what a -// genuine Save-As looks like: ONE project object saved to a new path. A -// tab-switch or open is a DIFFERENT object. -// 2. the minted GUID — content-based identity stored in ext state, kept to -// survive pointer *reuse* (REAPER recycles a closed project's address). +// What the persist timer must do on each tick. Identity rests on TWO facts, +// layered GUID-PRIMARY: +// 1. the minted GUID — content-based identity of record, stored in ext state. +// It is IMMUNE to REAPER recycling a closed project's ReaProject* address, +// so it is checked FIRST. +// 2. sameProjectObject — did the same live ReaProject* stay active across the +// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to +// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID +// onto a distinct object) vs a genuine Save-As (one object, new path). // -// The pointer was dropped in M4 (GUID-only), which broke FORKED projects: Save-As -// copies the whole .rpp incl. our stored GUID, so a fork and its parent share a -// GUID on disk. Tab-switching between two forked siblings (same GUID, different -// paths) then read as a Save-As and clobbered one bank with the other's — the -// data-integrity defect this fix closes. The pointer is the ONLY signal that -// separates "same object saved elsewhere" (Save-As) from "different object that -// happens to share a forked GUID" (a switch). +// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As +// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its +// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer +// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project +// can present the previous project's pointer with a different stored GUID — +// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded. +// Checking the GUID first catches recycling; the pointer then separates a fork +// (same GUID, different object -> Load) from a Save-As (same GUID, same object, +// new path -> relocate). // -// The load-bearing rule: a DIFFERENT project object NEVER relocates a bank. +// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a +// DIFFERENT project object with the same GUID is a fork Load, never a relocate. enum class ProjectTransition { - NoOp, // same object, same location — nothing to do + NoOp, // same object, same GUID, same location — nothing to do Load, // a different project is active — load ITS index from ext state - SaveAsRelocate, // SAME object, new .rpp location — relocate the bank folder + SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank }; // Classifies what a poll tick observed. @@ -126,24 +130,23 @@ enum class ProjectTransition { // unsaved or never written) // currentPath : the now-active project's .rpp path ("" if unsaved) // -// Rules: -// * sameProjectObject == false -> Load (a different project -// object — tab-switch / open / -// recycled pointer; NEVER a -// relocate, even if the GUID -// matches a forked sibling) -// * same object, non-empty GUID, path unchanged -> NoOp (Save in place / idle) -// * same object, non-empty GUID, path changed -> SaveAsRelocate -// * same object, empty GUID, path unchanged -> NoOp (idle unsaved project) -// * same object, empty GUID, path changed -> SaveAsRelocate (first save; -// the relocation plan no-ops on -// the empty old dir, so nothing -// is physically relocated — -// the empty-GUID safety holds — -// and poll() mints a GUID) -// A different object never yields SaveAsRelocate: that is the whole fix. The empty- -// GUID safety (unsaved projects never physically relocate) is preserved because an -// empty old project dir makes deriveRelocationPlan's `needed` false. +// Rules (evaluated in EXACTLY this order): +// 1. currentGuid != lastGuid -> Load (different record identity: +// recycled pointer w/ different GUID, +// new/unsaved<->saved, or two distinct +// saved projects) +// 2. !sameProjectObject -> Load (same GUID, different object: +// forked sibling, or two unsaved projects) +// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID, +// new path: genuine Save-As, or first save +// of an unsaved project — relocate no-ops +// on the empty old dir, poll() mints a GUID) +// 4. otherwise -> NoOp (same object, same GUID, same path) +// +// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID +// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved +// projects never physically relocate) is preserved because an empty old project dir +// makes deriveRelocationPlan's `needed` false. ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastGuid, const std::string& lastPath, diff --git a/src/persist.cpp b/src/persist.cpp index 091c50f..e4c316e 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -14,26 +14,30 @@ // 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 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. +// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the +// secondary disambiguator (classifyProjectTransition owns the exact order): +// * different stored GUID -> a different project of record -> LOAD its index; +// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's +// address, so a reopened/new project can present the previous pointer with a +// different GUID), new/unsaved<->saved, and switching between distinct saved +// projects. +// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via +// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings +// diverge going forward. +// * SAME GUID, 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 GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) +// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a +// fork and its parent share a GUID on disk; switching between them read as a +// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer +// RECYCLING — a reopened/new project reusing the previous project's address read +// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first +// catches recycling; the pointer then separates a fork (same GUID, different +// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). +// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` +// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free +// and testable; poll() executes the verdict. // // REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no // PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not @@ -309,19 +313,22 @@ void ReaSamplerSession::poll() { return; case ProjectTransition::Load: { - // A DIFFERENT project object is active (open / tab switch / new project - // / recycled pointer). Load ITS index; never relocate. + // A different project of record is active (open / tab switch / new / + // reopened / recycled pointer / forked sibling). 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()) { + // Forked-sibling divergence: gate on `!sameProjectObject` so this fires + // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork + // that copied our GUID and never re-saved (its fresh GUID was runtime- + // only on the sibling we came from). A recycled-pointer Load (step 1: + // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct + // identity. currentGuid == lastGuid_ can only hold here when step 1 did + // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject + // makes that intent load-bearing rather than incidental. Do this BEFORE + // loadFromProject reads the index (order is irrelevant — GUID and + // bank_index are distinct keys — but self-contained is clearest). + if (proj && !sameProjectObject && !currentGuid.empty() && + currentGuid == lastGuid_ && !rppPath.empty()) { const std::string fresh = genProjectGuidString(); SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtGuidKey, fresh.c_str()); diff --git a/src/persist.h b/src/persist.h index b27a0ec..f00b271 100644 --- a/src/persist.h +++ b/src/persist.h @@ -53,13 +53,14 @@ inline constexpr const char* kProjExtGuidKey = "project_guid"; // * project load -> load the index from ext state, resolve bank paths // * Save-As (new dir) -> relocate the bank folder under the new .rpp // -// Identity rests on the live ReaProject* pointer FIRST (a genuine Save-As is one -// object saved to a new path — same pointer; a tab-switch/open is a different -// object), with a minted GUID as a SECONDARY signal to survive pointer *reuse* -// (REAPER recycles a closed project's address). The pointer is what distinguishes -// a Save-As from a switch between two FORKED siblings that share a copied GUID on -// disk (the W10 data-integrity defect: dropping the pointer let a fork tab-switch -// masquerade as a Save-As and clobber a bank). +// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of +// record, immune to REAPER recycling a closed project's ReaProject* address) is +// checked FIRST, and the live pointer disambiguates only the same-GUID case — a +// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same +// GUID, same object, new path -> relocate). GUID-first catches pointer recycling +// (a reopened/new project reusing the previous address with a different GUID — the +// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As +// copies our GUID onto a distinct object — the W10 defect that clobbered a bank). // // The bank itself is exposed for the capture/action layer to mutate; persist // only reads it on save and replaces it on load. @@ -110,10 +111,11 @@ private: ViewModeModel view_; // The project identity last observed by poll(), used to detect load/Save-As. - // The pointer is the PRIMARY signal (same object across ticks = a candidate - // Save-As; different object = a switch/open, never a relocate). The GUID and - // path travel alongside: the GUID distinguishes pointer *reuse* and drives - // forked-sibling re-divergence; the path tells a Save-As from an idle tick. + // The GUID is the PRIMARY signal (a different stored GUID = a different project + // of record = Load, immune to pointer recycling). The pointer disambiguates the + // same-GUID case (different object = forked sibling -> Load; same object + new + // path -> Save-As) and drives forked-sibling re-divergence; the path tells a + // Save-As from an idle tick. // Held as void* so the header stays REAPER-free; it is a compared-only opaque // handle (never dereferenced), so a stale/recycled address is harmless. void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index b9cd07c..426a0cc 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -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);