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:
2026-07-22 21:28:54 -04:00
parent aacf8b49e8
commit 36270e2064
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;
}
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;
}
+45 -24
View File
@@ -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);
+63 -22
View File
@@ -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<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));
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<ReaProject*>(proj));
}
lastProject_ = proj; // unchanged (same object) — set for symmetry
lastGuid_ = fresh;
lastRppPath_ = rppPath;
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
// 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
+71 -42
View File
@@ -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);