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.
This commit is contained in:
+17
-18
@@ -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
@@ -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
@@ -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));
|
||||||
|
lastProject_ = proj;
|
||||||
|
lastGuid_ = fresh;
|
||||||
|
lastRppPath_ = rppPath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal load: establish identity the same way prime does.
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user