pS-usage: captures held by live ReaSampler 9000 instances are un-prunable — instances publish usage_<guid> ext-state records (ComponentState v11), prune unions live holds into referenced
This commit is contained in:
@@ -383,6 +383,35 @@ static void testDeletePlanDeduplicatesConfirmed() {
|
||||
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
|
||||
}
|
||||
|
||||
// --- mergeReferenced: the pS-usage referenced-union (bank refs ∪ instance holds) -----
|
||||
|
||||
// The load-bearing property: a file held ONLY by a sampler instance (not referenced by
|
||||
// any bank — e.g. its bank entry was deleted while the instance kept its v10 ref) joins
|
||||
// `referenced` through the union, so pruneOrphans can never emit it. Removing the hold
|
||||
// (the instance went away — liveness filtered its record out upstream) reclaims it again.
|
||||
static void testMergeReferencedProtectsInstanceHeldFile() {
|
||||
const std::vector<std::string> present{"b/held.wav", "b/orphan.wav"};
|
||||
const std::vector<std::string> owned{"b/held.wav", "b/orphan.wav"};
|
||||
const std::vector<std::string> bankRefs{}; // no bank references either file
|
||||
|
||||
const std::vector<std::string> withHold =
|
||||
pruneOrphans(present, mergeReferenced(bankRefs, {"b/held.wav"}), owned);
|
||||
CHECK((withHold == std::vector<std::string>{"b/orphan.wav"}));
|
||||
|
||||
const std::vector<std::string> withoutHold =
|
||||
pruneOrphans(present, mergeReferenced(bankRefs, {}), owned);
|
||||
CHECK(withoutHold.size() == 2); // no live hold -> both reclaim (no permanent block)
|
||||
}
|
||||
|
||||
// Order-preserving exact-string de-dup: primary first, then the extras not already seen.
|
||||
static void testMergeReferencedOrderDedupAndExactMatch() {
|
||||
const std::vector<std::string> merged = mergeReferenced(
|
||||
{"b/a.wav", "b/b.wav", "b/a.wav"}, {"b/b.wav", "b/c.wav", "B/A.WAV"});
|
||||
// Case differs -> distinct entry (exact-string convention, never case-folded here).
|
||||
CHECK((merged == std::vector<std::string>{"b/a.wav", "b/b.wav", "b/c.wav", "B/A.WAV"}));
|
||||
CHECK(mergeReferenced({}, {}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNullTestAllReferenced();
|
||||
testFormulaMixedPopulations();
|
||||
@@ -411,6 +440,8 @@ int main() {
|
||||
testDeletePlanNeverSweepsUnconfirmed();
|
||||
testDeletePlanEmptyInputs();
|
||||
testDeletePlanDeduplicatesConfirmed();
|
||||
testMergeReferencedProtectsInstanceHeldFile();
|
||||
testMergeReferencedOrderDedupAndExactMatch();
|
||||
|
||||
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
|
||||
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
|
||||
|
||||
@@ -2272,6 +2272,79 @@ static void testComponentStateV9LiftsToEmptyRefs() {
|
||||
CHECK(back.map.zones.empty());
|
||||
}
|
||||
|
||||
static void testInstanceGuidRoundTripV11() {
|
||||
// v11 (pS-usage): the minted publish identity round-trips with the envelope
|
||||
// neighbours (refs table before it, selection/zones after it) intact — the guid
|
||||
// read consumed exactly its own bytes. An empty guid (never published) is legal
|
||||
// and round-trips empty.
|
||||
ComponentState s;
|
||||
s.selectionId = "kick";
|
||||
s.instanceGuid = "0123456789abcdef0123456789abcdef";
|
||||
s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36));
|
||||
s.map.zones.push_back(zone("kick", 0, 127));
|
||||
const ComponentState back =
|
||||
deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.instanceGuid == "0123456789abcdef0123456789abcdef");
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.selectionId == "kick");
|
||||
CHECK(back.map.zones.size() == 1);
|
||||
|
||||
ComponentState fresh;
|
||||
fresh.selectionId = "s";
|
||||
const ComponentState freshBack =
|
||||
deserializeComponentState(serializeComponentState(fresh), 44100.0);
|
||||
CHECK(freshBack.instanceGuid.empty());
|
||||
CHECK(freshBack.selectionId == "s");
|
||||
}
|
||||
|
||||
static void testComponentStateV10LiftsToEmptyGuid() {
|
||||
// OLD-BLOB FALLBACK: a genuine v10 blob (refs table but no instance guid) restores
|
||||
// with an EMPTY guid — the processor mints one on first publish — and every other
|
||||
// field intact. Hand-built (serializeComponentState now emits v11, so it cannot
|
||||
// make a v10 blob).
|
||||
std::vector<std::uint8_t> v10;
|
||||
v10.push_back(10); v10.push_back(0); v10.push_back(0); v10.push_back(0); // version 10
|
||||
v10.push_back(0); // mode = mono
|
||||
for (int i = 0; i < 8; ++i) v10.push_back(0); // marker = 0
|
||||
v10.push_back(88); // preview velocity
|
||||
v10.push_back(7); // voice count
|
||||
v10.push_back(0); // voice mode = poly
|
||||
v10.push_back(0); // trigger = retrigger
|
||||
for (int i = 0; i < 8; ++i) v10.push_back(0); // gain double bytes...
|
||||
v10[17 + 6] = 0xF0; v10[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
|
||||
v10.push_back(1); // explicit flag = true
|
||||
// The v10 refs table: ONE entry {id "a", path "b/a.wav", root 36, no loop, ch 0, no name}.
|
||||
v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // ref count 1
|
||||
v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // id len 1
|
||||
v10.push_back('a');
|
||||
const std::string relPath = "b/a.wav";
|
||||
v10.push_back(static_cast<std::uint8_t>(relPath.size()));
|
||||
v10.push_back(0); v10.push_back(0); v10.push_back(0);
|
||||
v10.insert(v10.end(), relPath.begin(), relPath.end());
|
||||
v10.push_back(36); v10.push_back(0); v10.push_back(0); v10.push_back(0); // root 36
|
||||
v10.push_back(0); // hasLoop = false
|
||||
for (int i = 0; i < 16; ++i) v10.push_back(0); // loop start+end
|
||||
v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // channels 0
|
||||
v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // name len 0
|
||||
// NO guid field (the v11 addition) — the selection id follows directly.
|
||||
const std::string id = "saved";
|
||||
v10.push_back(static_cast<std::uint8_t>(id.size()));
|
||||
v10.push_back(0); v10.push_back(0); v10.push_back(0);
|
||||
v10.insert(v10.end(), id.begin(), id.end());
|
||||
v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // zone count 0
|
||||
const ComponentState back = deserializeComponentState(v10, 44100.0);
|
||||
CHECK(back.instanceGuid.empty()); // pre-v11 blob -> empty guid (minted on publish)
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "a");
|
||||
CHECK(back.sampleRefs.size() == 1 &&
|
||||
back.sampleRefs[0].ref.relativePath == "b/a.wav");
|
||||
CHECK(back.selectionId == "saved");
|
||||
CHECK(back.channelModeExplicit);
|
||||
CHECK(back.previewVelocity == 88);
|
||||
CHECK(back.voiceCount == 7);
|
||||
CHECK(back.map.zones.empty());
|
||||
}
|
||||
|
||||
static void testReferencedSampleIdsDedup() {
|
||||
// Selection first, then map order, duplicates collapsed; an empty selection contributes
|
||||
// nothing (no phantom "" id in the refs table).
|
||||
@@ -2524,6 +2597,8 @@ int main() {
|
||||
testResolveFromRefsMissingRefDrops();
|
||||
testResolveFromRefsMatchesBankResolve();
|
||||
testComponentStateV9LiftsToEmptyRefs();
|
||||
testInstanceGuidRoundTripV11();
|
||||
testComponentStateV10LiftsToEmptyGuid();
|
||||
testReferencedSampleIdsDedup();
|
||||
testRefreshRefsFromBankUpsertAndOwnership();
|
||||
testRetainRefsFiltersToPlayedSet();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// Standalone tests for reasampler::sample_usage — no REAPER, no framework.
|
||||
// The pS-usage seam: instances publish held captures; the extension folds live
|
||||
// instances' holds into the prune's `referenced` set. Tested hard here because this is
|
||||
// the prune-protection guarantee: a capture held by a live instance must be IMPOSSIBLE
|
||||
// to prune (the composed proof at the bottom links prune_reconcile and shows
|
||||
// pruneOrphans can never emit a held path), while a stale record from a deleted
|
||||
// instance must NOT permanently block reclaim (the liveness fold).
|
||||
//
|
||||
// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the
|
||||
// publish plan's four branches (fresh key / clean replace / same-track union /
|
||||
// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track,
|
||||
// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof.
|
||||
|
||||
#include "../src/sample_usage.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof)
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
UsageRecord makeRecord(const std::string& trackGuid,
|
||||
std::vector<UsageHold> holds) {
|
||||
UsageRecord r;
|
||||
r.trackGuid = trackGuid;
|
||||
r.holds = std::move(holds);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- wire round-trip ----------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
const UsageRecord rec = makeRecord(
|
||||
"{12345678-1234-1234-1234-1234567890AB}",
|
||||
{UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"},
|
||||
UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}});
|
||||
const std::string wire = encodeUsageRecord(rec);
|
||||
auto back = decodeUsageRecord(wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == rec);
|
||||
CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode
|
||||
}
|
||||
|
||||
static void testRoundTripEmptyHoldsAndEmptyGuid() {
|
||||
// An empty-holds record is LEGAL (an instance releasing everything it held), and an
|
||||
// empty trackGuid is legal (no track context at publish -> any-instance fallback).
|
||||
const UsageRecord rec = makeRecord("", {});
|
||||
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->trackGuid.empty());
|
||||
CHECK(back->holds.empty());
|
||||
}
|
||||
|
||||
static void testRoundTripAdversarialBytes() {
|
||||
// Ids/paths carrying the wire's own metacharacters must survive whole (the whole
|
||||
// point of length-prefixing): ':' delimiters, digits, the magic tag itself.
|
||||
const UsageRecord rec = makeRecord(
|
||||
"12:34:guid-with-colons",
|
||||
{UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}});
|
||||
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == rec);
|
||||
}
|
||||
|
||||
static void testDecodeMalformed() {
|
||||
CHECK(!decodeUsageRecord("").has_value());
|
||||
CHECK(!decodeUsageRecord("garbage").has_value());
|
||||
CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic
|
||||
CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version
|
||||
// Truncated mid-holds: claims 2 holds, carries 1.
|
||||
UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}});
|
||||
std::string wire = encodeUsageRecord(one);
|
||||
// Rewrite the count field "1:1" -> "1:2" (count is the 2nd field: len 1, value '1').
|
||||
const std::string needle = "1:1"; // count field for one hold
|
||||
const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4);
|
||||
CHECK(pos != std::string::npos);
|
||||
wire[pos + 2] = '2';
|
||||
CHECK(!decodeUsageRecord(wire).has_value());
|
||||
// Trailing garbage after a complete record -> reject whole.
|
||||
CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value());
|
||||
}
|
||||
|
||||
// --- publish plan --------------------------------------------------------------
|
||||
|
||||
static void testPlanFreshKey() {
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(!plan.skipWrite);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||
}
|
||||
|
||||
static void testPlanCleanReplaceAndSkip() {
|
||||
// The key holds exactly what this lifetime wrote -> clean replace; released holds drop.
|
||||
const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"},
|
||||
UsageHold{"b", "pb.wav"}});
|
||||
const std::string prevWire = encodeUsageRecord(prev);
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(!plan.skipWrite);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in
|
||||
|
||||
// Unchanged play-set -> byte-identical write -> skip (idle reload tick).
|
||||
const UsagePublishPlan idle = planUsagePublish(prevWire, prevWire, prev);
|
||||
CHECK(idle.skipWrite);
|
||||
CHECK(!idle.remint);
|
||||
}
|
||||
|
||||
static void testPlanSameTrackUnion() {
|
||||
// First publish of a lifetime (lastPublished empty) over a same-track existing value:
|
||||
// my own last-session record OR a same-track copy-sibling — either way UNION, never
|
||||
// drop (the fail-safe direction; a sibling's holds must survive my write).
|
||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"},
|
||||
UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan =
|
||||
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||
CHECK(!plan.remint);
|
||||
auto back = decodeUsageRecord(plan.wire);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back->trackGuid == "{T1}");
|
||||
CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped
|
||||
CHECK(back->holds[0].sampleId == "a");
|
||||
CHECK(back->holds[1].sampleId == "b");
|
||||
}
|
||||
|
||||
static void testPlanCrossTrackRemint() {
|
||||
// The key holds a foreign record from ANOTHER track: this state was cloned there
|
||||
// (FX copy / track duplication) — take a fresh identity, leave theirs untouched.
|
||||
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan =
|
||||
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||
CHECK(plan.remint);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine)); // written under the NEW key
|
||||
}
|
||||
|
||||
static void testPlanUndecodableExisting() {
|
||||
// An undecodable existing value protects nothing — overwrite with mine.
|
||||
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine);
|
||||
CHECK(!plan.remint);
|
||||
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||
}
|
||||
|
||||
// --- liveness fold ---------------------------------------------------------------
|
||||
|
||||
static void testHeldPathsLiveness() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}),
|
||||
makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance
|
||||
makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context
|
||||
};
|
||||
const std::unordered_set<std::string> live = {"{LIVE}"};
|
||||
|
||||
// Live-track record counts; dead-track record is EXCLUDED (no stale false-protect);
|
||||
// empty-guid record counts while ANY instance lives (fail-safe fallback).
|
||||
const std::vector<std::string> withAny = usageHeldPaths(records, live, true);
|
||||
CHECK(withAny.size() == 2);
|
||||
CHECK(withAny[0] == "pa.wav");
|
||||
CHECK(withAny[1] == "pc.wav");
|
||||
|
||||
// No instance anywhere -> empty-guid fallback closes too; only live-track survives.
|
||||
const std::vector<std::string> noAny = usageHeldPaths(records, live, false);
|
||||
CHECK(noAny.size() == 1);
|
||||
CHECK(noAny[0] == "pa.wav");
|
||||
|
||||
// Zero live instances at all -> nothing protected (a project whose instances were
|
||||
// all deleted cannot be permanently blocked by leftover records).
|
||||
const std::vector<std::string> none =
|
||||
usageHeldPaths(records, std::unordered_set<std::string>{}, false);
|
||||
CHECK(none.empty());
|
||||
}
|
||||
|
||||
static void testHeldPathsDedupAndEmptyPathSkip() {
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
||||
makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
||||
};
|
||||
const std::unordered_set<std::string> live = {"{T}"};
|
||||
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
||||
CHECK(paths.size() == 2); // shared.wav de-duped across records; empty path skipped
|
||||
CHECK(paths[0] == "shared.wav");
|
||||
CHECK(paths[1] == "own.wav");
|
||||
}
|
||||
|
||||
// --- the composed prune-protection proof -----------------------------------------
|
||||
// The definition-of-done property at the pure layer: a capture held by a live instance
|
||||
// lands in the referenced union, and pruneOrphans can NEVER emit it — even when the
|
||||
// bank no longer references it (deleted from the bank while the instance kept its ref)
|
||||
// and it is owned + present (the exact preconditions under which it WOULD be reclaimed).
|
||||
|
||||
static void testInstanceHoldMakesPathUnprunable() {
|
||||
const std::vector<std::string> present = {"held.wav", "orphan.wav"};
|
||||
const std::vector<std::string> owned = {"held.wav", "orphan.wav"};
|
||||
const std::vector<std::string> bankRefs = {}; // bank does NOT reference either
|
||||
|
||||
// Without instance usage both are orphans (the pre-pS-usage behavior).
|
||||
CHECK(pruneOrphans(present, bankRefs, owned).size() == 2);
|
||||
|
||||
// A live instance holds held.wav -> the union protects it; orphan.wav still reclaims.
|
||||
const std::vector<UsageRecord> records = {
|
||||
makeRecord("{T}", {UsageHold{"id-held", "held.wav"}})};
|
||||
const std::unordered_set<std::string> live = {"{T}"};
|
||||
const std::vector<std::string> referenced =
|
||||
mergeReferenced(bankRefs, usageHeldPaths(records, live, true));
|
||||
const std::vector<std::string> orphans = pruneOrphans(present, referenced, owned);
|
||||
CHECK(orphans.size() == 1);
|
||||
CHECK(orphans[0] == "orphan.wav");
|
||||
|
||||
// The instance (and its track) deleted -> the record no longer counts -> held.wav
|
||||
// is reclaimable again (no permanent stale-key block).
|
||||
const std::vector<std::string> refsAfterDelete = mergeReferenced(
|
||||
bankRefs, usageHeldPaths(records, std::unordered_set<std::string>{}, false));
|
||||
CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2);
|
||||
}
|
||||
|
||||
static void testMergeReferencedOrderAndDedup() {
|
||||
const std::vector<std::string> a = {"p1.wav", "p2.wav"};
|
||||
const std::vector<std::string> b = {"p2.wav", "p3.wav", "p1.wav"};
|
||||
const std::vector<std::string> merged = mergeReferenced(a, b);
|
||||
CHECK(merged.size() == 3);
|
||||
CHECK(merged[0] == "p1.wav");
|
||||
CHECK(merged[1] == "p2.wav");
|
||||
CHECK(merged[2] == "p3.wav");
|
||||
CHECK(mergeReferenced({}, {}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testRoundTrip();
|
||||
testRoundTripEmptyHoldsAndEmptyGuid();
|
||||
testRoundTripAdversarialBytes();
|
||||
testDecodeMalformed();
|
||||
testPlanFreshKey();
|
||||
testPlanCleanReplaceAndSkip();
|
||||
testPlanSameTrackUnion();
|
||||
testPlanCrossTrackRemint();
|
||||
testPlanUndecodableExisting();
|
||||
testHeldPathsLiveness();
|
||||
testHeldPathsDedupAndEmptyPathSkip();
|
||||
testInstanceHoldMakesPathUnprunable();
|
||||
testMergeReferencedOrderAndDedup();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("sample_usage_tests: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("sample_usage_tests: %d FAILURES\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user