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