Merge pS-usage: captures held by live ReaSampler 9000 instances are un-prunable — instances publish usage records (ComponentState v11), prune unions live holds; fail-safe to protect/abort

This commit is contained in:
2026-07-28 14:08:16 -04:00
20 changed files with 1956 additions and 11 deletions
+26 -2
View File
@@ -337,6 +337,19 @@ target_include_directories(provenance PUBLIC src)
add_library(assignment_request STATIC src/assignment_request.cpp)
target_include_directories(assignment_request PUBLIC src)
# ---------------------------------------------------------------------------
# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam:
# the per-instance usage record the INSTRUMENT writes ("rsusage_<guid>" ext-state,
# the one sanctioned VST-side write) and the EXTENSION reads at prune time. Owns
# the wire round-trip, the publish plan (copy-collision fail-safe resolution),
# the liveness fold (which records count against the live FX enumeration,
# protect-all when zero identified, abort on unreadable), and the FX identity
# matcher.
# Linked by BOTH artifacts — the mirror of assignment_request, reversed direction.
# ---------------------------------------------------------------------------
add_library(sample_usage STATIC src/sample_usage.cpp)
target_include_directories(sample_usage PUBLIC src)
# ---------------------------------------------------------------------------
# 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11
# native-OS-drag-out decision core: the gesture-boundary decision (drag state +
@@ -708,6 +721,14 @@ add_executable(assignment_request_tests tests/test_assignment_request.cpp)
target_link_libraries(assignment_request_tests PRIVATE assignment_request)
add_test(NAME assignment_request_tests COMMAND assignment_request_tests)
# sample_usage (pS-usage): the instance-usage wire + publish plan + liveness fold. The
# tests are the prune-protection proof at the pure layer: a capture held by a live
# instance lands in the referenced union and pruneOrphans can never emit it (links
# prune_reconcile for the composed proof).
add_executable(sample_usage_tests tests/test_sample_usage.cpp)
target_link_libraries(sample_usage_tests PRIVATE sample_usage prune_reconcile)
add_test(NAME sample_usage_tests COMMAND sample_usage_tests)
# sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the
# VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data
# boundary (a VST3/REAPER type in the core would fail to compile/link here).
@@ -1016,8 +1037,9 @@ add_library(reaper_reasampler MODULE
src/tooltip.cpp
src/card_meta.cpp
src/card_drag.cpp
src/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
@@ -1173,11 +1195,13 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# knob_deck + curve_popup + master_gain (Wave B FB1, r11): the pure deck layout/hit-test,
# the curve-popup sheet geometry, and the master-gain taper the recomposed Sample face
# draws + routes against (master_gain also rides in via sample_map for the v8 wire cap).
# sample_usage (pS-usage): the usage-record wire + publish plan the processor's
# reloadInstrument publishes through the bridge (the one sanctioned VST-side write).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck curve_popup master_gain)
knob_deck curve_popup master_gain sample_usage)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+20
View File
@@ -821,6 +821,26 @@ void doBankRemoveSelected() {
void doBankPruneFolder() {
const PruneReport report = g_session->pruneDryRun();
// pS-usage FAIL-SAFE: a present instance-usage record could not be read — the
// protected set is unknowable, so the prune HALTS outright (deletes nothing) rather
// than proceed with degraded protection. Distinct from "no orphans": the user must
// know the prune refused to run and why.
if (report.abortedUnreadableUsage) {
std::string msg =
"ReaSampler prune: ABORTED -- one or more instance usage records could not "
"be read or decoded. Nothing was deleted.\n"
"If the owning instance is still loaded it will republish its record on the "
"next poll tick, clearing the abort. If the instance no longer exists (the "
"key is an orphaned corrupt record), clear it manually via ReaScript:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
"Offending key(s):\n";
for (const std::string& key : report.offendingUsageKeys) {
msg += " " + key + "\n";
}
ShowConsoleMsg(msg.c_str());
return;
}
if (report.count == 0) {
ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
return;
+22
View File
@@ -69,4 +69,26 @@ inline constexpr const char* kProjExtBankGenKey = "bank_generation";
// changing this spelling strands any pending request an already-shipped instrument watches.
inline constexpr const char* kProjExtAssignKey = "assign_request";
// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per
// instance — "rsusage_<instanceGuid>" — carrying the sample_usage wire record of every
// capture that instance holds; the EXTENSION enumerates the prefix at prune-scan time
// and folds live instances' holds into the prune's `referenced` set so a held capture
// can never be pruned. This is the ONE sanctioned instrument-side ext-state write
// (Daniel's ruling — the VST publishes its OWN usage; it never mutates banks/view/
// tail/assign, and the bridge's write entry point structurally accepts only this
// prefix). WIRE-SHARED in the write->read direction the other keys reverse. The "rs"
// qualifier is deliberate: a future key that happens to start with "usage_" must never
// be swept into the FX-liveness fold (whose abort-on-unreadable rule would then halt
// every prune), so the prefix is namespaced like the wire magics (rsusage1/rsassign1).
// FOREVER-STABLE once shipped: changing the prefix strands every saved project's usage
// records (prune falls back to bank-references-only until instances republish —
// graceful, but the instance-hold protection lapses for stale-saved projects).
inline constexpr const char* kProjExtUsageKeyPrefix = "rsusage_";
// The full per-instance usage key for a minted instance GUID (the one composition
// point, shared by the instrument's writer and the extension's enumerator).
inline std::string usageKeyFor(const std::string& instanceGuid) {
return std::string(kProjExtUsageKeyPrefix) + instanceGuid;
}
} // namespace reasampler
+43 -3
View File
@@ -96,6 +96,7 @@
#include "app_version.h"
#include "capture_paths.h"
#include "prune_reconcile.h"
#include "usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`)
#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
#define REAPERAPI_MINIMAL
@@ -305,10 +306,18 @@ constexpr std::size_t kPruneListDisplayCap = 64;
// * orphans — the FULL orphan set (owned ∩ present) referenced, in enumeration
// order, untruncated. The pure core decides; this only supplies inputs.
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could
// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY —
// the prune must halt rather than proceed with degraded protection.
// An empty orphan set is itself the delete-side guarantee (every
// consumer of this scan deletes at most `orphans ∩ ...`), the flag is
// what lets the action TELL the user instead of claiming "no orphans".
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
@@ -359,9 +368,29 @@ PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned)
// The decision lives in the pure core — read-only inputs from the book and manifest.
// referencedPaths() unions across the whole book (pool included); owned().paths() is
// the manifest set. This shell only enumerates, resolves, and stats.
// the manifest set. pS-usage: the referenced set additionally unions every LIVE
// ReaSampler 9000 instance's held captures (usage_scan reads the per-instance
// rsusage_* records + the live FX enumeration; sample_usage decides liveness,
// including the protect-all net when zero instances were identified) — a capture
// any live instance holds can NEVER be an orphan, even when its bank entry was
// deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY,
// preserving this scan's no-write contract. This shell only enumerates, resolves,
// and stats.
scan.bankDirAbs = bankDir;
scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths());
const UsageScanResult usage = liveInstanceHeldPaths(proj);
if (usage.abortPrune) {
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the
// protected set is unknowable. Compute NO orphans — every downstream consumer
// (dry-run report, confirm set, fresh-recompute delete plan) then deletes
// nothing. The flag + key names surface the reason so the action can name each
// offending key for operator recovery.
scan.abortedUnreadableUsage = true;
scan.offendingUsageKeys = usage.offendingKeys;
return scan;
}
scan.orphans = pruneOrphans(
present, mergeReferenced(book.referencedPaths(), usage.heldPaths),
owned.paths());
return scan;
}
@@ -371,7 +400,15 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
PruneReport report =
buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// pS-usage fail-safe: surface the unreadable-record abort so the action halts with
// an explicit message instead of reporting "no orphaned files" (the count IS zero —
// the scan computed nothing — but the user must know the prune refused to run).
// The offending key names propagate so the action can name each one for recovery.
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
report.offendingUsageKeys = scan.offendingUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
@@ -457,6 +494,9 @@ PruneDeletionResult ReaSamplerSession::pruneReclaim(
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
// pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an
// EMPTY orphan set, so the plan below intersects to empty and nothing is deleted —
// the fail-safe holds even in the confirm→delete window, with no extra branch here.
const PruneScan scan = scanPruneOrphans(book_, owned_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
+7 -1
View File
@@ -184,7 +184,13 @@ public:
// relative machinery the index/persist use — never a stale absolute path, so it is
// correct across a Save-As relocation), spells every enumerated entry with the index's
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
// and feeds the R1 pure core with (present, book().referencedPaths(), owned().paths()).
// and feeds the R1 pure core with (present, referenced, owned().paths()) where
// `referenced` = book().referencedPaths() every LIVE ReaSampler 9000 instance's
// held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state
// records + the live FX enumeration; sample_usage decides liveness) — a capture any
// live instance holds can never be an orphan, so the prune can never delete it.
// FAIL-SAFE: a present-but-unreadable usage record sets the report's
// abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts.
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
// list. The decision stays in the pure core — this method only enumerates, resolves,
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
+14
View File
@@ -33,6 +33,20 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
return orphans;
}
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra) {
std::vector<std::string> merged;
merged.reserve(primary.size() + extra.size());
std::unordered_set<std::string> seen;
for (const std::string& p : primary) {
if (seen.insert(p).second) merged.push_back(p);
}
for (const std::string& p : extra) {
if (seen.insert(p).second) merged.push_back(p);
}
return merged;
}
PruneReport buildPruneReport(
const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
+28
View File
@@ -64,11 +64,26 @@ namespace reasampler {
// `truncated` says whether the list was clipped.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
// rsusage_* instance-usage record (pS-usage fail-safe): the orphan
// computation was NOT performed (count 0, empty list) and the prune
// must HALT — deleting with degraded protection is the data-loss
// direction. Set by the session's scan shell, never by
// buildPruneReport (which stays a pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" ext-state key names that
// triggered the abort (non-empty iff abortedUnreadableUsage). Named
// so the action can print them for operator recovery: a corrupt/
// oversized key whose owning instance no longer exists is never
// automatically rewritten, so the abort would be permanent without
// a way to clear it. The operator can clear each key via ReaScript:
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
struct PruneReport {
std::size_t count = 0;
std::uint64_t totalBytes = 0;
std::vector<std::string> orphans;
bool truncated = false;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
};
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
@@ -107,6 +122,19 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths()
// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths).
// Order-preserving (`primary` first, then the `extra` paths not already present),
// exact-string de-dup — the same comparison convention as everything above, so feeding
// the result to pruneOrphans keeps the ` referenced` guardrail byte-exact. A path held
// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10
// ref) is protected exactly like a bank-referenced one.
//
// Pure: no I/O, no REAPER. Kept here (not in the shells) so the "instance usage makes a
// file un-prunable" property is provable at the prune layer itself.
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup.
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`;
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
+301
View File
@@ -0,0 +1,301 @@
// sample_usage.cpp — see sample_usage.h. Pure: standard library only.
#include "sample_usage.h"
#include <cctype>
#include <cstddef>
#include <limits>
namespace reasampler {
namespace {
constexpr const char* kMagic = "rsusage1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>. The same wire idiom as
// assignment_request / provenance — one grammar across every ext-state seam.
void putField(std::string& out, const std::string& field) {
out += std::to_string(field.size());
out += ':';
out += field;
}
// Bounds-checked cursor over the encoded string (the assignment_request Cursor, trimmed
// to the two field kinds this record needs). A short read latches ok_ false.
class Cursor {
public:
explicit Cursor(const std::string& s) : s_(s) {}
bool ok() const { return ok_; }
bool atEnd() const { return pos_ >= s_.size(); }
bool field(std::string& out) {
if (!ok_) return false;
const std::size_t colon = s_.find(':', pos_);
if (colon == std::string::npos) return fail();
if (colon == pos_) return fail(); // empty length token
if (colon - pos_ > 20u) return fail(); // SIZE_MAX is 20 decimal digits
std::size_t len = 0;
for (std::size_t i = pos_; i < colon; ++i) {
const char c = s_[i];
if (c < '0' || c > '9') return fail();
const std::size_t digit = static_cast<std::size_t>(c - '0');
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
return fail();
len = len * 10u + digit;
}
const std::size_t start = colon + 1;
if (start > s_.size() || len > s_.size() - start) return fail();
out.assign(s_, start, len);
pos_ = start + len;
return true;
}
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
bool literal(const char* lit) {
if (!ok_) return false;
std::size_t i = 0;
for (; lit[i] != '\0'; ++i) {
if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail();
}
pos_ += i;
return true;
}
// A length-prefixed unsigned decimal (the hold count). Fails on empty, non-digit,
// or a value past a sane ceiling (a record cannot hold more entries than bytes).
bool fieldCount(std::size_t& out) {
std::string f;
if (!field(f)) return false;
if (f.empty() || f.size() > 10u) return fail();
std::size_t v = 0;
for (const char c : f) {
if (c < '0' || c > '9') return fail();
v = v * 10u + static_cast<std::size_t>(c - '0');
}
out = v;
return true;
}
private:
bool fail() {
ok_ = false;
return false;
}
const std::string& s_;
std::size_t pos_ = 0;
bool ok_ = true;
};
} // namespace
std::string encodeUsageRecord(const UsageRecord& rec) {
std::string out = kMagic;
putField(out, rec.trackGuid);
putField(out, rec.ownerNonce);
putField(out, rec.unioned ? "1" : "0");
putField(out, std::to_string(rec.holds.size()));
for (const UsageHold& h : rec.holds) {
putField(out, h.sampleId);
putField(out, h.relativePath);
}
return out;
}
std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
Cursor c(wire);
if (!c.literal(kMagic)) return std::nullopt;
UsageRecord rec;
if (!c.field(rec.trackGuid)) return std::nullopt;
if (!c.field(rec.ownerNonce)) return std::nullopt;
std::string unionedField;
if (!c.field(unionedField)) return std::nullopt;
if (unionedField == "1") rec.unioned = true;
else if (unionedField == "0") rec.unioned = false;
else return std::nullopt; // anything else is corruption -> reject whole
std::size_t count = 0;
if (!c.fieldCount(count)) return std::nullopt;
// Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is
// provably bogus — reject before looping rather than iterating a crafted huge count.
if (count > wire.size() / 4u + 1u) return std::nullopt;
rec.holds.reserve(count);
for (std::size_t i = 0; i < count; ++i) {
UsageHold h;
if (!c.field(h.sampleId)) return std::nullopt;
if (!c.field(h.relativePath)) return std::nullopt;
rec.holds.push_back(std::move(h));
}
if (!c.ok() || !c.atEnd()) return std::nullopt; // trailing garbage -> reject whole
return rec;
}
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
const UsageRecord& mine) {
UsagePublishPlan plan;
// The written form of "just mine": mine's identity + holds, unioned=false (the plan
// computes the flag; a sole-writer record is un-poisoned).
UsageRecord cleanMine = mine;
cleanMine.unioned = false;
plan.wire = encodeUsageRecord(cleanMine);
if (!existing || existing->empty()) {
// Fresh key — write mine.
return plan;
}
const std::optional<UsageRecord> theirs = decodeUsageRecord(*existing);
if (!theirs) {
// Undecodable existing value under MY key: corruption (a sibling sharing
// this key via copy always writes decodable records). REMINT rather than
// overwrite: writing mine over the corrupt key would clear the prune-side
// abort, but a same-key sibling B's holds would then be unprotected until
// B publishes again. Leaving the corrupt key in place keeps the prune-side
// abort firing (foldUsageRecords.abortPrune) so the window where B's holds
// might be unprotected can never resolve toward delete. Mine is published
// under the new key that remint produces.
// NOTE (>16 MB gap): readReasamplerExtState returning nullopt for a value
// larger than 16 MB is indistinguishable from "absent" at the publish site;
// that narrow case takes the fresh-write branch above rather than remint.
// Both outcomes are safe (fresh write is also correct for a truly absent key);
// the gap is documented in the header's fail-safe list.
plan.remint = true;
return plan;
}
const bool nonceMatch =
!mine.ownerNonce.empty() && theirs->ownerNonce == mine.ownerNonce;
if (nonceMatch && !theirs->unioned) {
// Exactly THIS incarnation wrote the key (the per-lifetime nonce is the exact
// ownership proof — a same-track sibling's byte-identical hold set can NOT pass
// this test, its nonce differs) AND no other writer has ever unioned into it,
// so the content is provably all mine. Clean replace: released holds drop.
if (plan.wire == *existing) plan.skipWrite = true; // idle reload tick
return plan;
}
if (theirs->trackGuid == mine.trackGuid || (nonceMatch && theirs->unioned)) {
// A foreign writer on MY OWN track (a same-track copy-sibling, or my own
// last-session record — indistinguishable by construction), or a record I
// wrote last but that carries unioned holds from an earlier multi-writer
// merge. Either way no hold in it may be dropped by me — union, existing-
// first, de-duped, and the record is (or stays) POISONED unioned=true so no
// future nonce-matching write can clean-replace a sibling's holds away.
UsageRecord merged;
merged.trackGuid = mine.trackGuid;
merged.ownerNonce = mine.ownerNonce;
merged.unioned = true;
merged.holds = theirs->holds;
for (const UsageHold& h : mine.holds) {
bool dup = false;
for (const UsageHold& e : merged.holds) {
if (e == h) { dup = true; break; }
}
if (!dup) merged.holds.push_back(h);
}
if (theirs->unioned && merged.holds == theirs->holds) {
// Already poisoned and the union adds nothing — the write would flip only
// the ownerNonce. Skip the redundant ext-state churn. (A false->true
// unioned flip is NEVER skipped: it is the poison that protects the other
// writer's holds from the last writer's future clean replace.)
plan.skipWrite = true;
}
plan.wire = encodeUsageRecord(merged);
return plan;
}
// Foreign value from ANOTHER track: this instance is a cross-track copy (or was
// moved). Take a fresh identity; never overwrite the other's record.
plan.remint = true;
return plan;
}
std::vector<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive) {
std::vector<std::string> out;
std::unordered_set<std::string> seen;
// FAIL-SAFE NET: records exist but not one instance was identified live anywhere —
// indistinguishable from an identity-matcher failure, so protect EVERY record's
// paths rather than none (zero-identified must never degrade toward delete).
const bool protectAll = !records.empty() && !anyInstanceLive;
for (const UsageRecord& rec : records) {
const bool live = protectAll ||
(rec.trackGuid.empty()
? anyInstanceLive
: (liveTrackGuids.count(rec.trackGuid) != 0));
if (!live) continue;
for (const UsageHold& h : rec.holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second) out.push_back(h.relativePath);
}
}
return out;
}
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive) {
UsageFoldResult result;
std::vector<UsageRecord> records;
records.reserve(decoded.size());
for (const std::optional<UsageRecord>& rec : decoded) {
if (!rec) {
// A present-but-unreadable record: it may protect ANYTHING, so the prune
// must halt outright. Belt-and-braces: return the PROTECT-ALL set (all
// readable records' paths) so the fail-safe holds even under a future
// caller that forgets to check abortPrune before using heldPaths. The
// abort flag is still the authoritative signal; heldPaths is the
// maximum-protection fallback.
result.abortPrune = true;
// Collect EVERY path from EVERY readable record, bypassing the liveness
// filter entirely (on abort the protected set is unknowable, so every
// decoded hold must be included regardless of track-guid membership).
std::unordered_set<std::string> seen;
for (const std::optional<UsageRecord>& r : decoded) {
if (!r) continue;
for (const UsageHold& h : r->holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second)
result.heldPaths.push_back(h.relativePath);
}
}
return result;
}
records.push_back(*rec);
}
result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive);
return result;
}
std::string toUpperAscii(const std::string& s) {
std::string out = s;
for (char& c : out)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return out;
}
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
const std::string& nameUpper,
const std::string& outputNameUpper) {
if (identity.empty()) return false;
const std::string up = toUpperAscii(identity);
// Primary: the 32-hex class UID embedded in REAPER's fx_ident rendering. Not
// guaranteed on every platform/REAPER build (byte-order of the rendered FUID vs
// REAPER's hex is unverified on Windows COM layout), hence the two name nets below
// — and the protect-all fold above them (see usageHeldPaths).
if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true;
// The module filename base ("REASAMPLER_9000") — fx_ident carries the .vst3 module
// path, so this is the alternative that works in the common case (the display name
// "REASAMPLER 9000", space-separated, can never match the filename form).
if (!outputNameUpper.empty() && up.find(outputNameUpper) != std::string::npos)
return true;
// The factory display name — matches original_name / renamed-instance renderings.
// Beta-substring over-protect is deliberate (see the header note): stable needles
// are substrings of beta ones, widening protection only — never a delete.
return !nameUpper.empty() && up.find(nameUpper) != std::string::npos;
}
} // namespace reasampler
+253
View File
@@ -0,0 +1,253 @@
#pragma once
// sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count
// as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10
// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state
// key ("rsusage_<instanceGuid>", see ext_keys.h); the EXTENSION reads every usage record
// at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX
// instance, and folds the surviving paths into the prune's `referenced` set — so a file
// any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never
// delete it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO VST3, NO SWELL,
// NO vendor/ includes. Standard library only. The mirror of assignment_request (the
// other VST<->extension ext-state wire): the wire format AND the two safety-critical
// decisions (what to write on publish, which records count at prune time) live here so
// they are provable without a DAW. The shells only move strings.
//
// -- The data-ownership boundary (load-bearing) -------------------------------
//
// The INSTRUMENT writes usage keys; the EXTENSION reads them. This is the ONE sanctioned
// instrument->ext-state write (Daniel's ruling: "if that means the VST writes to the
// bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK
// invariant: the instrument publishes its OWN usage under its OWN per-instance key,
// and never touches banks/view/tail/assign or any other extension-owned key. The bridge
// enforces this structurally — its write entry point accepts only "rsusage_"-prefixed keys.
//
// -- THE SAFETY PROPERTY (overrides every other consideration) -----------------
//
// The un-prunable guarantee is a SAFETY property: every failure, ambiguity, or
// uncertainty in this seam must FAIL-SAFE toward PROTECT. Over-protection (prune skips a
// reclaimable file, or refuses to run at all) is an acceptable residual; under-protection
// (deleting a file an instance may still be playing) is a data-loss bug. Three fail-safe
// folds live in this pure module so they are provable without a DAW:
// * sibling-collision -> UNION, never clean-replace over a foreign writer (ownerNonce);
// * zero-identified -> records exist but NO instance was identified live -> protect
// ALL records' paths (an identity-matcher failure must never
// degrade toward delete);
// * unreadable record -> ABORT the prune entirely (foldUsageRecords.abortPrune — a
// record we cannot read may protect anything; halting deletes
// nothing). Residual: readReasamplerExtState returning nullopt
// for a >16 MB value is indistinguishable from "absent" at the
// publish site — that narrow case takes the fresh-write branch
// (not remint), noted here for completeness.
//
// -- Liveness (no stale-key false-protect, no false-delete) --------------------
//
// A usage record must protect exactly the captures of instances that still EXIST. Two
// rejected designs shape the rules below:
// * NO teardown clearing. The obvious "clear my key in terminate()" is WRONG here:
// REAPER destroys the plugin instance when an FX is set OFFLINE — including the
// extension's own Design View CPU-park (per-FX offline on inactive-mode tracks). A
// terminate-time clear would strip the record of an instance that still exists in
// the project, opening a prune-deletes-a-used-file window. Records are therefore
// never cleared by the instrument; staleness is resolved by the EXTENSION at read
// time against the live FX enumeration.
// * NO challenge/response. Instances only poll ext-state on the EDITOR's UI timer
// (pollBankSync); a closed-editor instance could never answer a prune-time
// challenge, and its holds would be false-deleted. Publishing is therefore EAGER
// (on load + on every play-set change via reloadInstrument), and liveness is
// decided extension-side.
//
// The liveness rule (usageHeldPaths): a record counts iff the track it was published
// from still exists AND that track still hosts at least one ReaSampler 9000 FX
// instance (offline FX included — chain enumeration is chunk-level, so a parked
// instance still protects its holds). A record whose track GUID could not be resolved
// at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the
// project — the fail-safe fallback. And the identity-failure net: when records exist
// but ZERO instances were identified live anywhere, EVERY record's paths are protected
// (see the safety property above — indistinguishable from a matcher failure, so it may
// never resolve toward delete). Residuals: a deleted instance whose track still hosts a
// sibling 9000 keeps its record alive, and a project whose instances were all deleted
// keeps its leftover records protecting until an instance is identified again — both
// false-PROTECT only, bounded, documented, accepted.
//
// -- Identity & the copy problem (planUsagePublish) -----------------------------
//
// The publishing key is a minted per-instance GUID persisted in ComponentState (v11).
// A persisted id is inherently COPYABLE (FX copy / track duplication clones component
// state byte-for-byte), so two live instances can wake up sharing one key. Worse, two
// same-track copies converge on byte-identical wires, so "existing == what I last
// wrote" is NOT a sound ownership test — a sibling's byte-identical write would pass
// it, and a later clean replace would silently drop the sibling's holds (the delete
// direction). TWO in-wire facts close this:
// * ownerNonce — a per-LIFETIME nonce minted fresh in memory each instance lifetime,
// NEVER persisted (a persisted nonce would clone with the state, recreating the
// ambiguity). Proves "exactly this incarnation wrote the key last".
// * unioned — a STICKY multi-writer poison flag. "I wrote the key last" does NOT
// imply "the key contains only my holds": after I union a sibling's holds under my
// own nonce, a later nonce-matching clean replace would drop them. So the first
// union sets unioned=true in the wire, and a unioned record REFUSES clean replace
// forever — every subsequent write is a union (holds only accumulate). Over-protect
// residual, accepted; a solo never-restarted instance keeps clean-replace
// semantics, and a remint starts a fresh un-poisoned key.
// The publish plan resolves every collision in the fail-safe direction:
// * existing ownerNonce == mine AND not unioned -> clean replace (sole writer,
// provably my content; holds the instance released genuinely drop).
// * same track with a foreign nonce, OR unioned -> UNION of holds, written with
// unioned=true (a same-track sibling, my own last-session record, or a
// multi-writer key; nothing may be dropped — over-protects, never under-protects).
// * foreign nonce, DIFFERENT track, not unioned-by-me -> RE-MINT (a cross-track copy
// or move; the newcomer takes a fresh identity and leaves the original's record
// untouched; a moved-away original's old record dies by the liveness rule).
#include <optional>
#include <string>
#include <unordered_set>
#include <vector>
namespace reasampler {
// One held capture: the bank sample id (attribution/debugging) + the project-relative
// WAV path (the prune-protection payload — compared by EXACT string against the prune
// core's `present` spelling, which both sides source from the same bank-blob spelling).
struct UsageHold {
std::string sampleId;
std::string relativePath;
bool operator==(const UsageHold& o) const {
return sampleId == o.sampleId && relativePath == o.relativePath;
}
};
// One instance's published usage: the REAPER track GUID it was hosted on at publish
// time ("{...}" canonical form; empty when the host context could not resolve one), the
// writing incarnation's per-LIFETIME ownerNonce (the exact "did I write this?" ownership
// discriminator — see the copy-problem note above; never persisted in ComponentState),
// the sticky multi-writer `unioned` poison flag (once true, clean replace is refused
// forever — see the note above), plus every capture it holds. The record is
// self-contained — the extension needs nothing from the instance beyond this value and
// the live FX enumeration.
struct UsageRecord {
std::string trackGuid;
std::string ownerNonce;
bool unioned = false;
std::vector<UsageHold> holds;
bool operator==(const UsageRecord& o) const {
return trackGuid == o.trackGuid && ownerNonce == o.ownerNonce &&
unioned == o.unioned && holds == o.holds;
}
};
// Encode a usage record to the wire string. Length-prefixed fields behind a magic tag
// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes
// in a GUID or path round-trip whole. Deterministic.
//
// FORMAT: "rsusage1" <len>':'<trackGuid> <len>':'<ownerNonce> <len>':'<unioned "0"|"1">
// <len>':'<holdCount-decimal> then per hold: <len>':'<sampleId> <len>':'<relativePath>
std::string encodeUsageRecord(const UsageRecord& rec);
// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed /
// truncated / trailing-garbage input (never UB, never a partial value). The prune scan
// treats an undecodable record as UNREADABLE and ABORTS (foldUsageRecords) — it must
// never proceed with protection it cannot read.
std::optional<UsageRecord> decodeUsageRecord(const std::string& wire);
// The publish decision computed BEFORE a write (see the identity note above).
// * remint — true when the existing key value belongs to a live foreign instance
// on another track: the caller must mint a fresh instance GUID and
// write under the NEW key, leaving the existing record untouched.
// * skipWrite — true when the write would change nothing that matters: byte-identical
// to the existing value (idle reload tick), or a union over an
// ALREADY-unioned record that adds no holds (the write would flip only
// the ownerNonce — redundant ext-state churn, skipped; a false->true
// unioned flip is never skipped, it is the multi-writer poison).
// * wire — the encoded value to write (mine, or the same-track union).
struct UsagePublishPlan {
bool remint = false;
bool skipWrite = false;
std::string wire;
};
// Decide what to write for `mine` given the key's current value. `mine.ownerNonce` is
// THIS lifetime's nonce; `mine.unioned` is ignored (the plan computes the written
// flag). Branches, in order:
// * existing absent/empty -> write mine (unioned=false — sole known writer).
// * existing undecodable -> REMINT (mine, unioned=false, under a fresh key)
// rather than overwriting the corrupt key: overwriting would clear the prune-side
// abort, leaving a same-key sibling's holds unprotected until it republishes.
// Leaving the corrupt key in place keeps the prune-side abort (foldUsageRecords)
// firing so no delete-ward window opens. The sibling writes its own decodable
// record on the next publish tick; the corrupt key is eventually evicted once no
// live instance references it. Narrow gap: a >16 MB value reads back as nullopt
// (indistinguishable from absent), so it takes the fresh-write branch rather than
// remint — both outcomes are safe; the gap is noted in the header's fail-safe list.
// * nonce match AND !unioned -> clean replace (sole writer, provably my content;
// released holds drop); skipWrite when
// byte-identical (idle reload tick).
// * same track OR unioned -> union(existing.holds, mine.holds), existing-first,
// de-duped, written with unioned=TRUE under my
// nonce — a sibling's holds are NEVER dropped. The
// false->true unioned flip is ALWAYS written (it is
// the poison that blocks the last writer's future
// clean replace); skipWrite only when the existing
// record is already unioned AND the union adds no
// holds (the write would change nonce only).
// * else (foreign, other track) -> remint = true, write mine (fresh un-poisoned key).
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
const UsageRecord& mine);
// The prune-side liveness fold: every project-relative path held by a LIVE instance,
// de-duped, in (record, hold) input order. A record counts iff
// * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still
// exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR
// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for
// a record published without a resolvable track context).
// FAIL-SAFE NET (the safety property): when `records` is non-empty and
// `anyInstanceLive` is false — records exist but NOT ONE instance was identified
// anywhere — EVERY record's paths are returned (protect-all). Zero identified with
// records present is indistinguishable from an identity-matcher failure, and a matcher
// failure must never resolve toward delete. (Residual: leftover records in a project
// whose instances were all genuinely deleted keep protecting — false-PROTECT only.)
// Holds with an empty relativePath are skipped (nothing to protect).
std::vector<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive);
// The prune-side entry fold over RAW read/decode results, one element per enumerated
// rsusage_* key: nullopt = the key was present but could not be read or decoded
// (oversized ext-state read, truncation, corruption). ANY nullopt sets abortPrune —
// the prune must HALT and delete nothing (an unreadable record may protect anything;
// proceeding with degraded protection is the delete direction). Otherwise delegates to
// usageHeldPaths (including its protect-all net).
struct UsageFoldResult {
bool abortPrune = false;
std::vector<std::string> heldPaths;
};
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive);
// FX-identity match for the live-instance enumeration (pure so the matcher itself is
// testable; the shell only supplies REAPER's identity strings). `identity` is the value
// of an FX's "fx_ident" or "original_name" named-config parm; the three needles are the
// UPPERCASED channel constants:
// * uidHexUpper — the 32-hex VST3 class UID (instrument_drop::vstClassIdHex),
// * nameUpper — the factory display name ("REASAMPLER 9000"),
// * outputNameUpper— the .vst3 module filename base ("REASAMPLER_9000") — the form
// fx_ident is guaranteed to embed (it carries the module path),
// which the space-separated display name can never match.
// Substring, case-insensitive. NOTE the deliberate beta-substring over-protect: the
// stable needles are substrings of the beta ones ("REASAMPLER 9000" ⊂ "REASAMPLER 9000
// BETA", "REASAMPLER_9000" ⊂ "REASAMPLER_9000_BETA"), so a stable extension scanning a
// project with beta instances matches them too — a WIDER protected set only (fail-safe;
// it can never cause a delete).
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
const std::string& nameUpper, const std::string& outputNameUpper);
// ASCII-only uppercase (shared by the matcher and the shell's needle preparation).
std::string toUpperAscii(const std::string& s);
} // namespace reasampler
+275
View File
@@ -0,0 +1,275 @@
// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune
// protection; every decision is in the pure sample_usage module, this TU only reads.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272)
// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591)
// * CountTracks / GetTrack / GetMasterTrack (track scan)
// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570)
// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377)
// * CountMediaItems / GetMediaItem (~423/1964)
// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029)
// * GetMediaItemTrack(MediaItem*) (~2133)
// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774)
// * guidToString (via track_guid::guidString)
#include "usage_scan.h"
#include <cstdlib>
#include <functional>
#include <optional>
#include <string>
#include <unordered_set>
#include <vector>
#include "app_version.h" // vstPluginName / vstOutputName (channel name needles)
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
#include "sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions)
#include "track_guid.h" // guidString — the ONE canonical GUID key formatter
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMasterTrack
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetRecCount
#define REAPERAPI_WANT_TrackFX_GetNamedConfigParm
#define REAPERAPI_WANT_CountMediaItems
#define REAPERAPI_WANT_GetMediaItem
#define REAPERAPI_WANT_CountTakes
#define REAPERAPI_WANT_GetMediaItemTake
#define REAPERAPI_WANT_GetMediaItemTrack
#define REAPERAPI_WANT_TakeFX_GetCount
#define REAPERAPI_WANT_TakeFX_GetNamedConfigParm
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// The three UPPERCASED channel needles identityMatches (pure, sample_usage) checks
// every FX identity string against. One instance drives the whole scan.
struct FxIdentityNeedles {
std::string uidHexUpper; // 32-hex VST3 class UID (may not appear on all builds)
std::string outputNameUpper; // "REASAMPLER_9000" — the .vst3 filename base fx_ident embeds
std::string nameUpper; // "REASAMPLER 9000" — factory display name
};
// A named-config-parm getter abstracted over the FX-chain kind: track FX and take FX
// share the identical identity walk (fx_ident + original_name + container recursion),
// differing only in which REAPER getter reads the parm.
using FxParmGetter =
std::function<std::string(int fxId, const char* parm)>;
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
// ReaSampler 9000. BOTH fx_ident and original_name are checked on BOTH chain kinds (a
// renamed instance may keep its original_name; fx_ident carries the module path — the
// primary identification net is the module filename base via fx_ident, which holds even
// after a user renames the FX instance). Containers are walked via
// the documented container_count / container_item.X addressing (v7.06+); on a chain
// kind or REAPER version without containers the parm read returns empty and recursion
// is a no-op. `depth` bounds pathological nesting. fx_ident is queried per FX — chain
// enumeration is chunk-level, so OFFLINE instances match too (load-bearing: a
// Design-View-parked instance must keep protecting its holds).
bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId,
const FxIdentityNeedles& id, int depth) {
if (identityMatches(parm(fxId, "fx_ident"), id.uidHexUpper, id.nameUpper,
id.outputNameUpper) ||
identityMatches(parm(fxId, "original_name"), id.uidHexUpper, id.nameUpper,
id.outputNameUpper))
return true;
const std::string countStr = parm(fxId, "container_count");
if (countStr.empty()) return false; // not a container; no children to miss
if (depth <= 0) {
// This node IS a container but we have exhausted our descent budget. We cannot
// prove that none of its children is a ReaSampler 9000 instance — treat the
// incomplete walk as a positive identification (the protect direction). This is
// defense-in-depth: kMaxContainerDepth = 32 should prevent reaching this branch
// in any real project, but if it IS reached the fail-safe fires rather than
// silently missing a live nested instance.
return true;
}
const int n = std::atoi(countStr.c_str());
for (int k = 0; k < n; ++k) {
const std::string item =
parm(fxId, ("container_item." + std::to_string(k)).c_str());
if (item.empty()) continue;
const int childId = std::atoi(item.c_str());
if (childId <= 0) continue;
if (fxSubtreeHasInstance(parm, childId, id, depth - 1)) return true;
}
return false;
}
// Raised from 8 to 32 (defense in depth against truncation). Real-world FX containers
// are typically 24 levels deep; 32 is unreachable in practice while remaining finite.
// Even at 32, the truncation→protect-all guard below is the primary protection.
constexpr int kMaxContainerDepth = 32;
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
char buf[2048] = {0};
if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast<int>(sizeof(buf))))
return {};
return std::string(buf);
}
std::string takeFxParm(MediaItem_Take* take, int fxId, const char* parm) {
char buf[2048] = {0};
if (!TakeFX_GetNamedConfigParm(take, fxId, parm, buf, static_cast<int>(sizeof(buf))))
return {};
return std::string(buf);
}
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
// (index | 0x1000000), containers recursively.
bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) {
const FxParmGetter parm = [tr](int fxId, const char* p) {
return trackFxParm(tr, fxId, p);
};
const int n = TrackFX_GetCount(tr);
for (int i = 0; i < n; ++i) {
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
}
const int rec = TrackFX_GetRecCount(tr);
for (int i = 0; i < rec; ++i) {
if (fxSubtreeHasInstance(parm, 0x1000000 + i, id, kMaxContainerDepth))
return true;
}
return false;
}
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a
// non-active take's instance still exists in the project and reactivates with the
// take). The SAME identity walk as the track path: fx_ident + original_name + container
// recursion (an unrecognized exotic still lands in the pure protect-all net — records
// with zero identified instances protect everything rather than nothing).
bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) {
const int takes = CountTakes(item);
for (int t = 0; t < takes; ++t) {
MediaItem_Take* take = GetMediaItemTake(item, t);
if (!take) continue;
const FxParmGetter parm = [take](int fxId, const char* p) {
return takeFxParm(take, fxId, p);
};
const int n = TakeFX_GetCount(take);
for (int i = 0; i < n; ++i) {
if (fxSubtreeHasInstance(parm, i, id, kMaxContainerDepth)) return true;
}
}
return false;
}
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
// the hold count, so a fixed buffer risks a truncated decode. Returns nullopt when the
// key cannot be read WHOLE — absent-after-enumeration (rv <= 0) or pathologically large
// (> 16 MB give-up). The caller only queries keys the enumeration just listed, so a
// nullopt here is a PRESENT-BUT-UNREADABLE record: it folds to abortPrune (fail-safe —
// silently reduced protection is the delete direction).
std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key) {
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
if (rv <= 0) return std::nullopt;
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) return s;
// else possibly truncated -> grow and retry
}
return std::nullopt; // > 16 MB — unreadable whole, never "absent"
}
} // namespace
UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
UsageScanResult result;
// 1. Enumerate the rsusage_* keys and read+decode each record. Key names first
// (values via the growing reader — EnumProjExtState's fixed val buffer could
// truncate a large record). A nullopt element = present-but-unreadable/
// undecodable -> the pure fold ABORTS the prune.
std::vector<std::string> usageKeys;
{
const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N
char keyBuf[256];
for (int idx = 0;; ++idx) {
keyBuf[0] = '\0';
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
break;
const std::string key(keyBuf);
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
}
}
if (usageKeys.empty()) return result; // no instance ever published — skip the scan
std::vector<std::optional<UsageRecord>> decoded;
decoded.reserve(usageKeys.size());
for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) {
const std::string& key = usageKeys[ki];
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
if (!value) {
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
result.offendingKeys.push_back(key);
continue;
}
const std::optional<UsageRecord> rec = decodeUsageRecord(*value);
if (!rec) result.offendingKeys.push_back(key);
decoded.push_back(rec); // undecodable nullopt -> abort
}
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives
// every match; a track needs only ONE instance to keep all its records live.
FxIdentityNeedles id;
id.uidHexUpper = toUpperAscii(vstClassIdHex());
id.outputNameUpper = toUpperAscii(vstOutputName());
id.nameUpper = toUpperAscii(vstPluginName());
std::unordered_set<std::string> liveTrackGuids;
bool anyLive = false;
if (MediaTrack* master = GetMasterTrack(proj)) {
if (trackHasInstance(master, id)) {
liveTrackGuids.insert(guidString(master));
anyLive = true;
}
}
const int trackCount = CountTracks(proj);
for (int i = 0; i < trackCount; ++i) {
MediaTrack* tr = GetTrack(proj, i);
if (!tr) continue;
if (trackHasInstance(tr, id)) {
liveTrackGuids.insert(guidString(tr));
anyLive = true;
}
}
// Take-FX instances: attributed to the owning track (the VST-side getReaperParent(1)
// resolves the same track), and they set anyLive for the empty-guid fallback.
const int itemCount = CountMediaItems(proj);
for (int i = 0; i < itemCount; ++i) {
MediaItem* item = GetMediaItem(proj, i);
if (!item) continue;
if (itemHasInstance(item, id)) {
if (MediaTrack* tr = GetMediaItemTrack(item)) {
liveTrackGuids.insert(guidString(tr));
}
anyLive = true;
}
}
// 3. The pure fold decides: abort on any unreadable record; protect-all when zero
// instances were identified; otherwise the per-record liveness rule.
const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive);
result.abortPrune = fold.abortPrune;
result.heldPaths = fold.heldPaths;
// offendingKeys already populated above (unreadable + undecodable entries);
// clear it on success so callers see it only when abortPrune is set.
if (!result.abortPrune) result.offendingKeys.clear();
return result;
}
} // namespace reasampler
+59
View File
@@ -0,0 +1,59 @@
#pragma once
// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for
// the pure core, the fail-safe folds, and the full design note). At prune-scan time it
// answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler
// 9000 instance — or must the prune ABORT because a usage record could not be read?
//
// Three reads, no writes (the prune scan's READ-ONLY contract holds):
// 1. Enumerate every "rsusage_<guid>" key in the "reasampler" ext-state namespace
// (EnumProjExtState) and decode each record (sample_usage wire). A key that is
// present but cannot be read or decoded folds to abortPrune (fail-safe: an
// unreadable record may protect anything, so the prune halts and deletes nothing).
// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks
// (master included), normal + record/input chains, FX containers recursively, and
// take FX (same container recursion) — matching each FX's fx_ident AND
// original_name via the pure sample_usage::identityMatches (class-UID hex, module
// filename base, display name; see the matcher note there).
// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords /
// usageHeldPaths): a record counts iff its publishing track still hosts >= 1
// instance; a record with no track context counts while any instance exists; and
// when records exist but ZERO instances were identified anywhere, EVERY record's
// paths are protected (the identity-failure net — a matcher failure must never
// degrade toward delete).
//
// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so
// `referenced` = bank references live-instance holds — a held capture can never be
// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it.
// abortPrune propagates through PruneScan/PruneReport to the action, which halts.
//
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The
// header stays REAPER-free (`proj` is the opaque ReaProject* the persist seam already
// passes around as void*).
#include <string>
#include <vector>
namespace reasampler {
// The scan outcome. When abortPrune is true a present rsusage_* record could not be
// read or decoded — the caller MUST halt the prune (delete nothing). offendingKeys
// names the exact "rsusage_<guid>" keys that triggered the abort so the action can
// print them for operator recovery (clear via ReaScript:
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
// for each offending key). heldPaths on abort is the protect-all set (every readable
// record's paths) — meaningful only as a belt-and-braces fallback; the abort flag is
// the authoritative signal. Otherwise heldPaths is every project-relative path held by
// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common
// no-records case (the FX enumeration is skipped entirely).
struct UsageScanResult {
bool abortPrune = false;
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
std::vector<std::string> heldPaths;
};
// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project
// mutation.
UsageScanResult liveInstanceHeldPaths(void* proj);
} // namespace reasampler
+48
View File
@@ -42,6 +42,9 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
getProjExtState_ = nullptr;
enumProjExtState_ = nullptr;
enumProjects_ = nullptr;
setProjExtState_ = nullptr;
getTrackGuid_ = nullptr;
guidToString_ = nullptr;
hostApp_ = nullptr;
if (!context) return false;
@@ -62,6 +65,15 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
// persist.cpp uses, so the instrument derives the project directory identically.
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
reaper->getReaperApi("EnumProjects"));
// pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the
// usage record stamps. All degrade to null gracefully — an old REAPER just never
// publishes usage (the extension then protects by bank references only).
setProjExtState_ = reinterpret_cast<SetProjExtStateFn>(
reaper->getReaperApi("SetProjExtState"));
getTrackGuid_ = reinterpret_cast<GetTrackGuidFn>(
reaper->getReaperApi("GetTrackGUID"));
guidToString_ = reinterpret_cast<GuidToStringFn>(
reaper->getReaperApi("guidToString"));
return getProjExtState_ != nullptr;
}
@@ -97,6 +109,42 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
if (!setProjExtState_ || !hostApp_) return false;
// STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else.
// A non-"rsusage_" key is a programming error upstream — refuse rather than widen
// the instrument's write surface (banks/view/tail/assign stay extension-owned).
const std::string prefix = kProjExtUsageKeyPrefix;
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns "the size of the state for this extname" (SDK ~6288) —
// after storing our non-empty value the namespace state is necessarily > 0, so a
// <= 0 return means the write did not land. Reported to the caller (the publish
// path retries on the next reload tick); a silently-dropped record would leave the
// instance's holds unprotected.
const int rv =
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
// Deliberately NO MarkProjectDirty: a usage change always accompanies a component-
// state change that already dirties the project; an idempotent load-time republish
// must not flag an untouched project as modified.
return rv > 0;
}
std::string ReaperBridge::currentTrackGuid() {
if (!hostApp_ || !getTrackGuid_ || !guidToString_) return {};
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* track = reaper->getReaperParent(1); // the hosting MediaTrack*
if (!track) return {}; // no track context (unusual host state)
void* guid = getTrackGuid_(track);
if (!guid) return {};
char buf[64] = {0}; // guidToString's documented destNeed64 contract
guidToString_(guid, buf);
return std::string(buf);
}
std::string ReaperBridge::activeProjectDir() {
if (!enumProjects_) return {};
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
+31
View File
@@ -57,6 +57,25 @@ public:
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
std::string activeProjectDir();
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
// ext-state write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's
// usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is
// enforced structurally: this module can publish the instance's own usage and
// nothing else (banks/view/tail/assign remain unwritable from the instrument).
// Returns true iff written (the SetProjExtState return is checked — a dropped
// write must not silently claim protection). NOT RT-safe (calls into REAPER) —
// publish sites are the off-audio-thread reload path only. Deliberately does NOT
// mark the project dirty: a usage change always rides a component-state change
// that already does.
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
// The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance
// (getReaperParent(1) -> GetTrackGUID -> guidToString — the same rendering as the
// extension's track_guid::guidString, so usage records and the extension's live-FX
// enumeration compare byte-equal). Empty when unconnected or no track context (the
// usage reader then falls back to any-instance liveness — fail-safe). Not RT-safe.
std::string currentTrackGuid();
private:
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
// verified against reaper_plugin_functions.h.
@@ -69,11 +88,23 @@ private:
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
// and reads the .rpp path from the out-buffer exactly as persist.cpp does.
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
// SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by
// writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
const char* value);
// GetTrackGUID(MediaTrack*) -> GUID* (SDK ~3562) + guidToString(const GUID*, char*
// destNeed64) (SDK ~3848). Both held as opaque-pointer signatures so the header
// stays SDK-type-free; the GUID* is passed straight through, never dereferenced here.
using GetTrackGuidFn = void* (*)(void* tr);
using GuidToStringFn = void (*)(const void* g, char* destNeed64);
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
GetProjExtStateFn getProjExtState_ = nullptr;
EnumProjExtStateFn enumProjExtState_ = nullptr;
EnumProjectsFn enumProjects_ = nullptr;
SetProjExtStateFn setProjExtState_ = nullptr;
GetTrackGuidFn getTrackGuid_ = nullptr;
GuidToStringFn guidToString_ = nullptr;
};
} // namespace reasampler::vst
+84
View File
@@ -4,8 +4,10 @@
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <optional>
#include <random>
#include <utility>
#include <vector>
@@ -23,6 +25,7 @@
#include "reasampler_editor.h"
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
#include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained)
#include "sample_usage.h" // pS-usage publish plan + wire (prune-protection seam)
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
using namespace Steinberg;
@@ -47,6 +50,23 @@ constexpr std::size_t kPreserveVoiceCap = 8;
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
// strength) is the requirement: two instances sharing a key is the copy-collision
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
// vanishingly unlikely. Off-thread only.
std::string mintUsageInstanceGuid() {
std::random_device rd;
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
std::uniform_int_distribution<std::uint64_t> dist;
char buf[33] = {0};
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
static_cast<unsigned long long>(dist(gen)),
static_cast<unsigned long long>(dist(gen)));
return std::string(buf);
}
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
// any failure — the caller treats an unreadable WAV as "nothing to play".
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
@@ -258,6 +278,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
std::lock_guard<std::mutex> lock(refsMutex_);
sampleRefs_ = cs.sampleRefs;
}
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
// for the copy-collision analysis (the fresh nonce means this incarnation can never
// be mistaken for the previous one's writes — or for a copy-sibling's).
{
std::lock_guard<std::mutex> lock(usageMutex_);
instanceGuid_ = cs.instanceGuid;
usageNonce_.clear();
}
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
// not carry over (#A — the legacy lift gets one fresh run per restored state).
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
@@ -302,6 +331,12 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
state_out.sampleRefs = sampleRefs();
retainRefs(state_out.sampleRefs,
referencedSampleIds(state_out.selectionId, state_out.map));
// pS-usage: persist the publish identity (v11) so the instance's usage key is
// stable across sessions (records do not proliferate per reopen).
{
std::lock_guard<std::mutex> lock(usageMutex_);
state_out.instanceGuid = instanceGuid_;
}
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
@@ -601,9 +636,58 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
// pointer is re-owned by the graveyard.
publishBuiltLocked(std::move(built));
// 5. pS-usage: publish this instance's held captures so the extension's prune can
// never reclaim them (see publishUsage). AFTER the instrument swap, still off the
// audio thread and under reloadMutex_. Publishes regardless of decode success:
// the holds are the refs the instance RETAINS (its play-set), not what decoded —
// a transiently unreadable WAV must stay protected.
publishUsage(refs, ids);
return resolvedId;
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
const std::vector<std::string>& ids) {
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
UsageRecord mine;
mine.trackGuid = bridge_.currentTrackGuid();
for (const std::string& id : ids) {
if (const SelectedSample* ref = findRef(refs, id)) {
if (!ref->relativePath.empty()) {
mine.holds.push_back(UsageHold{id, ref->relativePath});
}
}
}
std::lock_guard<std::mutex> lock(usageMutex_);
// A never-published instance with nothing held writes nothing — no key litter for
// fresh/empty instances. Once an identity exists, empties DO publish (they release
// holds the prune would otherwise keep protecting).
if (instanceGuid_.empty() && mine.holds.empty()) return;
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
// siblings always union and never clean-replace over each other's held paths.
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
mine.ownerNonce = usageNonce_;
const std::optional<std::string> existing =
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
const UsagePublishPlan plan = planUsagePublish(existing, mine);
if (plan.remint) {
// This state was cloned onto another track (FX copy / track duplication): take a
// fresh identity and leave the original's record untouched. The abandoned old
// identity's record dies by the extension's liveness rule when its track no
// longer hosts an instance. getState persists the new guid on the next save.
instanceGuid_ = mintUsageInstanceGuid();
} else if (plan.skipWrite) {
return; // idle tick, or a union that adds nothing — no ext-state churn
}
bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire);
}
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
+29
View File
@@ -302,6 +302,19 @@ private:
// safety-critical swap dance (see the handoff proof below).
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// pS-usage: publish this instance's held captures to its per-instance ext-state key
// ("rsusage_<instanceGuid>") so the extension's prune counts them as referenced — a
// capture a live instance holds can never be pruned. Called at the end of every
// reloadInstrument (the ONE choke point every play-set change funnels through:
// selection change, zone edits, assignment consume, bank refresh, setState load), so
// publishing is EAGER and needs no timer — a closed-editor instance's record is
// already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge
// calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects
// this state was cloned onto another track (FX copy / track duplication). Idempotent
// on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own
// snapshot — the refs table and the id set the instance currently plays.
void publishUsage(const SampleRefs& refs, const std::vector<std::string>& ids);
ReaperBridge bridge_;
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
@@ -368,6 +381,22 @@ private:
std::mutex refsMutex_;
SampleRefs sampleRefs_;
// pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is
// the persisted per-instance identity (ComponentState v11; empty until first
// publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried
// INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact
// ownership discriminator between "my own write" (clean replace) and "a foreign
// writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with
// the state on FX copy, and two same-track copies converging on byte-identical
// wires is exactly the ambiguity the nonce exists to break (a wire-equality
// discriminator let sibling A clean-replace over sibling B's still-held paths —
// the delete direction). Minted lazily on first publish; cleared on setState (a
// restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under
// reloadMutex_ but getState/setState do not).
std::mutex usageMutex_;
std::string instanceGuid_;
std::string usageNonce_;
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
// on the audio thread — process renders against the host's negotiated output channel count.
+13
View File
@@ -759,6 +759,11 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
}
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
putU32le(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
@@ -835,6 +840,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
@@ -924,6 +930,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
if (!r.ok) return out;
}
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
// EMPTY default holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen);
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
}
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
+22 -5
View File
@@ -540,10 +540,13 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then a 4-byte LE
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The refs table is the ONLY envelope-v10 addition over v9 — the envelope grew a field,
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
// only v10 addition over v9 — the envelope grows a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
@@ -554,8 +557,10 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path):
// * v10 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, selectionId, zones} direct.
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
@@ -610,9 +615,21 @@ struct ComponentState {
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 10;
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
+31
View File
@@ -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);
+75
View File
@@ -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();
+575
View File
@@ -0,0 +1,575 @@
// 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 SAFETY property: every failure, ambiguity, or
// uncertainty must FAIL-SAFE toward PROTECT (over-protection acceptable;
// under-protection = deleting a maybe-used file is a data-loss bug).
//
// Covers: wire round-trip (nonce + unioned flag, empty / adversarial bytes),
// malformed -> nullopt, the publish plan's branches (fresh / clean replace + skip /
// sibling union with the sticky poison flag / cross-track re-mint / undecodable remint),
// the SAME-TRACK SIBLING repro (the review's 🔴#1 — byte-identical wire convergence
// must never let one sibling clean-replace the other's still-held paths, including one
// write later via the poison flag), the liveness fold (live, dead-track, empty-guid
// fallback, de-dup), the ZERO-IDENTIFIED protect-all net (🔴#2 — an identity-matcher
// failure must protect everything, not nothing), the UNREADABLE-record abort
// (foldUsageRecords.abortPrune — prune halts, deletes nothing), the UNDECODABLE-EXISTING
// REMINT (corrupt key left in place — prune-side abort keeps firing while sibling holds
// unprotected), the ABORT→PROTECT-ALL belt-and-braces (foldUsageRecords.heldPaths is
// the full protect-all set even when abortPrune is set), the TRUNCATED-WALK→PROTECT-ALL
// proof (FX walk misses a nested instance → anyLive=false → usageHeldPaths protects
// every record — the pure side of the depth-cap + depth-exhaustion-is-container fix),
// the pure identity matcher (UID hex / module filename base / display name, beta
// over-protect), and the composed pruneOrphans exclusion proof.
#include "../src/sample_usage.h"
#include <cstdio>
#include <optional>
#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, const std::string& nonce,
std::vector<UsageHold> holds, bool unioned = false) {
UsageRecord r;
r.trackGuid = trackGuid;
r.ownerNonce = nonce;
r.unioned = unioned;
r.holds = std::move(holds);
return r;
}
bool holdsContainPath(const std::vector<UsageHold>& holds, const std::string& path) {
for (const UsageHold& h : holds)
if (h.relativePath == path) return true;
return false;
}
} // namespace
// --- wire round-trip ----------------------------------------------------------
static void testRoundTrip() {
const UsageRecord rec = makeRecord(
"{12345678-1234-1234-1234-1234567890AB}", "aabbccdd00112233",
{UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"},
UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}},
/*unioned=*/true);
const std::string wire = encodeUsageRecord(rec);
auto back = decodeUsageRecord(wire);
CHECK(back.has_value());
CHECK(*back == rec);
CHECK(back->unioned);
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->ownerNonce.empty());
CHECK(!back->unioned);
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", "1:0",
{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:0:1:01:0").has_value()); // wrong magic
// A non-"0"/"1" unioned field is corruption -> reject whole. Hand-built wire:
// magic + trackGuid "" + nonce "" + unioned "2" + count 0.
CHECK(!decodeUsageRecord("rsusage1" "0:" "0:" "1:2" "1:0").has_value());
// Truncated mid-holds: claims 2 holds, carries 1. The count field for one hold is
// the "1:1" that FOLLOWS the unioned field "1:0" (nonce chosen digit-free so the
// needle is unambiguous).
UsageRecord one = makeRecord("{G}", "nonce", {UsageHold{"a", "p.wav"}});
std::string wire = encodeUsageRecord(one);
const std::string needle = "1:0" "1:1"; // unioned=0 then count=1
const std::size_t pos = wire.find(needle);
CHECK(pos != std::string::npos);
wire[pos + 5] = '2'; // count "1:1" -> "1: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}", "NA", {UsageHold{"a", "p.wav"}});
const UsagePublishPlan plan = planUsagePublish(std::nullopt, mine);
CHECK(!plan.remint);
CHECK(!plan.skipWrite);
auto back = decodeUsageRecord(plan.wire);
CHECK(back.has_value());
CHECK(back->ownerNonce == "NA");
CHECK(!back->unioned); // sole known writer -> un-poisoned
}
static void testPlanCleanReplaceAndSkip() {
// The key holds this incarnation's own un-poisoned write (nonce match, !unioned):
// the sole-writer path. Clean replace; released holds drop.
const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"},
UsageHold{"b", "pb.wav"}});
const std::string prevWire = encodeUsageRecord(prev);
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan plan = planUsagePublish(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
CHECK(!back->unioned);
// Unchanged play-set -> byte-identical write -> skip (idle reload tick).
const UsagePublishPlan idle = planUsagePublish(prevWire, prev);
CHECK(idle.skipWrite);
CHECK(!idle.remint);
}
// The review's 🔴#1 repro. Two same-track FX copies share a key and converge on
// byte-identical hold sets; the OLD wire-equality discriminator let sibling A
// clean-replace over B's still-held path. With the in-wire per-lifetime nonce +
// sticky unioned poison, every same-track collision unions and NO write of A's can
// ever drop B's holds — including A's SECOND write after it re-owns the key.
static void testSiblingCollisionNeverDropsHolds() {
// A (lifetime nonce NA) publishes {pa}.
const UsageRecord aFirst = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan planA1 = planUsagePublish(std::nullopt, aFirst);
CHECK(!planA1.remint && !planA1.skipWrite);
// B (lifetime nonce NB, same track, SAME hold set — the byte-identical
// convergence) publishes {pa}: foreign nonce, same track -> UNION, and the
// false->true poison flip is WRITTEN (never skipped), marking the key multi-writer.
const UsageRecord bSame = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan planB = planUsagePublish(planA1.wire, bSame);
CHECK(!planB.remint);
CHECK(!planB.skipWrite); // the poison flip must land in ext-state
auto bBack = decodeUsageRecord(planB.wire);
CHECK(bBack.has_value());
CHECK(bBack->unioned);
CHECK(holdsContainPath(bBack->holds, "pa.wav"));
// A changes its selection to {pc} (releases pa from ITS play-set — but B still
// plays pa). Foreign nonce (NB) -> union: pa is RETAINED. Pre-fix this was the
// clean-replace that dropped B's hold -> prune could delete B's playing file.
const UsageRecord aSecond = makeRecord("{T1}", "NA", {UsageHold{"c", "pc.wav"}});
const UsagePublishPlan planA2 = planUsagePublish(planB.wire, aSecond);
CHECK(!planA2.remint);
auto a2Back = decodeUsageRecord(planA2.wire);
CHECK(a2Back.has_value());
CHECK(holdsContainPath(a2Back->holds, "pa.wav")); // B's hold survives A's write
CHECK(holdsContainPath(a2Back->holds, "pc.wav"));
CHECK(a2Back->unioned);
// A writes AGAIN (selection {pd}) — now the key carries A's OWN nonce (NA). A bare
// nonce discriminator would clean-replace here and drop pa one step late; the
// sticky unioned poison forces union forever. pa STILL survives.
const UsageRecord aThird = makeRecord("{T1}", "NA", {UsageHold{"d", "pd.wav"}});
const UsagePublishPlan planA3 = planUsagePublish(planA2.wire, aThird);
CHECK(!planA3.remint);
auto a3Back = decodeUsageRecord(planA3.wire);
CHECK(a3Back.has_value());
CHECK(holdsContainPath(a3Back->holds, "pa.wav")); // the poison-flag guarantee
CHECK(a3Back->unioned);
}
static void testPlanUnionSkipOnlyWhenAlreadyPoisoned() {
// Union over an ALREADY-unioned record that adds no holds -> skip (the write would
// flip only the nonce — redundant churn; the protection is already in place).
const UsageRecord poisoned = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}},
/*unioned=*/true);
const UsageRecord mineSubset = makeRecord("{T1}", "NB", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan plan =
planUsagePublish(encodeUsageRecord(poisoned), mineSubset);
CHECK(!plan.remint);
CHECK(plan.skipWrite);
// But a union that ADDS a hold must write even when already poisoned.
const UsageRecord mineNew = makeRecord("{T1}", "NB", {UsageHold{"b", "pb.wav"}});
const UsagePublishPlan plan2 =
planUsagePublish(encodeUsageRecord(poisoned), mineNew);
CHECK(!plan2.skipWrite);
auto back = decodeUsageRecord(plan2.wire);
CHECK(back.has_value());
CHECK(back->holds.size() == 2);
CHECK(back->unioned);
}
static void testPlanEmptyNonceNeverClaimsOwnership() {
// A record written with an empty nonce (defensive: publisher failed to mint) can
// never be claimed via empty==empty — the same-track path must UNION, not replace.
const UsageRecord theirs = makeRecord("{T1}", "", {UsageHold{"a", "pa.wav"}});
const UsageRecord mine = makeRecord("{T1}", "", {UsageHold{"b", "pb.wav"}});
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(theirs), mine);
CHECK(!plan.remint);
auto back = decodeUsageRecord(plan.wire);
CHECK(back.has_value());
CHECK(holdsContainPath(back->holds, "pa.wav")); // never dropped
CHECK(back->unioned);
}
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}", "NA", {UsageHold{"a", "pa.wav"}});
const UsageRecord mine = makeRecord("{T2}", "NB", {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->unioned); // written under the NEW key — fresh, un-poisoned
}
static void testPlanOwnRecordAfterTrackMove() {
// My own un-poisoned record, but the instance moved tracks THIS lifetime (nonce
// matches, track differs): still mine — clean replace with the new track guid, NOT
// a remint (the key stays stable; no record proliferation on a track move).
const UsageRecord prev = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
const UsageRecord mine = makeRecord("{T2}", "NA", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan plan = planUsagePublish(encodeUsageRecord(prev), mine);
CHECK(!plan.remint);
auto back = decodeUsageRecord(plan.wire);
CHECK(back.has_value());
CHECK(back->trackGuid == "{T2}");
}
static void testPlanUndecodableExistingRemints() {
// An undecodable existing value under MY key must REMINT (not overwrite). Overwriting
// would clear the prune-side abort while a same-key sibling B's holds are unprotected
// until B republishes. Leaving the corrupt key in place keeps the prune-side abort
// (foldUsageRecords.abortPrune) firing so no delete-ward window opens.
const UsageRecord mine = makeRecord("{T1}", "NA", {UsageHold{"a", "pa.wav"}});
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), mine);
CHECK(plan.remint); // fresh key — leave the corrupt key untouched
CHECK(!plan.skipWrite);
// wire carries mine (to be written under the NEW key by the caller)
auto back = decodeUsageRecord(plan.wire);
CHECK(back.has_value());
CHECK(back->holds.size() == 1);
CHECK(!back->unioned); // fresh key, sole writer — un-poisoned
}
// --- liveness fold ---------------------------------------------------------------
static void testHeldPathsLiveness() {
const std::vector<UsageRecord> records = {
makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}}),
makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}}), // deleted track
makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}), // no track context
};
const std::unordered_set<std::string> live = {"{LIVE}"};
// Live-track record counts; dead-track record is EXCLUDED (stale-record cleanup —
// possible ONLY because at least one instance was positively identified, so the
// matcher demonstrably works in this project); 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");
}
// The review's 🔴#2 repro: records exist but ZERO instances were identified live
// (either every instance was genuinely deleted, or — indistinguishable — the identity
// matcher failed on every FX). The old fold dropped every record -> all held captures
// became prunable. The fail-safe net protects ALL records' paths instead.
static void testZeroIdentifiedProtectsAll() {
const std::vector<UsageRecord> records = {
makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}),
makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}),
makeRecord("", "N3", {UsageHold{"c", "pc.wav"}}),
};
const std::vector<std::string> all =
usageHeldPaths(records, std::unordered_set<std::string>{}, false);
CHECK(all.size() == 3); // EVERY path protected — never zero
CHECK(all[0] == "pa.wav");
CHECK(all[1] == "pb.wav");
CHECK(all[2] == "pc.wav");
// No records at all -> nothing to protect (the common no-instances case).
CHECK(usageHeldPaths({}, std::unordered_set<std::string>{}, false).empty());
}
static void testHeldPathsDedupAndEmptyPathSkip() {
const std::vector<UsageRecord> records = {
makeRecord("{T}", "N1", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
makeRecord("{T}", "N2", {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");
}
// A take-FX-hosted instance: the shell attributes it to the ITEM'S OWNING TRACK (the
// same guid the VST-side getReaperParent(1) publishes), so at the pure layer its record
// folds exactly like a track-FX one. This is the pure half of the take-FX guarantee;
// the enumeration itself (TakeFX_* walk, fx_ident + original_name) is shell code.
static void testTakeFxAttributedRecordIsProtected() {
const std::vector<UsageRecord> records = {
makeRecord("{ITEM-TRACK}", "N1", {UsageHold{"a", "take-held.wav"}}),
};
const std::unordered_set<std::string> live = {"{ITEM-TRACK}"}; // set via item scan
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
CHECK(paths.size() == 1);
CHECK(paths[0] == "take-held.wav");
}
// --- the unreadable-record abort (foldUsageRecords) --------------------------------
// A present-but-unreadable/undecodable rsusage_* record must ABORT the prune (halt,
// delete nothing) — silently reduced protection is the delete direction.
static void testUnreadableRecordAbortsPrune() {
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // one unreadable record among readable ones
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(fold.abortPrune);
// All readable -> no abort, normal liveness fold.
std::vector<std::optional<UsageRecord>> ok;
ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
const UsageFoldResult okFold =
foldUsageRecords(ok, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(!okFold.abortPrune);
CHECK(okFold.heldPaths.size() == 1);
CHECK(okFold.heldPaths[0] == "pa.wav");
// Empty input (no records enumerated) -> empty, no abort.
const UsageFoldResult empty =
foldUsageRecords({}, std::unordered_set<std::string>{}, false);
CHECK(!empty.abortPrune);
CHECK(empty.heldPaths.empty());
// Readable records + zero identified -> the protect-all net applies through the
// fold too (belt and braces with the abort).
std::vector<std::optional<UsageRecord>> unmatched;
unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}));
const UsageFoldResult net =
foldUsageRecords(unmatched, std::unordered_set<std::string>{}, false);
CHECK(!net.abortPrune);
CHECK(net.heldPaths.size() == 1);
}
// --- abort returns the protect-all set (belt-and-braces) ---------------------------
// foldUsageRecords must return heldPaths = EVERY readable record's paths when
// abortPrune is set, so a future caller that forgets to check the flag before using
// heldPaths still gets maximum protection rather than an empty set (which would be
// delete-ward).
static void testAbortFoldReturnsProtectAllSet() {
// Two readable records + one unreadable (nullopt) in between.
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // triggers abort
decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}));
// Live: only {T1} — so without protect-all, T2's path would be excluded.
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(fold.abortPrune);
// heldPaths must contain BOTH paths (protect-all over all readable records),
// not just {T1}'s path.
CHECK(fold.heldPaths.size() == 2);
bool hasPA = false, hasPB = false;
for (const std::string& p : fold.heldPaths) {
if (p == "pa.wav") hasPA = true;
if (p == "pb.wav") hasPB = true;
}
CHECK(hasPA);
CHECK(hasPB);
// All nullopt (every key unreadable): abort + empty heldPaths (nothing readable).
std::vector<std::optional<UsageRecord>> allNull;
allNull.push_back(std::nullopt);
const UsageFoldResult allNullFold =
foldUsageRecords(allNull, std::unordered_set<std::string>{}, false);
CHECK(allNullFold.abortPrune);
CHECK(allNullFold.heldPaths.empty()); // no readable records to protect
}
// --- truncated enumeration → protect-all -------------------------------------------
// The FX walk may truncate at kMaxContainerDepth, leaving a deeply-nested live instance
// missed. At the PURE layer this is indistinguishable from a genuine identity-matcher
// failure: anyInstanceLive stays false while records exist. The protect-all net in
// usageHeldPaths guarantees this resolves toward PROTECT, never toward delete — the same
// test shape as testZeroIdentifiedProtectsAll, stated here explicitly for the truncation
// failure mode.
static void testTruncatedWalkProtectsAll() {
// Records from two tracks that host instances; the FX walk (shell side) failed to
// identify ANY instance (e.g. truncated at depth, or a future matcher gap).
const std::vector<UsageRecord> records = {
makeRecord("{TRACK-A}", "N1", {UsageHold{"x", "nested-a.wav"}}),
makeRecord("{TRACK-B}", "N2", {UsageHold{"y", "nested-b.wav"}}),
};
// Shell reported anyLive=false (it couldn't identify any instance — truncated walk).
const std::vector<std::string> paths =
usageHeldPaths(records, std::unordered_set<std::string>{}, /*anyInstanceLive=*/false);
// Both paths must be protected — the protect-all net fires.
CHECK(paths.size() == 2);
bool hasA = false, hasB = false;
for (const std::string& p : paths) {
if (p == "nested-a.wav") hasA = true;
if (p == "nested-b.wav") hasB = true;
}
CHECK(hasA);
CHECK(hasB);
// Belt-and-braces: the same scenario through foldUsageRecords also protects all.
std::vector<std::optional<UsageRecord>> decoded;
for (const UsageRecord& r : records) decoded.push_back(r);
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{}, false);
CHECK(!fold.abortPrune);
CHECK(fold.heldPaths.size() == 2);
}
// --- the identity matcher ----------------------------------------------------------
// The common-case shapes: REAPER's fx_ident carries the .vst3 MODULE PATH (matched by
// the output-name needle "REASAMPLER_9000" — the display name, space-separated, can
// never match it); original_name carries the display name. UID hex matches when the
// rendering embeds it. All substring, case-insensitive.
static void testIdentityMatcher() {
const std::string uid = "ABCD1234ABCD1234ABCD1234ABCD1234";
const std::string name = "REASAMPLER 9000";
const std::string output = "REASAMPLER_9000";
// The module-path fx_ident shape: ONLY the output-name needle can catch this (the
// review's 🔴#2b — the display name alone silently failed the common case).
const std::string modulePath =
"C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3";
CHECK(identityMatches(modulePath, uid, name, output));
CHECK(!identityMatches(modulePath, uid, name, "")); // display name can't match it
// The display-name shape (original_name / renamed renderings).
CHECK(identityMatches("VST3: ReaSampler 9000", uid, name, output));
// The UID-hex shape (case-insensitive).
CHECK(identityMatches("vst3<abcd1234abcd1234abcd1234abcd1234>", uid, name, output));
// Beta over-protect (deliberate): stable needles are substrings of beta renderings
// — a stable extension protects beta instances' holds too (wider set only).
CHECK(identityMatches("...\\reasampler_9000_beta.vst3", uid, name, output));
CHECK(identityMatches("ReaSampler 9000 beta", uid, name, output));
// Non-matches stay non-matches.
CHECK(!identityMatches("", uid, name, output));
CHECK(!identityMatches("ReaComp", uid, name, output));
CHECK(!identityMatches("some_other_sampler.vst3", uid, name, output));
}
// --- 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}", "N1", {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");
// Zero instances identified anywhere -> the protect-all net keeps held.wav
// un-prunable THROUGH the composed pipeline too (matcher failure must never
// resolve toward delete; the accepted residual is that leftover records keep
// protecting until an instance is identified again).
const std::vector<std::string> refsNoneIdentified = mergeReferenced(
bankRefs, usageHeldPaths(records, std::unordered_set<std::string>{}, false));
const std::vector<std::string> orphansNone =
pruneOrphans(present, refsNoneIdentified, owned);
CHECK(orphansNone.size() == 1);
CHECK(orphansNone[0] == "orphan.wav");
// Stale-record cleanup still works when the matcher is demonstrably alive: another
// instance is identified on a different track, the record's own track is gone ->
// the record no longer counts -> held.wav is reclaimable again.
const std::vector<std::string> refsAfterDelete = mergeReferenced(
bankRefs,
usageHeldPaths(records, std::unordered_set<std::string>{"{OTHER}"}, true));
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();
testSiblingCollisionNeverDropsHolds();
testPlanUnionSkipOnlyWhenAlreadyPoisoned();
testPlanEmptyNonceNeverClaimsOwnership();
testPlanCrossTrackRemint();
testPlanOwnRecordAfterTrackMove();
testPlanUndecodableExistingRemints();
testHeldPathsLiveness();
testZeroIdentifiedProtectsAll();
testHeldPathsDedupAndEmptyPathSkip();
testTakeFxAttributedRecordIsProtected();
testUnreadableRecordAbortsPrune();
testAbortFoldReturnsProtectAllSet();
testTruncatedWalkProtectsAll();
testIdentityMatcher();
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;
}