pS-usage: captures held by live ReaSampler 9000 instances are un-prunable — instances publish usage_<guid> ext-state records (ComponentState v11), prune unions live holds into referenced
This commit is contained in:
+24
-2
@@ -337,6 +337,17 @@ target_include_directories(provenance PUBLIC src)
|
|||||||
add_library(assignment_request STATIC src/assignment_request.cpp)
|
add_library(assignment_request STATIC src/assignment_request.cpp)
|
||||||
target_include_directories(assignment_request PUBLIC src)
|
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 ("usage_<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),
|
||||||
|
# and the liveness fold (which records count against the live FX enumeration).
|
||||||
|
# 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
|
# 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 +
|
# native-OS-drag-out decision core: the gesture-boundary decision (drag state +
|
||||||
@@ -708,6 +719,14 @@ add_executable(assignment_request_tests tests/test_assignment_request.cpp)
|
|||||||
target_link_libraries(assignment_request_tests PRIVATE assignment_request)
|
target_link_libraries(assignment_request_tests PRIVATE assignment_request)
|
||||||
add_test(NAME assignment_request_tests COMMAND assignment_request_tests)
|
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
|
# 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
|
# 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).
|
# boundary (a VST3/REAPER type in the core would fail to compile/link here).
|
||||||
@@ -1016,8 +1035,9 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/tooltip.cpp
|
src/tooltip.cpp
|
||||||
src/card_meta.cpp
|
src/card_meta.cpp
|
||||||
src/card_drag.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})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
# 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'
|
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||||
@@ -1173,11 +1193,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,
|
# 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
|
# 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).
|
# 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
|
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||||
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||||
waveform_view bank_sync browser_scroll note_entry param_slider
|
waveform_view bank_sync browser_scroll note_entry param_slider
|
||||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
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;
|
# 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.
|
# 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})
|
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
@@ -69,4 +69,23 @@ inline constexpr const char* kProjExtBankGenKey = "bank_generation";
|
|||||||
// changing this spelling strands any pending request an already-shipped instrument watches.
|
// changing this spelling strands any pending request an already-shipped instrument watches.
|
||||||
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
inline constexpr const char* kProjExtAssignKey = "assign_request";
|
||||||
|
|
||||||
|
// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per
|
||||||
|
// instance — "usage_<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. 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 = "usage_";
|
||||||
|
|
||||||
|
// 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
|
} // namespace reasampler
|
||||||
|
|||||||
+11
-2
@@ -96,6 +96,7 @@
|
|||||||
#include "app_version.h"
|
#include "app_version.h"
|
||||||
#include "capture_paths.h"
|
#include "capture_paths.h"
|
||||||
#include "prune_reconcile.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)
|
#include "vst/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
@@ -359,9 +360,17 @@ PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned)
|
|||||||
|
|
||||||
// The decision lives in the pure core — read-only inputs from the book and manifest.
|
// 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
|
// 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
|
||||||
|
// usage_* records + the live FX enumeration; sample_usage decides liveness) — 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.bankDirAbs = bankDir;
|
||||||
scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths());
|
scan.orphans = pruneOrphans(
|
||||||
|
present, mergeReferenced(book.referencedPaths(), liveInstanceHeldPaths(proj)),
|
||||||
|
owned.paths());
|
||||||
return scan;
|
return scan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -184,7 +184,11 @@ public:
|
|||||||
// relative machinery the index/persist use — never a stale absolute path, so it is
|
// 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
|
// 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),
|
// 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 usage_* 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.
|
||||||
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
||||||
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
// 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
|
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
|
|||||||
return orphans;
|
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(
|
PruneReport buildPruneReport(
|
||||||
const std::vector<std::string>& orphans,
|
const std::vector<std::string>& orphans,
|
||||||
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
|
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
|
|||||||
const std::vector<std::string>& referenced,
|
const std::vector<std::string>& referenced,
|
||||||
const std::vector<std::string>& owned);
|
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.
|
// 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`;
|
// 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.
|
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// sample_usage.cpp — see sample_usage.h. Pure: standard library only.
|
||||||
|
|
||||||
|
#include "sample_usage.h"
|
||||||
|
|
||||||
|
#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, 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;
|
||||||
|
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 std::string& lastPublishedThisLifetime,
|
||||||
|
const UsageRecord& mine) {
|
||||||
|
UsagePublishPlan plan;
|
||||||
|
plan.wire = encodeUsageRecord(mine);
|
||||||
|
|
||||||
|
if (!existing || existing->empty()) {
|
||||||
|
// Fresh key — write mine.
|
||||||
|
} else if (!lastPublishedThisLifetime.empty() &&
|
||||||
|
*existing == lastPublishedThisLifetime) {
|
||||||
|
// The key holds exactly what THIS instance wrote this lifetime: the normal
|
||||||
|
// single-owner path. Clean replace (released holds genuinely drop).
|
||||||
|
if (plan.wire == lastPublishedThisLifetime) plan.skipWrite = true;
|
||||||
|
} else {
|
||||||
|
const std::optional<UsageRecord> theirs = decodeUsageRecord(*existing);
|
||||||
|
if (!theirs) {
|
||||||
|
// Undecodable existing value — overwrite with mine (it protects nothing).
|
||||||
|
} else if (theirs->trackGuid == mine.trackGuid) {
|
||||||
|
// Foreign value from MY OWN track: my own persisted record from the last
|
||||||
|
// session, or a same-track copy-sibling. Either way no hold in it may be
|
||||||
|
// dropped by me — union, existing-first, de-duped. Over-protects (fail-safe)
|
||||||
|
// until the next clean replace.
|
||||||
|
UsageRecord merged;
|
||||||
|
merged.trackGuid = mine.trackGuid;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
plan.wire = encodeUsageRecord(merged);
|
||||||
|
} else {
|
||||||
|
// 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;
|
||||||
|
for (const UsageRecord& rec : records) {
|
||||||
|
const bool live = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#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 ("usage_<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 "usage_"-prefixed keys.
|
||||||
|
//
|
||||||
|
// -- 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. The residual: a deleted instance whose track still
|
||||||
|
// hosts a sibling 9000 keeps its record alive (false-PROTECT only — prune skips a file
|
||||||
|
// it could have reclaimed; never the delete direction). 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. The publish
|
||||||
|
// plan resolves every collision in the fail-safe direction:
|
||||||
|
// * existing value == what THIS instance wrote this lifetime -> clean replace (the
|
||||||
|
// normal single-owner path; holds the instance released genuinely drop).
|
||||||
|
// * existing value is foreign but from the SAME track -> UNION of holds (a same-track
|
||||||
|
// copy; neither sibling's holds may be dropped — over-protects until the next clean
|
||||||
|
// replace, never under-protects).
|
||||||
|
// * existing value is foreign from a DIFFERENT track -> 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) 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::vector<UsageHold> holds;
|
||||||
|
|
||||||
|
bool operator==(const UsageRecord& o) const {
|
||||||
|
return trackGuid == o.trackGuid && 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>':'<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 extension
|
||||||
|
// treats an undecodable record as absent — it can protect nothing 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 be byte-identical to what this instance
|
||||||
|
// already wrote this lifetime (idle reload tick) — skip the ext-state
|
||||||
|
// churn entirely.
|
||||||
|
// * 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 and what this instance
|
||||||
|
// last wrote THIS LIFETIME (empty string = nothing yet this lifetime — a fresh load;
|
||||||
|
// the existing value is then this instance's own persisted record from the last
|
||||||
|
// session, OR a copy-source's record: same-track -> union, other-track -> remint).
|
||||||
|
// * existing absent or undecodable -> write mine.
|
||||||
|
// * existing == lastPublishedThisLifetime -> write mine (clean replace).
|
||||||
|
// * existing.trackGuid == mine.trackGuid -> write union(existing.holds, mine.holds)
|
||||||
|
// (existing-first order, de-duped).
|
||||||
|
// * else -> remint = true, write mine (new key).
|
||||||
|
UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
|
||||||
|
const std::string& lastPublishedThisLifetime,
|
||||||
|
const UsageRecord& mine);
|
||||||
|
|
||||||
|
// The prune-side 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).
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// 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 <cctype>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "app_version.h" // vstPluginName (channel display-name fallback match)
|
||||||
|
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
|
||||||
|
#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
|
||||||
|
#include "sample_usage.h" // decodeUsageRecord, usageHeldPaths (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 {
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does this FX identity string name a ReaSampler 9000 of THIS channel? Primary match:
|
||||||
|
// fx_ident contains the channel's 32-hex class UID (REAPER renders VST3 idents with the
|
||||||
|
// UID hex embedded; case varies, so compare uppercased). Fallback: the identity carries
|
||||||
|
// the channel display name ("ReaSampler 9000" / "ReaSampler 9000 beta") — belt and
|
||||||
|
// braces for an fx_ident rendering that omits the hex. A false positive here only
|
||||||
|
// widens the protected set (fail-safe direction); it can never cause a delete.
|
||||||
|
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper) {
|
||||||
|
if (identity.empty()) return false;
|
||||||
|
const std::string up = toUpperAscii(identity);
|
||||||
|
if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true;
|
||||||
|
return !nameUpper.empty() && up.find(nameUpper) != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read one named config parm of a track FX into a string ("" on failure/absence).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
|
||||||
|
// ReaSampler 9000. Containers are walked via the documented container_count /
|
||||||
|
// container_item.X addressing (v7.06+); `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 trackFxSubtreeHasInstance(MediaTrack* tr, int fxId,
|
||||||
|
const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper, int depth) {
|
||||||
|
if (identityMatches(trackFxParm(tr, fxId, "fx_ident"), uidHexUpper, nameUpper) ||
|
||||||
|
identityMatches(trackFxParm(tr, fxId, "original_name"), uidHexUpper, nameUpper))
|
||||||
|
return true;
|
||||||
|
if (depth <= 0) return false;
|
||||||
|
const std::string countStr = trackFxParm(tr, fxId, "container_count");
|
||||||
|
if (countStr.empty()) return false; // not a container
|
||||||
|
const int n = std::atoi(countStr.c_str());
|
||||||
|
for (int k = 0; k < n; ++k) {
|
||||||
|
const std::string item =
|
||||||
|
trackFxParm(tr, 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 (trackFxSubtreeHasInstance(tr, childId, uidHexUpper, nameUpper, depth - 1))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
|
||||||
|
// (index | 0x1000000), containers recursively.
|
||||||
|
bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper) {
|
||||||
|
constexpr int kMaxContainerDepth = 8;
|
||||||
|
const int n = TrackFX_GetCount(tr);
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
if (trackFxSubtreeHasInstance(tr, i, uidHexUpper, nameUpper, kMaxContainerDepth))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const int rec = TrackFX_GetRecCount(tr);
|
||||||
|
for (int i = 0; i < rec; ++i) {
|
||||||
|
if (trackFxSubtreeHasInstance(tr, 0x1000000 + i, uidHexUpper, nameUpper,
|
||||||
|
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). No container recursion here: take chains are queried flat, and a sampler
|
||||||
|
// nested in a take-FX container is exotic enough that the empty-trackGuid any-instance
|
||||||
|
// fallback (sample_usage liveness rule) is the documented safety net.
|
||||||
|
bool itemHasInstance(MediaItem* item, const std::string& uidHexUpper,
|
||||||
|
const std::string& nameUpper) {
|
||||||
|
const int takes = CountTakes(item);
|
||||||
|
for (int t = 0; t < takes; ++t) {
|
||||||
|
MediaItem_Take* take = GetMediaItemTake(item, t);
|
||||||
|
if (!take) continue;
|
||||||
|
const int n = TakeFX_GetCount(take);
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
char buf[2048] = {0};
|
||||||
|
if (TakeFX_GetNamedConfigParm(take, i, "fx_ident", buf,
|
||||||
|
static_cast<int>(sizeof(buf))) &&
|
||||||
|
identityMatches(buf, uidHexUpper, nameUpper))
|
||||||
|
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 — and an undecodable
|
||||||
|
// record protects nothing, which is the DANGEROUS direction here. Empty on absence.
|
||||||
|
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::string s(buf.data());
|
||||||
|
if (static_cast<int>(s.size()) + 1 < cap) return s;
|
||||||
|
// else possibly truncated -> grow and retry
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
|
||||||
|
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
|
||||||
|
|
||||||
|
// 1. Enumerate the usage_* keys and decode each record. Key names first (values via
|
||||||
|
// the growing reader — EnumProjExtState's fixed val buffer could truncate a large
|
||||||
|
// record, and a truncated record decodes to nothing = protects nothing).
|
||||||
|
std::vector<std::string> usageKeys;
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
const std::string prefix = kProjExtUsageKeyPrefix;
|
||||||
|
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<UsageRecord> records;
|
||||||
|
records.reserve(usageKeys.size());
|
||||||
|
for (const std::string& key : usageKeys) {
|
||||||
|
const std::string value = readExtStateValue(proj, key.c_str());
|
||||||
|
if (value.empty()) continue;
|
||||||
|
if (std::optional<UsageRecord> rec = decodeUsageRecord(value)) {
|
||||||
|
records.push_back(std::move(*rec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (records.empty()) return {}; // no instance ever published — skip the FX scan
|
||||||
|
|
||||||
|
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen identity pair drives
|
||||||
|
// every match; a track needs only ONE instance to keep all its records live.
|
||||||
|
const std::string uidHexUpper = toUpperAscii(vstClassIdHex());
|
||||||
|
const std::string nameUpper = toUpperAscii(vstPluginName());
|
||||||
|
std::unordered_set<std::string> liveTrackGuids;
|
||||||
|
bool anyLive = false;
|
||||||
|
|
||||||
|
if (MediaTrack* master = GetMasterTrack(proj)) {
|
||||||
|
if (trackHasInstance(master, uidHexUpper, nameUpper)) {
|
||||||
|
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, uidHexUpper, nameUpper)) {
|
||||||
|
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, uidHexUpper, nameUpper)) {
|
||||||
|
if (MediaTrack* tr = GetMediaItemTrack(item)) {
|
||||||
|
liveTrackGuids.insert(guidString(tr));
|
||||||
|
}
|
||||||
|
anyLive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The pure liveness fold decides which records count.
|
||||||
|
return usageHeldPaths(records, liveTrackGuids, anyLive);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for
|
||||||
|
// the pure core 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?
|
||||||
|
//
|
||||||
|
// Three reads, no writes (the prune scan's READ-ONLY contract holds):
|
||||||
|
// 1. Enumerate every "usage_<guid>" key in the "reasampler" ext-state namespace
|
||||||
|
// (EnumProjExtState) and decode each record (sample_usage wire).
|
||||||
|
// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks
|
||||||
|
// (master included), normal + record/input chains, FX containers recursively, and
|
||||||
|
// take FX — matching by fx_ident containing this channel's VST3 class-UID hex
|
||||||
|
// (instrument_drop::vstClassIdHex, the same frozen constants the factory
|
||||||
|
// registers) with the channel display name as a fallback match. OFFLINE FX are
|
||||||
|
// included: chain enumeration is chunk-level, so a Design-View-parked instance
|
||||||
|
// still protects its holds (the reason the instrument never clears its own key —
|
||||||
|
// see sample_usage.h).
|
||||||
|
// 3. Fold with the pure liveness rule (sample_usage::usageHeldPaths): a record counts
|
||||||
|
// iff its publishing track still hosts >= 1 instance (or, for a record with no
|
||||||
|
// track context, iff any instance exists at all).
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
|
||||||
|
// Every project-relative path held by a live ReaSampler 9000 instance in `proj`
|
||||||
|
// (nullptr = active project), de-duped, in record order. Empty when no usage records
|
||||||
|
// exist (the common no-instances case — the FX enumeration is skipped entirely).
|
||||||
|
// READ-ONLY: no ext-state write, no project mutation.
|
||||||
|
std::vector<std::string> liveInstanceHeldPaths(void* proj);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -42,6 +42,9 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
|||||||
getProjExtState_ = nullptr;
|
getProjExtState_ = nullptr;
|
||||||
enumProjExtState_ = nullptr;
|
enumProjExtState_ = nullptr;
|
||||||
enumProjects_ = nullptr;
|
enumProjects_ = nullptr;
|
||||||
|
setProjExtState_ = nullptr;
|
||||||
|
getTrackGuid_ = nullptr;
|
||||||
|
guidToString_ = nullptr;
|
||||||
hostApp_ = nullptr;
|
hostApp_ = nullptr;
|
||||||
if (!context) return false;
|
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.
|
// persist.cpp uses, so the instrument derives the project directory identically.
|
||||||
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
||||||
reaper->getReaperApi("EnumProjects"));
|
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;
|
return getProjExtState_ != nullptr;
|
||||||
}
|
}
|
||||||
@@ -97,6 +109,36 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
|
|||||||
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
|
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-"usage_" 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_(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 true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
std::string ReaperBridge::activeProjectDir() {
|
||||||
if (!enumProjects_) return {};
|
if (!enumProjects_) return {};
|
||||||
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
||||||
|
|||||||
@@ -57,6 +57,23 @@ public:
|
|||||||
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
||||||
std::string activeProjectDir();
|
std::string activeProjectDir();
|
||||||
|
|
||||||
|
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
|
||||||
|
// ext-state write. `usageKey` MUST carry the "usage_" 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. 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:
|
private:
|
||||||
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
||||||
// verified against reaper_plugin_functions.h.
|
// verified against reaper_plugin_functions.h.
|
||||||
@@ -69,11 +86,23 @@ private:
|
|||||||
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
|
// ~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.
|
// and reads the .rpp path from the out-buffer exactly as persist.cpp does.
|
||||||
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
|
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)
|
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
|
||||||
GetProjExtStateFn getProjExtState_ = nullptr;
|
GetProjExtStateFn getProjExtState_ = nullptr;
|
||||||
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
||||||
EnumProjectsFn enumProjects_ = nullptr;
|
EnumProjectsFn enumProjects_ = nullptr;
|
||||||
|
SetProjExtStateFn setProjExtState_ = nullptr;
|
||||||
|
GetTrackGuidFn getTrackGuid_ = nullptr;
|
||||||
|
GuidToStringFn guidToString_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::vst
|
} // namespace reasampler::vst
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <random>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
#include "reasampler_editor.h"
|
#include "reasampler_editor.h"
|
||||||
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
#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_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)
|
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||||
|
|
||||||
using namespace Steinberg;
|
using namespace Steinberg;
|
||||||
@@ -47,6 +50,21 @@ constexpr std::size_t kPreserveVoiceCap = 8;
|
|||||||
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
|
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
|
||||||
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
|
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
|
||||||
|
|
||||||
|
// pS-usage: mint a fresh per-instance publish identity — 32 lowercase hex chars from the
|
||||||
|
// OS entropy source. 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
|
// 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".
|
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||||
@@ -258,6 +276,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||||
sampleRefs_ = cs.sampleRefs;
|
sampleRefs_ = cs.sampleRefs;
|
||||||
}
|
}
|
||||||
|
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
|
||||||
|
// minted on first publish). lastPublishedUsageWire_ resets: a restored blob is a NEW
|
||||||
|
// LIFETIME for the copy-collision analysis (planUsagePublish must compare the key's
|
||||||
|
// current value against what THIS incarnation wrote, not a previous one's writes).
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||||
|
instanceGuid_ = cs.instanceGuid;
|
||||||
|
lastPublishedUsageWire_.clear();
|
||||||
|
}
|
||||||
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
// 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).
|
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
||||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||||
@@ -302,6 +329,12 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
|||||||
state_out.sampleRefs = sampleRefs();
|
state_out.sampleRefs = sampleRefs();
|
||||||
retainRefs(state_out.sampleRefs,
|
retainRefs(state_out.sampleRefs,
|
||||||
referencedSampleIds(state_out.selectionId, state_out.map));
|
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);
|
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||||
if (!bytes.empty()) {
|
if (!bytes.empty()) {
|
||||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||||
@@ -601,9 +634,55 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
|||||||
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
||||||
// pointer is re-owned by the graveyard.
|
// pointer is re-owned by the graveyard.
|
||||||
publishBuiltLocked(std::move(built));
|
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;
|
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();
|
||||||
|
|
||||||
|
const std::optional<std::string> existing =
|
||||||
|
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
||||||
|
const UsagePublishPlan plan =
|
||||||
|
planUsagePublish(existing, lastPublishedUsageWire_, 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; // byte-identical to what this lifetime already wrote — idle tick
|
||||||
|
}
|
||||||
|
if (bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire)) {
|
||||||
|
lastPublishedUsageWire_ = plan.wire;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||||
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||||
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
|
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||||
|
|||||||
@@ -302,6 +302,19 @@ private:
|
|||||||
// safety-critical swap dance (see the handoff proof below).
|
// safety-critical swap dance (see the handoff proof below).
|
||||||
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||||
|
|
||||||
|
// pS-usage: publish this instance's held captures to its per-instance ext-state key
|
||||||
|
// ("usage_<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_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
||||||
@@ -368,6 +381,17 @@ private:
|
|||||||
std::mutex refsMutex_;
|
std::mutex refsMutex_;
|
||||||
SampleRefs sampleRefs_;
|
SampleRefs sampleRefs_;
|
||||||
|
|
||||||
|
// pS-usage publish identity + lifetime memory (see publishUsage). instanceGuid_ is
|
||||||
|
// the persisted per-instance identity (ComponentState v11; empty until first
|
||||||
|
// publish); lastPublishedUsageWire_ is what THIS lifetime last wrote — the
|
||||||
|
// planUsagePublish discriminator between "my own key" (clean replace) and "a
|
||||||
|
// copy-source's key" (union / re-mint), cleared on setState (a new blob is a new
|
||||||
|
// lifetime for the collision analysis). Guarded by usageMutex_ (publish runs under
|
||||||
|
// reloadMutex_ but getState/setState do not).
|
||||||
|
std::mutex usageMutex_;
|
||||||
|
std::string instanceGuid_;
|
||||||
|
std::string lastPublishedUsageWire_;
|
||||||
|
|
||||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
// 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
|
// 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.
|
// on the audio thread — process renders against the host's negotiated output channel count.
|
||||||
|
|||||||
@@ -759,6 +759,11 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
|
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
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 —
|
// 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).
|
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
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)
|
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||||
}
|
}
|
||||||
if (version != kComponentStateVersion &&
|
if (version != kComponentStateVersion &&
|
||||||
|
version != kSelectionZonesRefsV10Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||||
@@ -924,6 +930,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
}
|
}
|
||||||
if (!r.ok) return out;
|
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();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
|||||||
+22
-5
@@ -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
|
// 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
|
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
// 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
|
||||||
|
// "usage_<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
|
// 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).
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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 —
|
// 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):
|
// 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 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).
|
// * 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).
|
// * 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).
|
// * 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
|
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||||
// path once (then re-saves self-contained).
|
// path once (then re-saves self-contained).
|
||||||
SampleRefs sampleRefs;
|
SampleRefs sampleRefs;
|
||||||
|
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||||
|
// "usage_<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).
|
// 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
|
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||||
|
|||||||
@@ -383,6 +383,35 @@ static void testDeletePlanDeduplicatesConfirmed() {
|
|||||||
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
|
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() {
|
int main() {
|
||||||
testNullTestAllReferenced();
|
testNullTestAllReferenced();
|
||||||
testFormulaMixedPopulations();
|
testFormulaMixedPopulations();
|
||||||
@@ -411,6 +440,8 @@ int main() {
|
|||||||
testDeletePlanNeverSweepsUnconfirmed();
|
testDeletePlanNeverSweepsUnconfirmed();
|
||||||
testDeletePlanEmptyInputs();
|
testDeletePlanEmptyInputs();
|
||||||
testDeletePlanDeduplicatesConfirmed();
|
testDeletePlanDeduplicatesConfirmed();
|
||||||
|
testMergeReferencedProtectsInstanceHeldFile();
|
||||||
|
testMergeReferencedOrderDedupAndExactMatch();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
|
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
|
||||||
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
|
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
|
||||||
|
|||||||
@@ -2272,6 +2272,79 @@ static void testComponentStateV9LiftsToEmptyRefs() {
|
|||||||
CHECK(back.map.zones.empty());
|
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() {
|
static void testReferencedSampleIdsDedup() {
|
||||||
// Selection first, then map order, duplicates collapsed; an empty selection contributes
|
// Selection first, then map order, duplicates collapsed; an empty selection contributes
|
||||||
// nothing (no phantom "" id in the refs table).
|
// nothing (no phantom "" id in the refs table).
|
||||||
@@ -2524,6 +2597,8 @@ int main() {
|
|||||||
testResolveFromRefsMissingRefDrops();
|
testResolveFromRefsMissingRefDrops();
|
||||||
testResolveFromRefsMatchesBankResolve();
|
testResolveFromRefsMatchesBankResolve();
|
||||||
testComponentStateV9LiftsToEmptyRefs();
|
testComponentStateV9LiftsToEmptyRefs();
|
||||||
|
testInstanceGuidRoundTripV11();
|
||||||
|
testComponentStateV10LiftsToEmptyGuid();
|
||||||
testReferencedSampleIdsDedup();
|
testReferencedSampleIdsDedup();
|
||||||
testRefreshRefsFromBankUpsertAndOwnership();
|
testRefreshRefsFromBankUpsertAndOwnership();
|
||||||
testRetainRefsFiltersToPlayedSet();
|
testRetainRefsFiltersToPlayedSet();
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Standalone tests for reasampler::sample_usage — no REAPER, no framework.
|
||||||
|
// The pS-usage seam: instances publish held captures; the extension folds live
|
||||||
|
// instances' holds into the prune's `referenced` set. Tested hard here because this is
|
||||||
|
// the prune-protection guarantee: a capture held by a live instance must be IMPOSSIBLE
|
||||||
|
// to prune (the composed proof at the bottom links prune_reconcile and shows
|
||||||
|
// pruneOrphans can never emit a held path), while a stale record from a deleted
|
||||||
|
// instance must NOT permanently block reclaim (the liveness fold).
|
||||||
|
//
|
||||||
|
// Covers: wire round-trip (empty / adversarial bytes), malformed -> nullopt, the
|
||||||
|
// publish plan's four branches (fresh key / clean replace / same-track union /
|
||||||
|
// cross-track re-mint) + skipWrite idempotence, the liveness fold (live, dead-track,
|
||||||
|
// empty-guid fallback, de-dup), and the composed pruneOrphans exclusion proof.
|
||||||
|
|
||||||
|
#include "../src/sample_usage.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../src/prune_reconcile.h" // mergeReferenced + pruneOrphans (composed proof)
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
UsageRecord makeRecord(const std::string& trackGuid,
|
||||||
|
std::vector<UsageHold> holds) {
|
||||||
|
UsageRecord r;
|
||||||
|
r.trackGuid = trackGuid;
|
||||||
|
r.holds = std::move(holds);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- wire round-trip ----------------------------------------------------------
|
||||||
|
|
||||||
|
static void testRoundTrip() {
|
||||||
|
const UsageRecord rec = makeRecord(
|
||||||
|
"{12345678-1234-1234-1234-1234567890AB}",
|
||||||
|
{UsageHold{"cap-1700-kick", "reasampler_bank/kick.wav"},
|
||||||
|
UsageHold{"cap-1701-snare", "reasampler_bank/snare.wav"}});
|
||||||
|
const std::string wire = encodeUsageRecord(rec);
|
||||||
|
auto back = decodeUsageRecord(wire);
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(*back == rec);
|
||||||
|
CHECK(encodeUsageRecord(*back) == wire); // deterministic re-encode
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripEmptyHoldsAndEmptyGuid() {
|
||||||
|
// An empty-holds record is LEGAL (an instance releasing everything it held), and an
|
||||||
|
// empty trackGuid is legal (no track context at publish -> any-instance fallback).
|
||||||
|
const UsageRecord rec = makeRecord("", {});
|
||||||
|
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back->trackGuid.empty());
|
||||||
|
CHECK(back->holds.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripAdversarialBytes() {
|
||||||
|
// Ids/paths carrying the wire's own metacharacters must survive whole (the whole
|
||||||
|
// point of length-prefixing): ':' delimiters, digits, the magic tag itself.
|
||||||
|
const UsageRecord rec = makeRecord(
|
||||||
|
"12:34:guid-with-colons",
|
||||||
|
{UsageHold{"rsusage1-lookalike", "path with spaces/and:colons/7:x.wav"}});
|
||||||
|
auto back = decodeUsageRecord(encodeUsageRecord(rec));
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(*back == rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testDecodeMalformed() {
|
||||||
|
CHECK(!decodeUsageRecord("").has_value());
|
||||||
|
CHECK(!decodeUsageRecord("garbage").has_value());
|
||||||
|
CHECK(!decodeUsageRecord("rsusage1").has_value()); // truncated after magic
|
||||||
|
CHECK(!decodeUsageRecord("rsusage2" "0:1:0").has_value()); // wrong magic version
|
||||||
|
// Truncated mid-holds: claims 2 holds, carries 1.
|
||||||
|
UsageRecord one = makeRecord("{G}", {UsageHold{"a", "p.wav"}});
|
||||||
|
std::string wire = encodeUsageRecord(one);
|
||||||
|
// Rewrite the count field "1:1" -> "1:2" (count is the 2nd field: len 1, value '1').
|
||||||
|
const std::string needle = "1:1"; // count field for one hold
|
||||||
|
const std::size_t pos = wire.find(needle, std::string("rsusage1").size() + 4);
|
||||||
|
CHECK(pos != std::string::npos);
|
||||||
|
wire[pos + 2] = '2';
|
||||||
|
CHECK(!decodeUsageRecord(wire).has_value());
|
||||||
|
// Trailing garbage after a complete record -> reject whole.
|
||||||
|
CHECK(!decodeUsageRecord(encodeUsageRecord(one) + "x").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- publish plan --------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testPlanFreshKey() {
|
||||||
|
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "p.wav"}});
|
||||||
|
const UsagePublishPlan plan = planUsagePublish(std::nullopt, "", mine);
|
||||||
|
CHECK(!plan.remint);
|
||||||
|
CHECK(!plan.skipWrite);
|
||||||
|
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlanCleanReplaceAndSkip() {
|
||||||
|
// The key holds exactly what this lifetime wrote -> clean replace; released holds drop.
|
||||||
|
const UsageRecord prev = makeRecord("{T1}", {UsageHold{"a", "pa.wav"},
|
||||||
|
UsageHold{"b", "pb.wav"}});
|
||||||
|
const std::string prevWire = encodeUsageRecord(prev);
|
||||||
|
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsagePublishPlan plan = planUsagePublish(prevWire, prevWire, mine);
|
||||||
|
CHECK(!plan.remint);
|
||||||
|
CHECK(!plan.skipWrite);
|
||||||
|
auto back = decodeUsageRecord(plan.wire);
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back->holds.size() == 1); // 'b' genuinely released — NOT unioned back in
|
||||||
|
|
||||||
|
// Unchanged play-set -> byte-identical write -> skip (idle reload tick).
|
||||||
|
const UsagePublishPlan idle = planUsagePublish(prevWire, prevWire, prev);
|
||||||
|
CHECK(idle.skipWrite);
|
||||||
|
CHECK(!idle.remint);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlanSameTrackUnion() {
|
||||||
|
// First publish of a lifetime (lastPublished empty) over a same-track existing value:
|
||||||
|
// my own last-session record OR a same-track copy-sibling — either way UNION, never
|
||||||
|
// drop (the fail-safe direction; a sibling's holds must survive my write).
|
||||||
|
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"b", "pb.wav"},
|
||||||
|
UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsagePublishPlan plan =
|
||||||
|
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||||
|
CHECK(!plan.remint);
|
||||||
|
auto back = decodeUsageRecord(plan.wire);
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back->trackGuid == "{T1}");
|
||||||
|
CHECK(back->holds.size() == 2); // a (existing-first) + b, de-duped
|
||||||
|
CHECK(back->holds[0].sampleId == "a");
|
||||||
|
CHECK(back->holds[1].sampleId == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlanCrossTrackRemint() {
|
||||||
|
// The key holds a foreign record from ANOTHER track: this state was cloned there
|
||||||
|
// (FX copy / track duplication) — take a fresh identity, leave theirs untouched.
|
||||||
|
const UsageRecord theirs = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsageRecord mine = makeRecord("{T2}", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsagePublishPlan plan =
|
||||||
|
planUsagePublish(encodeUsageRecord(theirs), "", mine);
|
||||||
|
CHECK(plan.remint);
|
||||||
|
CHECK(plan.wire == encodeUsageRecord(mine)); // written under the NEW key
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlanUndecodableExisting() {
|
||||||
|
// An undecodable existing value protects nothing — overwrite with mine.
|
||||||
|
const UsageRecord mine = makeRecord("{T1}", {UsageHold{"a", "pa.wav"}});
|
||||||
|
const UsagePublishPlan plan = planUsagePublish(std::string("corrupt"), "", mine);
|
||||||
|
CHECK(!plan.remint);
|
||||||
|
CHECK(plan.wire == encodeUsageRecord(mine));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- liveness fold ---------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testHeldPathsLiveness() {
|
||||||
|
const std::vector<UsageRecord> records = {
|
||||||
|
makeRecord("{LIVE}", {UsageHold{"a", "pa.wav"}}),
|
||||||
|
makeRecord("{DEAD}", {UsageHold{"b", "pb.wav"}}), // deleted track/instance
|
||||||
|
makeRecord("", {UsageHold{"c", "pc.wav"}}), // no track context
|
||||||
|
};
|
||||||
|
const std::unordered_set<std::string> live = {"{LIVE}"};
|
||||||
|
|
||||||
|
// Live-track record counts; dead-track record is EXCLUDED (no stale false-protect);
|
||||||
|
// empty-guid record counts while ANY instance lives (fail-safe fallback).
|
||||||
|
const std::vector<std::string> withAny = usageHeldPaths(records, live, true);
|
||||||
|
CHECK(withAny.size() == 2);
|
||||||
|
CHECK(withAny[0] == "pa.wav");
|
||||||
|
CHECK(withAny[1] == "pc.wav");
|
||||||
|
|
||||||
|
// No instance anywhere -> empty-guid fallback closes too; only live-track survives.
|
||||||
|
const std::vector<std::string> noAny = usageHeldPaths(records, live, false);
|
||||||
|
CHECK(noAny.size() == 1);
|
||||||
|
CHECK(noAny[0] == "pa.wav");
|
||||||
|
|
||||||
|
// Zero live instances at all -> nothing protected (a project whose instances were
|
||||||
|
// all deleted cannot be permanently blocked by leftover records).
|
||||||
|
const std::vector<std::string> none =
|
||||||
|
usageHeldPaths(records, std::unordered_set<std::string>{}, false);
|
||||||
|
CHECK(none.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testHeldPathsDedupAndEmptyPathSkip() {
|
||||||
|
const std::vector<UsageRecord> records = {
|
||||||
|
makeRecord("{T}", {UsageHold{"a", "shared.wav"}, UsageHold{"x", ""}}),
|
||||||
|
makeRecord("{T}", {UsageHold{"b", "shared.wav"}, UsageHold{"c", "own.wav"}}),
|
||||||
|
};
|
||||||
|
const std::unordered_set<std::string> live = {"{T}"};
|
||||||
|
const std::vector<std::string> paths = usageHeldPaths(records, live, true);
|
||||||
|
CHECK(paths.size() == 2); // shared.wav de-duped across records; empty path skipped
|
||||||
|
CHECK(paths[0] == "shared.wav");
|
||||||
|
CHECK(paths[1] == "own.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the composed prune-protection proof -----------------------------------------
|
||||||
|
// The definition-of-done property at the pure layer: a capture held by a live instance
|
||||||
|
// lands in the referenced union, and pruneOrphans can NEVER emit it — even when the
|
||||||
|
// bank no longer references it (deleted from the bank while the instance kept its ref)
|
||||||
|
// and it is owned + present (the exact preconditions under which it WOULD be reclaimed).
|
||||||
|
|
||||||
|
static void testInstanceHoldMakesPathUnprunable() {
|
||||||
|
const std::vector<std::string> present = {"held.wav", "orphan.wav"};
|
||||||
|
const std::vector<std::string> owned = {"held.wav", "orphan.wav"};
|
||||||
|
const std::vector<std::string> bankRefs = {}; // bank does NOT reference either
|
||||||
|
|
||||||
|
// Without instance usage both are orphans (the pre-pS-usage behavior).
|
||||||
|
CHECK(pruneOrphans(present, bankRefs, owned).size() == 2);
|
||||||
|
|
||||||
|
// A live instance holds held.wav -> the union protects it; orphan.wav still reclaims.
|
||||||
|
const std::vector<UsageRecord> records = {
|
||||||
|
makeRecord("{T}", {UsageHold{"id-held", "held.wav"}})};
|
||||||
|
const std::unordered_set<std::string> live = {"{T}"};
|
||||||
|
const std::vector<std::string> referenced =
|
||||||
|
mergeReferenced(bankRefs, usageHeldPaths(records, live, true));
|
||||||
|
const std::vector<std::string> orphans = pruneOrphans(present, referenced, owned);
|
||||||
|
CHECK(orphans.size() == 1);
|
||||||
|
CHECK(orphans[0] == "orphan.wav");
|
||||||
|
|
||||||
|
// The instance (and its track) deleted -> the record no longer counts -> held.wav
|
||||||
|
// is reclaimable again (no permanent stale-key block).
|
||||||
|
const std::vector<std::string> refsAfterDelete = mergeReferenced(
|
||||||
|
bankRefs, usageHeldPaths(records, std::unordered_set<std::string>{}, false));
|
||||||
|
CHECK(pruneOrphans(present, refsAfterDelete, owned).size() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMergeReferencedOrderAndDedup() {
|
||||||
|
const std::vector<std::string> a = {"p1.wav", "p2.wav"};
|
||||||
|
const std::vector<std::string> b = {"p2.wav", "p3.wav", "p1.wav"};
|
||||||
|
const std::vector<std::string> merged = mergeReferenced(a, b);
|
||||||
|
CHECK(merged.size() == 3);
|
||||||
|
CHECK(merged[0] == "p1.wav");
|
||||||
|
CHECK(merged[1] == "p2.wav");
|
||||||
|
CHECK(merged[2] == "p3.wav");
|
||||||
|
CHECK(mergeReferenced({}, {}).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testRoundTrip();
|
||||||
|
testRoundTripEmptyHoldsAndEmptyGuid();
|
||||||
|
testRoundTripAdversarialBytes();
|
||||||
|
testDecodeMalformed();
|
||||||
|
testPlanFreshKey();
|
||||||
|
testPlanCleanReplaceAndSkip();
|
||||||
|
testPlanSameTrackUnion();
|
||||||
|
testPlanCrossTrackRemint();
|
||||||
|
testPlanUndecodableExisting();
|
||||||
|
testHeldPathsLiveness();
|
||||||
|
testHeldPathsDedupAndEmptyPathSkip();
|
||||||
|
testInstanceHoldMakesPathUnprunable();
|
||||||
|
testMergeReferencedOrderAndDedup();
|
||||||
|
|
||||||
|
if (g_fail == 0) {
|
||||||
|
std::printf("sample_usage_tests: all tests passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("sample_usage_tests: %d FAILURES\n", g_fail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user