Merge fix: project-object identity stops forked-bank cross-contamination

This commit is contained in:
2026-07-22 21:29:50 -04:00
5 changed files with 212 additions and 116 deletions
+17 -18
View File
@@ -105,30 +105,29 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
return r; return r;
} }
ProjectTransition classifyProjectTransition(const std::string& lastGuid, ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
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 changed GUID is the unambiguous signal of a different project — load it. // A DIFFERENT project object is a tab-switch / open / recycled pointer — load
// This is the whole point of the content-based identity: it survives pointer // ITS index; NEVER relocate a bank. This is the load-bearing safety fix: it
// recycling that the old pointer-equality check could not distinguish from a // holds even when currentGuid == lastGuid, which is exactly the forked-sibling
// Save-As. // case (Save-As copied our GUID, so two distinct projects share it on disk).
if (currentGuid != lastGuid) { // 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; return ProjectTransition::Load;
} }
// Same GUID from here on. If it is empty, we have NO proof the two ticks saw // Same object from here on: identity is PROVEN by the pointer. A path change is
// the same project (an unsaved project can't carry a stored GUID). A path // a Save-As (or a first save, when the old path was empty); an unchanged path
// change under an empty GUID is therefore a first-save or a switch between // is Save-in-place / idle. Note SaveAsRelocate is safe even for a first save:
// unsaved projects — load, never relocate (relocating would copy the wrong // the old project dir is empty, so deriveRelocationPlan makes `needed` false
// bank over a real one, the defect being fixed). // and nothing is physically relocated (empty-GUID safety preserved), while
if (currentGuid.empty()) { // poll() still mints a GUID on that branch.
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.
return (currentPath == lastPath) ? ProjectTransition::NoOp return (currentPath == lastPath) ? ProjectTransition::NoOp
: ProjectTransition::SaveAsRelocate; : ProjectTransition::SaveAsRelocate;
} }
+45 -24
View File
@@ -89,42 +89,63 @@ 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 (M4 defect fix) ---------------------------- // --- Project-identity transition (W10 forked-project fix) --------------------
// //
// What the persist timer must do on each tick, decided purely from the LAST // What the persist timer must do on each tick. Identity now rests on TWO facts,
// observed identity and the CURRENT one. Identity is CONTENT-BASED: a project // not the GUID alone:
// GUID we mint and store in our ext state (REAPER exposes no stable per-project // 1. sameProjectObject — did the same live ReaProject* stay active across the
// GUID). The raw ReaProject* is deliberately NOT part of this decision — REAPER // two ticks (computed in poll() as `proj == lastProject_`)? This is what a
// recycles pointer addresses across project close/open, and keying Save-As off // genuine Save-As looks like: ONE project object saved to a new path. A
// the pointer let a project switch masquerade as a Save-As and clobber a bank. // 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 { 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 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. // 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) // lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
// lastPath : that project's .rpp path when last seen ("" if 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 // currentGuid : the GUID stored in the now-active project's ext state ("" if
// 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 (GUID is the identity; path only distinguishes Save vs Save-As within // Rules:
// the SAME identity): // * sameProjectObject == false -> Load (a different project
// * currentGuid != lastGuid -> Load (a different project) // object — tab-switch / open /
// * same non-empty GUID, currentPath == lastPath -> NoOp (Save in place / idle) // recycled pointer; NEVER a
// * same non-empty GUID, currentPath != lastPath -> SaveAsRelocate // relocate, even if the GUID
// * both GUIDs empty, same path -> NoOp (idle unsaved project) // matches a forked sibling)
// * both GUIDs empty, different path -> Load (can't PROVE same // * same object, non-empty GUID, path unchanged -> NoOp (Save in place / idle)
// project without a GUID — a // * same object, non-empty GUID, path changed -> SaveAsRelocate
// first-save or a switch // * same object, empty GUID, path unchanged -> NoOp (idle unsaved project)
// between unsaved projects; // * same object, empty GUID, path changed -> SaveAsRelocate (first save;
// never a relocate) // the relocation plan no-ops on
// The both-empty/different-path -> Load rule is what makes the recycled-pointer // the empty old dir, so nothing
// bug impossible: absent GUID corroboration, a path change is treated as a new // is physically relocated —
// project (safe: load), never a relocate (destructive: copy-over). // the empty-GUID safety holds —
ProjectTransition classifyProjectTransition(const std::string& lastGuid, // 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& lastPath,
const std::string& currentGuid, const std::string& currentGuid,
const std::string& currentPath); const std::string& currentPath);
+63 -22
View File
@@ -14,23 +14,33 @@
// 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 is that GUID — CONTENT-BASED, not the ReaProject* pointer: // state. Identity rests on the live ReaProject* POINTER first, with the GUID as
// * GUID changed -> a different project became active (open a project, switch // a secondary signal:
// tab, new project, OR a recycled pointer) -> LOAD its index from ext state. // * different project object (proj != lastProject_) -> a switch/open/new
// * same GUID, .rpp path changed -> genuine Save-As to a new location -> // project -> LOAD its index; NEVER relocate. If its stored GUID still equals
// relocate the bank folder from the old dir to the new one. // the one we just left (a forked sibling that copied our GUID via Save-As),
// Why not the pointer: REAPER recycles a closed project's ReaProject* address // re-GUID it so the siblings diverge going forward.
// for a newly-active project. Keying Save-As off "same pointer + new path" let // * SAME object, .rpp path changed -> genuine Save-As to a new location ->
// that recycling read as a Save-As and copy the wrong bank over a real one // relocate the bank folder from the old dir to the new one, then re-GUID.
// (the M4 defect). classifyProjectTransition (pure, capture_paths) encodes the // Why the pointer is back (W10 fix): the M4 GUID-only scheme could not tell a
// decision; poll() only supplies the observed identity and executes the verdict. // 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 // 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
// a cross-open identity), so we MINT one with genGuid/guidToString and store it // 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 // 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 // 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 // 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 // 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). // treating it as a "change" (avoids a spurious relocation on startup).
primed_ = true; primed_ = true;
loadFromProject(proj, projectDirOf(rppPath)); loadFromProject(proj, projectDirOf(rppPath));
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid); lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath; lastRppPath_ = rppPath;
return; return;
} }
const ProjectTransition transition = // Pointer identity is the primary signal: a genuine Save-As keeps the SAME
classifyProjectTransition(lastGuid_, lastRppPath_, currentGuid, rppPath); // 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) { switch (transition) {
case ProjectTransition::NoOp: case ProjectTransition::NoOp:
return; return;
case ProjectTransition::Load: case ProjectTransition::Load: {
// A different project is active (open / tab switch / new project / // A DIFFERENT project object is active (open / tab switch / new project
// recycled pointer). Load ITS index; never relocate. Establish its // / recycled pointer). Load ITS index; never relocate.
// identity the same way prime does. //
// 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<ReaProject*>(proj), kProjExtNamespace,
kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
loadFromProject(proj, projectDirOf(rppPath)); 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); lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath; lastRppPath_ = rppPath;
return; return;
}
case ProjectTransition::SaveAsRelocate: { case ProjectTransition::SaveAsRelocate: {
// Same GUID + new .rpp path: a genuine Save-As. Relocate the bank // SAME project object + new .rpp path: a genuine Save-As (the pointer
// folder from the old dir to the new one so the wavs sit under the // proves it — a fork tab-switch is a DIFFERENT object and took the Load
// new .rpp and the index's relative paths still resolve. Keep the // branch above). Relocate the bank folder from the old dir to the new
// in-memory bank as-is (Save-As copied our ext state, the relative // one so the wavs sit under the new .rpp and the index's relative paths
// paths are unchanged) — do NOT reload. // 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 oldDir = projectDirOf(lastRppPath_);
const std::string newDir = projectDirOf(rppPath); const std::string newDir = projectDirOf(rppPath);
const BankRelocation plan = deriveRelocationPlan(oldDir, newDir); const BankRelocation plan = deriveRelocationPlan(oldDir, newDir);
@@ -312,6 +352,7 @@ void ReaSamplerSession::poll() {
kProjExtGuidKey, fresh.c_str()); kProjExtGuidKey, fresh.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj)); MarkProjectDirty(static_cast<ReaProject*>(proj));
} }
lastProject_ = proj; // unchanged (same object) — set for symmetry
lastGuid_ = fresh; lastGuid_ = fresh;
lastRppPath_ = rppPath; lastRppPath_ = rppPath;
return; return;
+16 -10
View File
@@ -48,15 +48,18 @@ inline constexpr const char* kProjExtGuidKey = "project_guid";
// Owns the session's BankIndex and drives persistence against the active REAPER // 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 // 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 // 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 // * 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 is CONTENT-BASED, not pointer-based: persist keys Load/Save-As off a // Identity rests on the live ReaProject* pointer FIRST (a genuine Save-As is one
// GUID it mints and stores in each project's ext state, not the ReaProject* // object saved to a new path — same pointer; a tab-switch/open is a different
// pointer (REAPER recycles pointer addresses across close/open, which let a // object), with a minted GUID as a SECONDARY signal to survive pointer *reuse*
// project switch masquerade as a Save-As and clobber a bank — the M4 defect). // (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 // 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.
@@ -95,12 +98,15 @@ 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.
// Identity is the GUID we mint per project (kProjExtGuidKey), NOT the raw // The pointer is the PRIMARY signal (same object across ticks = a candidate
// ReaProject* pointer — see the class comment for why. The .rpp path is // Save-As; different object = a switch/open, never a relocate). The GUID and
// tracked alongside so a same-GUID path change (Save-As) is distinguishable // path travel alongside: the GUID distinguishes pointer *reuse* and drives
// from a same-GUID same-path idle tick (Save in place / no change). // 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 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 bool primed_ = false; // false until the first poll() observes state
// Load the index from the given project's ext state and resolve bank paths // Load the index from the given project's ext state and resolve bank paths
+71 -42
View File
@@ -178,64 +178,92 @@ static void testRelocationPlanEmptyInputsNoOp() {
CHECK(!deriveRelocationPlan("", "").needed); 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() { static void testTransitionForkTabSwitchLoadsNeverRelocates() {
// THE ORIGINAL BUG. Project A (guidA, /a/a.rpp) is closed; REAPER makes a // THE W10 BUG. proj2 and proj3 are forked siblings (Save-As copied the .rpp
// different saved project B active with a recycled pointer. B carries its own // incl. our GUID), so BOTH carry the same non-empty GUID on disk but sit at
// GUID and a different path. With identity keyed on the GUID this is a Load, // different paths. Tab-switching between them is a DIFFERENT project object
// NOT a Save-As — so the bank is never clobbered. // (sameProjectObject == false). The old GUID-only classifier read this as a
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp") // 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); == ProjectTransition::Load);
// Even the adversarial shape — recycled pointer AND B happens to sit at a // Switching back the other way is likewise a different object -> Load.
// path the old check would misread — resolves to Load purely on the GUID. CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/a/other.rpp") "guidShared", "/proj3/p.rpp",
"guidShared", "/proj2/p.rpp")
== ProjectTransition::Load); == ProjectTransition::Load);
} }
static void testTransitionGenuineSaveAsRelocates() { static void testTransitionGenuineSaveAsRelocates() {
// Same project (same non-empty GUID) saved to a new .rpp location -> the one // The SAME project object (pointer unchanged) saved to a new .rpp location ->
// case that legitimately relocates the bank. // the one case that legitimately relocates the bank. Same GUID, new path.
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/b/b.rpp") CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate); == ProjectTransition::SaveAsRelocate);
} }
static void testTransitionTabSwitchLoads() { static void testTransitionPointerReuseDifferentGuidLoads() {
// Switching to another open project (different GUID) -> Load, never relocate, // Pointer *reuse* for a genuinely different project: at THIS tick the object
// regardless of whether the paths differ. // differs (sameProjectObject == false) and its GUID differs too -> Load. Never
CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp") // 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); == 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); == ProjectTransition::Load);
} }
static void testTransitionSaveInPlaceIsNoOp() { 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. // 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); == ProjectTransition::NoOp);
} }
static void testTransitionEmptyGuidPathChangeLoadsNeverRelocates() { static void testTransitionFirstSaveSameObjectRelocatesButPlanNoOps() {
// No GUID on either side (unsaved projects / first-save) means we CANNOT // Same object, empty GUID, path appears (first save of an untitled project).
// prove the two ticks saw the same project. A path change is then a Load // The classifier says SaveAsRelocate, but the empty-GUID safety is preserved
// (first save, or a switch between unsaved projects) — never a relocate, // at execution: the old project dir is empty, so deriveRelocationPlan makes
// which is the safe direction (no destructive copy-over without proof). // `needed` false and NOTHING is physically relocated; poll() just mints a GUID.
CHECK(classifyProjectTransition("", "", "", "/a/a.rpp") CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
== ProjectTransition::Load); "", "", "", "/a/a.rpp")
CHECK(classifyProjectTransition("", "/tmp/untitled.rpp", "", "/a/a.rpp") == ProjectTransition::SaveAsRelocate);
== ProjectTransition::Load); // Prove the safety end-to-end: the relocation plan for an empty old dir no-ops.
// Both empty, same path -> idle unsaved project -> NoOp. CHECK(!deriveRelocationPlan(/*oldProjectDir=*/"", "/a").needed);
CHECK(classifyProjectTransition("", "", "", "")
== ProjectTransition::NoOp);
} }
static void testTransitionGuidAppearingLoads() { static void testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers() {
// A project that had no stored GUID (last seen empty) now reports one (we just // Divergence guard's precondition, from the pure side: even if a forked sibling
// minted+wrote it, or an older project gained one). The GUID changed, so it // still shares our GUID, as long as the OBJECT differs the verdict is Load
// classifies as Load — harmless: loadFromProject re-reads the same ext state. // (never Save-As). The actual re-GUID that makes the siblings diverge is a
CHECK(classifyProjectTransition("", "/a/a.rpp", "guidA", "/a/a.rpp") // 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); == ProjectTransition::Load);
} }
@@ -255,12 +283,13 @@ int main() {
testRelocationPlanForSaveAs(); testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace(); testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp(); testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerLoadsNotRelocates(); testTransitionForkTabSwitchLoadsNeverRelocates();
testTransitionGenuineSaveAsRelocates(); testTransitionGenuineSaveAsRelocates();
testTransitionTabSwitchLoads(); testTransitionPointerReuseDifferentGuidLoads();
testTransitionTabSwitchDistinctProjectsLoads();
testTransitionSaveInPlaceIsNoOp(); testTransitionSaveInPlaceIsNoOp();
testTransitionEmptyGuidPathChangeLoadsNeverRelocates(); testTransitionFirstSaveSameObjectRelocatesButPlanNoOps();
testTransitionGuidAppearingLoads(); testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers();
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);