From 36270e2064d034ae12e669f463b23917dc21116f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 21:28:54 -0400 Subject: [PATCH] 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. --- src/capture_paths.cpp | 35 ++++++----- src/capture_paths.h | 69 +++++++++++++-------- src/persist.cpp | 85 +++++++++++++++++++------- src/persist.h | 26 ++++---- tests/test_capture_paths.cpp | 113 ++++++++++++++++++++++------------- 5 files changed, 212 insertions(+), 116 deletions(-) diff --git a/src/capture_paths.cpp b/src/capture_paths.cpp index 9e63e3d..544f636 100644 --- a/src/capture_paths.cpp +++ b/src/capture_paths.cpp @@ -105,30 +105,29 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, return r; } -ProjectTransition classifyProjectTransition(const std::string& lastGuid, +ProjectTransition classifyProjectTransition(bool sameProjectObject, + const std::string& lastGuid, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath) { - // A changed GUID is the unambiguous signal of a different project — load it. - // This is the whole point of the content-based identity: it survives pointer - // recycling that the old pointer-equality check could not distinguish from a - // Save-As. - if (currentGuid != lastGuid) { + // 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; + if (!sameProjectObject) { return ProjectTransition::Load; } - // Same GUID from here on. If it is empty, we have NO proof the two ticks saw - // the same project (an unsaved project can't carry a stored GUID). A path - // change under an empty GUID is therefore a first-save or a switch between - // unsaved projects — load, never relocate (relocating would copy the wrong - // bank over a real one, the defect being fixed). - if (currentGuid.empty()) { - return (currentPath == lastPath) ? ProjectTransition::NoOp - : ProjectTransition::Load; - } - - // Same NON-EMPTY GUID: proven same project. A path change is a genuine - // Save-As to a new location; an unchanged path is Save-in-place / idle. + // 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; } diff --git a/src/capture_paths.h b/src/capture_paths.h index fc6e4e3..6e77a6b 100644 --- a/src/capture_paths.h +++ b/src/capture_paths.h @@ -89,42 +89,63 @@ struct BankRelocation { BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir); -// --- Project-identity transition (M4 defect fix) ---------------------------- +// --- Project-identity transition (W10 forked-project fix) -------------------- // -// What the persist timer must do on each tick, decided purely from the LAST -// observed identity and the CURRENT one. Identity is CONTENT-BASED: a project -// GUID we mint and store in our ext state (REAPER exposes no stable per-project -// GUID). The raw ReaProject* is deliberately NOT part of this decision — REAPER -// recycles pointer addresses across project close/open, and keying Save-As off -// the pointer let a project switch masquerade as a Save-As and clobber a bank. +// 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). +// +// 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). +// +// The load-bearing rule: a DIFFERENT project object NEVER relocates a bank. enum class ProjectTransition { - NoOp, // same project, same location — nothing to do + NoOp, // same object, same location — nothing to do Load, // a different project is active — load ITS index from ext state - SaveAsRelocate, // same project, new .rpp location — relocate the bank folder + SaveAsRelocate, // SAME object, new .rpp location — relocate the bank folder }; // Classifies what a poll tick observed. +// sameProjectObject : true iff the SAME ReaProject* stayed active across the two +// ticks (poll() computes `proj == lastProject_`). The pure +// classifier takes the bool, not the raw pointer, to stay +// REAPER-free and testable. // lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) // lastPath : that project's .rpp path when last seen ("" if unsaved) // currentGuid : the GUID stored in the now-active project's ext state ("" if // unsaved or never written) // currentPath : the now-active project's .rpp path ("" if unsaved) // -// Rules (GUID is the identity; path only distinguishes Save vs Save-As within -// the SAME identity): -// * currentGuid != lastGuid -> Load (a different project) -// * same non-empty GUID, currentPath == lastPath -> NoOp (Save in place / idle) -// * same non-empty GUID, currentPath != lastPath -> SaveAsRelocate -// * both GUIDs empty, same path -> NoOp (idle unsaved project) -// * both GUIDs empty, different path -> Load (can't PROVE same -// project without a GUID — a -// first-save or a switch -// between unsaved projects; -// never a relocate) -// The both-empty/different-path -> Load rule is what makes the recycled-pointer -// bug impossible: absent GUID corroboration, a path change is treated as a new -// project (safe: load), never a relocate (destructive: copy-over). -ProjectTransition classifyProjectTransition(const std::string& lastGuid, +// 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. +ProjectTransition classifyProjectTransition(bool sameProjectObject, + const std::string& lastGuid, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath); diff --git a/src/persist.cpp b/src/persist.cpp index 924b653..aef6d27 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -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(proj), kProjExtNamespace, + kProjExtGuidKey, fresh.c_str()); + MarkProjectDirty(static_cast(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(proj)); } + lastProject_ = proj; // unchanged (same object) — set for symmetry lastGuid_ = fresh; lastRppPath_ = rppPath; return; diff --git a/src/persist.h b/src/persist.h index bab2a8a..86a273c 100644 --- a/src/persist.h +++ b/src/persist.h @@ -48,15 +48,18 @@ inline constexpr const char* kProjExtGuidKey = "project_guid"; // Owns the session's BankIndex and drives persistence against the active REAPER // project. One instance lives for the extension's lifetime (main.cpp). It tracks // the project identity it last saw so the timer tick can detect a project load -// (identity changed) and a Save-As (same project, path changed) and react: +// (a different project became active) and a Save-As (SAME project, path changed): // // * project load -> load the index from ext state, resolve bank paths // * Save-As (new dir) -> relocate the bank folder under the new .rpp // -// Identity is CONTENT-BASED, not pointer-based: persist keys Load/Save-As off a -// GUID it mints and stores in each project's ext state, not the ReaProject* -// pointer (REAPER recycles pointer addresses across close/open, which let a -// project switch masquerade as a Save-As and clobber a bank — the M4 defect). +// 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). // // The bank itself is exposed for the capture/action layer to mutate; persist // only reads it on save and replaces it on load. @@ -95,12 +98,15 @@ private: ViewModeModel view_; // The project identity last observed by poll(), used to detect load/Save-As. - // Identity is the GUID we mint per project (kProjExtGuidKey), NOT the raw - // ReaProject* pointer — see the class comment for why. The .rpp path is - // tracked alongside so a same-GUID path change (Save-As) is distinguishable - // from a same-GUID same-path idle tick (Save in place / no change). + // 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. + // 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) std::string lastGuid_; // "" until the first saved project is seen - std::string lastRppPath_; // .rpp path last seen for lastGuid_ + std::string lastRppPath_; // .rpp path last seen for lastProject_ bool primed_ = false; // false until the first poll() observes state // Load the index from the given project's ext state and resolve bank paths diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 6cbbce8..b9cd07c 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -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);