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
+33 -16
View File
@@ -110,26 +110,43 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastPath, const std::string& lastPath,
const std::string& currentGuid, const std::string& currentGuid,
const std::string& currentPath) { const std::string& currentPath) {
// A DIFFERENT project object is a tab-switch / open / recycled pointer — load // 1. The GUID is the identity of record and is checked FIRST. A different
// ITS index; NEVER relocate a bank. This is the load-bearing safety fix: it // stored GUID means a genuinely different project is active — Load ITS index.
// holds even when currentGuid == lastGuid, which is exactly the forked-sibling // This catches the regression that pointer-primary classification missed:
// case (Save-As copied our GUID, so two distinct projects share it on disk). // REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
// The GUID is deliberately NOT consulted here — the object identity alone // new project can reuse the previous project's address (sameProjectObject ==
// decides, and it cannot be fooled by a copied GUID. // true) while carrying a different stored GUID. Deciding on the pointer alone
(void)lastGuid; // then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
(void)currentGuid; // 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) { if (!sameProjectObject) {
return ProjectTransition::Load; return ProjectTransition::Load;
} }
// Same object from here on: identity is PROVEN by the pointer. A path change is // 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
// a Save-As (or a first save, when the old path was empty); an unchanged path // identity is proven and the record identity is unchanged — only the .rpp
// is Save-in-place / idle. Note SaveAsRelocate is safe even for a first save: // moved). Also the first save of an unsaved project (both GUIDs empty, old
// the old project dir is empty, so deriveRelocationPlan makes `needed` false // path empty): SaveAsRelocate is safe there because deriveRelocationPlan
// and nothing is physically relocated (empty-GUID safety preserved), while // no-ops on the empty old dir (empty-GUID safety preserved) while poll()
// poll() still mints a GUID on that branch. // mints a GUID.
return (currentPath == lastPath) ? ProjectTransition::NoOp if (currentPath != lastPath) {
: ProjectTransition::SaveAsRelocate; return ProjectTransition::SaveAsRelocate;
}
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp;
} }
} // namespace reasampler } // namespace reasampler
+40 -37
View File
@@ -89,30 +89,34 @@ struct BankRelocation {
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir); 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, // What the persist timer must do on each tick. Identity rests on TWO facts,
// not the GUID alone: // layered GUID-PRIMARY:
// 1. sameProjectObject — did the same live ReaProject* stay active across the // 1. the minted GUID — content-based identity of record, stored in ext state.
// two ticks (computed in poll() as `proj == lastProject_`)? This is what a // It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
// genuine Save-As looks like: ONE project object saved to a new path. A // so it is checked FIRST.
// tab-switch or open is a DIFFERENT object. // 2. sameProjectObject — did the same live ReaProject* stay active across the
// 2. the minted GUID — content-based identity stored in ext state, kept to // two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
// survive pointer *reuse* (REAPER recycles a closed project's address). // 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 // This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As
// copies the whole .rpp incl. our stored GUID, so a fork and its parent share a // forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its
// GUID on disk. Tab-switching between two forked siblings (same GUID, different // parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer
// paths) then read as a Save-As and clobbered one bank with the other's — the // RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
// data-integrity defect this fix closes. The pointer is the ONLY signal that // can present the previous project's pointer with a different stored GUID —
// separates "same object saved elsewhere" (Save-As) from "different object that // pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded.
// happens to share a forked GUID" (a switch). // 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 { 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 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. // Classifies what a poll tick observed.
@@ -126,24 +130,23 @@ enum class ProjectTransition {
// unsaved or never written) // unsaved or never written)
// currentPath : the now-active project's .rpp path ("" if unsaved) // currentPath : the now-active project's .rpp path ("" if unsaved)
// //
// Rules: // Rules (evaluated in EXACTLY this order):
// * sameProjectObject == false -> Load (a different project // 1. currentGuid != lastGuid -> Load (different record identity:
// object — tab-switch / open / // recycled pointer w/ different GUID,
// recycled pointer; NEVER a // new/unsaved<->saved, or two distinct
// relocate, even if the GUID // saved projects)
// matches a forked sibling) // 2. !sameProjectObject -> Load (same GUID, different object:
// * same object, non-empty GUID, path unchanged -> NoOp (Save in place / idle) // forked sibling, or two unsaved projects)
// * same object, non-empty GUID, path changed -> SaveAsRelocate // 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
// * same object, empty GUID, path unchanged -> NoOp (idle unsaved project) // new path: genuine Save-As, or first save
// * same object, empty GUID, path changed -> SaveAsRelocate (first save; // of an unsaved project — relocate no-ops
// the relocation plan no-ops on // on the empty old dir, poll() mints a GUID)
// the empty old dir, so nothing // 4. otherwise -> NoOp (same object, same GUID, same path)
// is physically relocated — //
// the empty-GUID safety holds — // The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
// and poll() mints a GUID) // case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
// A different object never yields SaveAsRelocate: that is the whole fix. The empty- // projects never physically relocate) is preserved because an empty old project dir
// GUID safety (unsaved projects never physically relocate) is preserved because an // makes deriveRelocationPlan's `needed` false.
// empty old project dir makes deriveRelocationPlan's `needed` false.
ProjectTransition classifyProjectTransition(bool sameProjectObject, ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid, const std::string& lastGuid,
const std::string& lastPath, const std::string& lastPath,
+39 -32
View File
@@ -14,26 +14,30 @@
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): // PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active // 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 // 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 // state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the
// a secondary signal: // secondary disambiguator (classifyProjectTransition owns the exact order):
// * different project object (proj != lastProject_) -> a switch/open/new // * different stored GUID -> a different project of record -> LOAD its index;
// project -> LOAD its index; NEVER relocate. If its stored GUID still equals // NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's
// the one we just left (a forked sibling that copied our GUID via Save-As), // address, so a reopened/new project can present the previous pointer with a
// re-GUID it so the siblings diverge going forward. // different GUID), new/unsaved<->saved, and switching between distinct saved
// * SAME object, .rpp path changed -> genuine Save-As to a new location -> // projects.
// relocate the bank folder from the old dir to the new one, then re-GUID. // * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via
// Why the pointer is back (W10 fix): the M4 GUID-only scheme could not tell a // Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings
// Save-As from a tab-switch between two FORKED projects. Save-As copies the whole // diverge going forward.
// .rpp incl. our stored GUID, so a fork and its parent share a GUID on disk; // * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new
// switching between them (same GUID, different paths) read as a Save-As and // location -> relocate the bank folder from the old dir to the new one, then
// clobbered a bank. The pointer is the only signal that separates "same object // re-GUID.
// saved elsewhere" (Save-As) from "different object sharing a copied GUID" // Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only)
// (a switch). classifyProjectTransition (pure, capture_paths) takes a // broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a
// `sameProjectObject` bool (poll() computes `proj == lastProject_`) so the // fork and its parent share a GUID on disk; switching between them read as a
// decision stays REAPER-free and testable; poll() executes the verdict. // Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer
// Pointer *reuse* (a recycled address for a genuinely different project) stays // RECYCLING — a reopened/new project reusing the previous project's address read
// correct: it is a different object at that moment, so it Loads, and its GUID // as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first
// differs or gets re-diverged. // 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 // REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no
// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not // PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not
@@ -309,19 +313,22 @@ void ReaSamplerSession::poll() {
return; return;
case ProjectTransition::Load: { case ProjectTransition::Load: {
// A DIFFERENT project object is active (open / tab switch / new project // A different project of record is active (open / tab switch / new /
// / recycled pointer). Load ITS index; never relocate. // reopened / recycled pointer / forked sibling). Load ITS index; never
// relocate.
// //
// Forked-sibling divergence: if the now-active project's stored GUID // Forked-sibling divergence: gate on `!sameProjectObject` so this fires
// still equals the one we just left, it is a Save-As fork that copied // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork
// our GUID and never re-saved (its fresh GUID was runtime-only on the // that copied our GUID and never re-saved (its fresh GUID was runtime-
// sibling we came from). Left alone, the two keep colliding on identity. // only on the sibling we came from). A recycled-pointer Load (step 1:
// Mint a fresh GUID for the now-active project so the siblings diverge // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct
// going forward. Do this BEFORE loadFromProject reads the index (order // identity. currentGuid == lastGuid_ can only hold here when step 1 did
// is irrelevant to the index — GUID and bank_index are distinct keys — // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject
// but keeping the write self-contained is clearest). // makes that intent load-bearing rather than incidental. Do this BEFORE
if (proj && !currentGuid.empty() && currentGuid == lastGuid_ && // loadFromProject reads the index (order is irrelevant — GUID and
!rppPath.empty()) { // bank_index are distinct keys — but self-contained is clearest).
if (proj && !sameProjectObject && !currentGuid.empty() &&
currentGuid == lastGuid_ && !rppPath.empty()) {
const std::string fresh = genProjectGuidString(); const std::string fresh = genProjectGuidString();
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace, SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
kProjExtGuidKey, fresh.c_str()); kProjExtGuidKey, fresh.c_str());
+13 -11
View File
@@ -53,13 +53,14 @@ inline constexpr const char* kProjExtGuidKey = "project_guid";
// * project load -> load the index from ext state, resolve bank paths // * project load -> load the index from ext state, resolve bank paths
// * Save-As (new dir) -> relocate the bank folder under the new .rpp // * 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 // Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
// object saved to a new path — same pointer; a tab-switch/open is a different // record, immune to REAPER recycling a closed project's ReaProject* address) is
// object), with a minted GUID as a SECONDARY signal to survive pointer *reuse* // checked FIRST, and the live pointer disambiguates only the same-GUID case — a
// (REAPER recycles a closed project's address). The pointer is what distinguishes // forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same
// a Save-As from a switch between two FORKED siblings that share a copied GUID on // GUID, same object, new path -> relocate). GUID-first catches pointer recycling
// disk (the W10 data-integrity defect: dropping the pointer let a fork tab-switch // (a reopened/new project reusing the previous address with a different GUID — the
// masquerade as a Save-As and clobber a bank). // 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 // The bank itself is exposed for the capture/action layer to mutate; persist
// only reads it on save and replaces it on load. // only reads it on save and replaces it on load.
@@ -110,10 +111,11 @@ private:
ViewModeModel view_; ViewModeModel view_;
// The project identity last observed by poll(), used to detect load/Save-As. // 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 // The GUID is the PRIMARY signal (a different stored GUID = a different project
// Save-As; different object = a switch/open, never a relocate). The GUID and // of record = Load, immune to pointer recycling). The pointer disambiguates the
// path travel alongside: the GUID distinguishes pointer *reuse* and drives // same-GUID case (different object = forked sibling -> Load; same object + new
// forked-sibling re-divergence; the path tells a Save-As from an idle tick. // 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 // 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. // handle (never dereferenced), so a stale/recycled address is harmless.
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
+80 -52
View File
@@ -178,20 +178,47 @@ static void testRelocationPlanEmptyInputsNoOp() {
CHECK(!deriveRelocationPlan("", "").needed); CHECK(!deriveRelocationPlan("", "").needed);
} }
// --- Project-identity transition (W10 forked-project fix) ------------------- // --- Project-identity transition (W12 combined identity fix) ----------------
// //
// Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath, // Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath,
// currentGuid, currentPath). // currentGuid, currentPath).
// The first arg — did the SAME ReaProject* stay active across the two ticks — // Identity is layered GUID-PRIMARY: the stored GUID (identity of record) leads;
// is the primary signal; poll() computes it as `proj == lastProject_`. // 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() { static void testTransitionForkTabSwitchLoadsNeverRelocates() {
// THE W10 BUG. proj2 and proj3 are forked siblings (Save-As copied the .rpp // THE W10 CASE — must stay fixed. proj2 and proj3 are forked siblings (Save-As
// incl. our GUID), so BOTH carry the same non-empty GUID on disk but sit at // copied the .rpp incl. our GUID), so BOTH carry the same non-empty GUID on disk
// different paths. Tab-switching between them is a DIFFERENT project object // but sit at different paths. Tab-switching between them is a DIFFERENT project
// (sameProjectObject == false). The old GUID-only classifier read this as a // object (sameProjectObject == false). Same GUID -> step 1 falls through; step 2
// Save-As and clobbered a bank; with the pointer back it is a Load, so neither // (!sameProjectObject) -> Load, so neither bank is ever relocated.
// bank is ever relocated. This is the regression made provable outside the DAW.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false, CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidShared", "/proj2/p.rpp", "guidShared", "/proj2/p.rpp",
"guidShared", "/proj3/p.rpp") "guidShared", "/proj3/p.rpp")
@@ -204,26 +231,24 @@ static void testTransitionForkTabSwitchLoadsNeverRelocates() {
} }
static void testTransitionGenuineSaveAsRelocates() { static void testTransitionGenuineSaveAsRelocates() {
// The SAME project object (pointer unchanged) saved to a new .rpp location -> // The SAME project object (pointer unchanged) AND same GUID saved to a new .rpp
// the one case that legitimately relocates the bank. Same GUID, new path. // location -> the one case that legitimately relocates the bank (step 3).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp") "guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate); == ProjectTransition::SaveAsRelocate);
} }
static void testTransitionPointerReuseDifferentGuidLoads() { static void testTransitionReopenSameProjectRecycledSameAddrIsNoOp() {
// Pointer *reuse* for a genuinely different project: at THIS tick the object // Reopen the SAME project, recycled onto the same address: same object, same
// differs (sameProjectObject == false) and its GUID differs too -> Load. Never // (non-empty) GUID, same path -> nothing changed -> NoOp (step 4).
// a relocate. This is the case the M4 GUID was originally added to handle; CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
// the pointer check subsumes it (different object) and the GUID corroborates. "guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
CHECK(classifyProjectTransition(/*sameProjectObject=*/false, == ProjectTransition::NoOp);
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
} }
static void testTransitionTabSwitchDistinctProjectsLoads() { static void testTransitionTwoDistinctSavedProjectsDistinctPointersLoad() {
// Ordinary tab-switch between two distinct (non-forked) saved projects: // Ordinary tab-switch between two distinct (non-forked) saved projects: distinct
// different object, different GUID -> Load, regardless of paths. // pointers, different GUIDs -> step 1 (GUID differs) -> Load, regardless of paths.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false, CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp") "guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load); == ProjectTransition::Load);
@@ -232,39 +257,40 @@ static void testTransitionTabSwitchDistinctProjectsLoads() {
== ProjectTransition::Load); == ProjectTransition::Load);
} }
static void testTransitionSaveInPlaceIsNoOp() { static void testTransitionTwoUnsavedProjectsSwitchLoads() {
// Same object, same path (idle tick, or a Save that did not move the .rpp) -> // Switch between two unsaved projects: both GUIDs empty (step 1 falls through:
// nothing to do. // equal), distinct objects -> step 2 (!sameProjectObject) -> Load. Installs the
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, // right in-memory (empty) state for whichever unsaved project is now active.
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp") CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
== ProjectTransition::NoOp); "", "", "", "")
// Empty GUID (unsaved project sitting idle), same (empty) path -> NoOp too. == ProjectTransition::Load);
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "") // Distinct unsaved objects may even report distinct (untitled) paths -> Load.
== ProjectTransition::NoOp); CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"", "/untitled1", "", "/untitled2")
== ProjectTransition::Load);
} }
static void testTransitionFirstSaveSameObjectRelocatesButPlanNoOps() { static void testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps() {
// Same object, empty GUID, path appears (first save of an untitled project). // First save of an unsaved project: same object, both GUIDs empty (step 1 & 2
// The classifier says SaveAsRelocate, but the empty-GUID safety is preserved // fall through), path appears (step 3) -> SaveAsRelocate. The empty-GUID safety
// at execution: the old project dir is empty, so deriveRelocationPlan makes // is preserved at execution: the old project dir is empty, so deriveRelocation-
// `needed` false and NOTHING is physically relocated; poll() just mints a GUID. // Plan makes `needed` false and NOTHING is physically relocated; poll() mints a
// GUID.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"", "", "", "/a/a.rpp") "", "", "", "/a/a.rpp")
== ProjectTransition::SaveAsRelocate); == ProjectTransition::SaveAsRelocate);
// Prove the safety end-to-end: the relocation plan for an empty old dir no-ops. // 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() { static void testTransitionInPlaceSaveIsNoOp() {
// Divergence guard's precondition, from the pure side: even if a forked sibling // In-place save (or an idle tick): same object, same GUID, same path -> NoOp.
// still shares our GUID, as long as the OBJECT differs the verdict is Load CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
// (never Save-As). The actual re-GUID that makes the siblings diverge is a "guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
// REAPER-facing action in poll() (SetProjExtState + MarkProjectDirty) and is == ProjectTransition::NoOp);
// covered by the DAW procedure; here we lock the pure verdict that gates it. // Idle unsaved project (same object, both empty GUID, same empty path) -> NoOp.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false, CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
"guidShared", "/proj2/p.rpp", == ProjectTransition::NoOp);
"guidShared", "/proj3/p.rpp")
== ProjectTransition::Load);
} }
int main() { int main() {
@@ -283,13 +309,15 @@ int main() {
testRelocationPlanForSaveAs(); testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace(); testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp(); testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerReopenDifferentProjectLoads();
testTransitionOpenNewUnsavedFromSavedLoads();
testTransitionForkTabSwitchLoadsNeverRelocates(); testTransitionForkTabSwitchLoadsNeverRelocates();
testTransitionGenuineSaveAsRelocates(); testTransitionGenuineSaveAsRelocates();
testTransitionPointerReuseDifferentGuidLoads(); testTransitionReopenSameProjectRecycledSameAddrIsNoOp();
testTransitionTabSwitchDistinctProjectsLoads(); testTransitionTwoDistinctSavedProjectsDistinctPointersLoad();
testTransitionSaveInPlaceIsNoOp(); testTransitionTwoUnsavedProjectsSwitchLoads();
testTransitionFirstSaveSameObjectRelocatesButPlanNoOps(); testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers(); testTransitionInPlaceSaveIsNoOp();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail); else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);